Skip to content

Commit ec7d836

Browse files
Jeffrey Lauwersclaude
andcommitted
feat(SkipLink): toegankelijkheidskoppeling om herhalende navigatie te omzeilen (#148)
- SkipLink component met clip-path verbergen + focus-visible positieonering - Design tokens: z-index 600, padding, border-radius, offset-block/inline-start - Z-index 500 toegevoegd aan modal-dialog.json en drawer.json - Backdrop comment bijgewerkt met volledige z-index schaal (400→500→600) - React component met forwardRef, href (verplicht) en children (default NL-tekst) - 10 tests, TypeScript schoon, 0 lint fouten - Storybook: Default, AllStates, ShortText, LongText, MultipleSkipLinks, RTL stories Closes #148 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 411c21f commit ec7d836

14 files changed

Lines changed: 510 additions & 2 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* SkipLink Component
3+
* Accessibility component — eerste focusbare element op de pagina.
4+
* Verborgen totdat de gebruiker er met Tab op focust.
5+
*/
6+
7+
.dsn-skip-link {
8+
/* Verborgen standaard — zelfde patroon als dsn-visually-hidden */
9+
position: absolute;
10+
clip-path: inset(50%);
11+
overflow: hidden;
12+
white-space: nowrap;
13+
14+
/* Hoge z-index zodat de link altijd boven andere elementen staat bij focus */
15+
z-index: var(--dsn-skip-link-z-index);
16+
17+
/* Structuur */
18+
display: inline-block;
19+
20+
/* Spacing */
21+
padding-block: var(--dsn-skip-link-padding-block);
22+
padding-inline: var(--dsn-skip-link-padding-inline);
23+
24+
/* Afronding */
25+
border-radius: var(--dsn-skip-link-border-radius);
26+
27+
/* Typografie */
28+
font: inherit;
29+
text-decoration: none;
30+
}
31+
32+
/* Focus state — link wordt zichtbaar en gepositioneerd */
33+
.dsn-skip-link:focus-visible {
34+
/* Clip verwijderen zodat de link zichtbaar wordt */
35+
clip-path: none;
36+
overflow: visible;
37+
white-space: normal;
38+
39+
/* Positionering in de hoek van het viewport */
40+
inset-block-start: var(--dsn-skip-link-offset-block-start);
41+
inset-inline-start: var(--dsn-skip-link-offset-inline-start);
42+
43+
/* Focus stijl — consistent met dsn-link focus */
44+
background-color: var(--dsn-focus-background-color);
45+
color: var(--dsn-focus-color);
46+
outline: var(--dsn-focus-outline-width) var(--dsn-focus-outline-style)
47+
var(--dsn-focus-outline-color);
48+
outline-offset: var(--dsn-focus-outline-offset);
49+
box-shadow: 0 0 0
50+
calc(var(--dsn-focus-outline-offset) + var(--dsn-focus-outline-width))
51+
var(--dsn-focus-inverse-outline-color);
52+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* SkipLink Component Styles for React
3+
* Re-exports the base SkipLink styles from components-html
4+
*/
5+
6+
@import '../../../components-html/src/skip-link/skip-link.css';
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import { SkipLink } from './SkipLink';
4+
5+
describe('SkipLink', () => {
6+
it('renders children', () => {
7+
render(<SkipLink href="#main">Ga direct naar de hoofdinhoud</SkipLink>);
8+
expect(
9+
screen.getByRole('link', { name: 'Ga direct naar de hoofdinhoud' })
10+
).toBeInTheDocument();
11+
});
12+
13+
it('renders as an anchor element', () => {
14+
render(<SkipLink href="#main">Skip</SkipLink>);
15+
expect(screen.getByRole('link').tagName).toBe('A');
16+
});
17+
18+
it('sets href on the anchor', () => {
19+
render(<SkipLink href="#main-content">Skip</SkipLink>);
20+
expect(screen.getByRole('link')).toHaveAttribute('href', '#main-content');
21+
});
22+
23+
it('applies base dsn-skip-link class', () => {
24+
render(<SkipLink href="#main">Skip</SkipLink>);
25+
expect(screen.getByRole('link')).toHaveClass('dsn-skip-link');
26+
});
27+
28+
it('applies custom className', () => {
29+
render(
30+
<SkipLink href="#main" className="custom">
31+
Skip
32+
</SkipLink>
33+
);
34+
expect(screen.getByRole('link')).toHaveClass('dsn-skip-link', 'custom');
35+
});
36+
37+
it('uses default children text when no children provided', () => {
38+
render(<SkipLink href="#main" />);
39+
expect(
40+
screen.getByRole('link', { name: 'Ga direct naar de hoofdinhoud' })
41+
).toBeInTheDocument();
42+
});
43+
44+
it('renders custom children text', () => {
45+
render(<SkipLink href="#main">Sla navigatie over</SkipLink>);
46+
expect(
47+
screen.getByRole('link', { name: 'Sla navigatie over' })
48+
).toBeInTheDocument();
49+
});
50+
51+
it('forwards ref', () => {
52+
const ref = { current: null as HTMLAnchorElement | null };
53+
render(
54+
<SkipLink ref={ref} href="#main">
55+
Skip
56+
</SkipLink>
57+
);
58+
expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
59+
});
60+
61+
it('passes through additional anchor attributes', () => {
62+
render(
63+
<SkipLink href="#main" data-testid="skip-link">
64+
Skip
65+
</SkipLink>
66+
);
67+
expect(screen.getByTestId('skip-link')).toBeInTheDocument();
68+
});
69+
70+
it('passes lang attribute', () => {
71+
render(
72+
<SkipLink href="#main" lang="en">
73+
Skip to main content
74+
</SkipLink>
75+
);
76+
expect(screen.getByRole('link')).toHaveAttribute('lang', 'en');
77+
});
78+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import React from 'react';
2+
import { classNames } from '@dsn/core';
3+
import './SkipLink.css';
4+
5+
export interface SkipLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
6+
/**
7+
* Het doel-ID waarnaar de gebruiker gesprongen wordt (inclusief `#`).
8+
* Het element met dit ID moet `tabindex="-1"` hebben als het niet natively focusbaar is.
9+
* @example "#main-content"
10+
*/
11+
href: string;
12+
13+
/**
14+
* Tekst van de skip-link — zichtbaar voor screenreaders en bij focus.
15+
* @default "Ga direct naar de hoofdinhoud"
16+
*/
17+
children?: React.ReactNode;
18+
19+
/**
20+
* Extra CSS klassen
21+
*/
22+
className?: string;
23+
}
24+
25+
/**
26+
* SkipLink — toegankelijkheidskoppeling om herhalende navigatie te omzeilen.
27+
*
28+
* Plaatst de link als **eerste focusbaar element** in de DOM, vóór `<header>` en `<nav>`.
29+
* Voldoet aan WCAG 2.1 succescriterium 2.4.1 (Bypass Blocks, Level A).
30+
*
31+
* @example
32+
* ```tsx
33+
* // Standaard gebruik — eerste element in <body>
34+
* <SkipLink href="#main-content">Ga direct naar de hoofdinhoud</SkipLink>
35+
* <header>...</header>
36+
* <main id="main-content" tabIndex={-1}>...</main>
37+
* ```
38+
*/
39+
export const SkipLink = React.forwardRef<HTMLAnchorElement, SkipLinkProps>(
40+
(
41+
{ href, children = 'Ga direct naar de hoofdinhoud', className, ...props },
42+
ref
43+
) => {
44+
return (
45+
<a
46+
ref={ref}
47+
href={href}
48+
className={classNames('dsn-skip-link', className)}
49+
{...props}
50+
>
51+
{children}
52+
</a>
53+
);
54+
}
55+
);
56+
57+
SkipLink.displayName = 'SkipLink';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './SkipLink';

packages/components-react/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export * from './Details';
6060
// Branding Components
6161
export * from './Logo';
6262

63+
// Accessibility Components
64+
export * from './SkipLink';
65+
6366
// Navigation Components
6467
export * from './BreadcrumbNavigation';
6568
export * from './Menu';

packages/design-tokens/src/tokens/components/backdrop.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"z-index": {
1515
"value": "400",
1616
"type": "number",
17-
"comment": "Moet lager zijn dan Modal/Drawer z-index — afstemmen zodra die componenten gedefinieerd worden"
17+
"comment": "Z-index schaal: backdrop (400) → modal-dialog/drawer (500) → skip-link (600)"
1818
}
1919
}
2020
}

packages/design-tokens/src/tokens/components/drawer.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
{
22
"dsn": {
33
"drawer": {
4+
"z-index": {
5+
"value": "500",
6+
"type": "number",
7+
"comment": "Positionering boven de backdrop (400) en onder de skip-link (600)"
8+
},
49
"background": {
510
"value": "{dsn.color.neutral.bg-elevated}",
611
"type": "color",

packages/design-tokens/src/tokens/components/modal-dialog.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
{
22
"dsn": {
33
"modal-dialog": {
4+
"z-index": {
5+
"value": "500",
6+
"type": "number",
7+
"comment": "Positionering boven de backdrop (400) en onder de skip-link (600)"
8+
},
49
"background": {
510
"value": "{dsn.color.neutral.bg-elevated}",
611
"type": "color",
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"dsn": {
3+
"skip-link": {
4+
"z-index": {
5+
"value": "600",
6+
"type": "number",
7+
"comment": "Hoogste z-index — boven modals (500) en backdrop (400), zodat de skip-link altijd zichtbaar is bij focus"
8+
},
9+
"padding-block": {
10+
"value": "{dsn.space.block.md}",
11+
"type": "spacing",
12+
"comment": "Verticale padding van de skip-link"
13+
},
14+
"padding-inline": {
15+
"value": "{dsn.space.inline.lg}",
16+
"type": "spacing",
17+
"comment": "Horizontale padding van de skip-link"
18+
},
19+
"border-radius": {
20+
"value": "{dsn.border.radius.md}",
21+
"type": "dimension",
22+
"comment": "Afgeronde hoeken van de skip-link"
23+
},
24+
"offset-block-start": {
25+
"value": "{dsn.space.block.md}",
26+
"type": "spacing",
27+
"comment": "Afstand van de bovenkant van het viewport bij focus"
28+
},
29+
"offset-inline-start": {
30+
"value": "{dsn.space.inline.md}",
31+
"type": "spacing",
32+
"comment": "Afstand van de linkerkant (of rechterkant in RTL) van het viewport bij focus"
33+
}
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)