mirror of
https://github.com/vercel/components.build.git
synced 2026-09-14 20:06:39 +08:00
55fae375a4
* Update Geistdocs * Update geistdocs.tsx * Run translation script * Delete toc.tsx * Update route.ts * Update route.ts
811 lines
18 KiB
Plaintext
811 lines
18 KiB
Plaintext
---
|
||
title: Tilgængelighed
|
||
description: At bygge komponenter, der er brugbare for alle, inklusive brugere med handicap, som er afhængige af hjælpemidler.
|
||
---
|
||
|
||
Tilgængelighed (a11y) er ikke en valgfri funktion — det er et grundlæggende krav til moderne webkomponenter. Hver komponent skal være brugbar for alle, inklusive personer med syns-, motoriske, høre- eller kognitive handicap.
|
||
|
||
Denne vejledning er en ikke-udtømmende liste over tilgængelighedsprincipper og mønstre, som du bør følge, når du bygger komponenter. Det er ikke en komplet guide, men den skulle give dig en fornemmelse af de typer problemer, du bør være opmærksom på.
|
||
|
||
Hvis du bruger en linter med stærke regler for tilgængelighed som [Ultracite](https://www.ultracite.ai), vil denne type problemer sandsynligvis blive fanget automatisk, men det er stadig vigtigt at forstå principperne.
|
||
|
||
## Kerneprincipper
|
||
|
||
### 1. Semantisk HTML først
|
||
|
||
Start altid med det mest passende HTML-element. Semantisk HTML giver indbyggede tilgængelighedsfunktioner, som brugerdefinerede implementeringer ofte mangler.
|
||
|
||
```tsx
|
||
// ❌ Don't reinvent the wheel
|
||
<div onClick={handleClick} className="button">
|
||
Click me
|
||
</div>
|
||
|
||
// ✅ Use semantic elements
|
||
<button onClick={handleClick}>
|
||
Click me
|
||
</button>
|
||
```
|
||
|
||
Semantiske elementer kommer med korrekte rollemeddelelser, tastaturinteraktion, fokusstyring og deltagelse i formularer.
|
||
|
||
### 2. Tastaturnavigation
|
||
|
||
Hvert interaktivt element skal være tilgængeligt via tastatur. Brugere skal kunne navigere, aktivere og interagere med al funktionalitet ved kun at bruge et tastatur.
|
||
|
||
```tsx
|
||
// ✅ Complete keyboard support
|
||
function Menu() {
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
switch(e.key) {
|
||
case 'ArrowDown':
|
||
focusNextItem();
|
||
break;
|
||
case 'ArrowUp':
|
||
focusPreviousItem();
|
||
break;
|
||
case 'Home':
|
||
focusFirstItem();
|
||
break;
|
||
case 'End':
|
||
focusLastItem();
|
||
break;
|
||
case 'Escape':
|
||
closeMenu();
|
||
break;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div role="menu" onKeyDown={handleKeyDown}>
|
||
{/* menu items */}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 3. Skærmlæserstøtte
|
||
|
||
Sørg for, at alt indhold og interaktioner bliver annonceret korrekt til skærmlæsere ved at bruge ARIA-attributter, når det er nødvendigt.
|
||
|
||
```tsx
|
||
// ✅ Proper ARIA labeling
|
||
<nav aria-label="Main navigation">
|
||
<ul>
|
||
<li><a href="/" aria-current="page">Home</a></li>
|
||
<li><a href="/about">About</a></li>
|
||
</ul>
|
||
</nav>
|
||
|
||
// ✅ Dynamic content announcements
|
||
<div aria-live="polite" aria-atomic="true">
|
||
{isLoading && <span>Loading results...</span>}
|
||
{results && <span>{results.length} results found</span>}
|
||
</div>
|
||
```
|
||
|
||
### 4. Visuel tilgængelighed
|
||
|
||
Understøt brugere med synsnedsættelser gennem korrekt kontrast, fokusindikatorer og responsiv tekststørrelse.
|
||
|
||
```css
|
||
/* ✅ Visible focus indicators */
|
||
button:focus-visible {
|
||
outline: 2px solid var(--color-focus);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* ✅ Sufficient color contrast (4.5:1 for normal text, 3:1 for large text) */
|
||
.text {
|
||
color: #333; /* Against white: 12.6:1 ratio */
|
||
background: white;
|
||
}
|
||
|
||
/* ✅ Responsive text sizing */
|
||
.text {
|
||
font-size: 1rem; /* Respects user preferences */
|
||
}
|
||
```
|
||
|
||
## ARIA-mønstre
|
||
|
||
### Forstå ARIA
|
||
|
||
ARIA (Accessible Rich Internet Applications) giver semantisk information om elementer til hjælpemidler. Brug ARIA til at forbedre, ikke erstatte, semantisk HTML.
|
||
|
||
Der er et par regler, du bør følge:
|
||
|
||
1. Brug ikke ARIA, hvis du kan bruge semantisk HTML
|
||
2. Ændr ikke native semantik medmindre det er nødvendigt
|
||
3. Alle interaktive elementer skal være tilgængelige via tastatur
|
||
4. Skjul ikke fokusérbare elementer for hjælpemidler
|
||
5. Alle interaktive elementer skal have tilgængelige navne
|
||
|
||
### Almindelige ARIA-attributter
|
||
|
||
#### Roller
|
||
|
||
Definér hvad et element er:
|
||
|
||
```tsx
|
||
// Widget roles
|
||
<div role="button" tabIndex={0} onClick={handleClick}>
|
||
Custom Button
|
||
</div>
|
||
|
||
// Landmark roles
|
||
<div role="navigation" aria-label="Breadcrumb">
|
||
{/* breadcrumb items */}
|
||
</div>
|
||
|
||
// Live region roles
|
||
<div role="alert">
|
||
Error: Invalid email address
|
||
</div>
|
||
```
|
||
|
||
#### Tilstande
|
||
|
||
Beskriv et elements aktuelle tilstand:
|
||
|
||
```tsx
|
||
// Checked state
|
||
<div
|
||
role="checkbox"
|
||
aria-checked={isChecked}
|
||
tabIndex={0}
|
||
>
|
||
Accept terms
|
||
</div>
|
||
|
||
// Expanded state
|
||
<button
|
||
aria-expanded={isOpen}
|
||
aria-controls="panel-1"
|
||
>
|
||
Toggle Panel
|
||
</button>
|
||
<div id="panel-1" hidden={!isOpen}>
|
||
Panel content
|
||
</div>
|
||
|
||
// Selected state
|
||
<li
|
||
role="option"
|
||
aria-selected={isSelected}
|
||
>
|
||
Option 1
|
||
</li>
|
||
```
|
||
|
||
#### Egenskaber
|
||
|
||
Giv yderligere information:
|
||
|
||
```tsx
|
||
// Labels and descriptions
|
||
<input
|
||
aria-label="Search"
|
||
aria-describedby="search-help"
|
||
type="search"
|
||
/>
|
||
<span id="search-help">Press Enter to search</span>
|
||
|
||
// Relationships
|
||
<button aria-controls="modal-1">Open Modal</button>
|
||
<div id="modal-1" role="dialog">{/* modal content */}</div>
|
||
|
||
// Required and invalid
|
||
<input
|
||
aria-required="true"
|
||
aria-invalid={hasError}
|
||
aria-errormessage="email-error"
|
||
/>
|
||
<span id="email-error">Please enter a valid email</span>
|
||
```
|
||
|
||
## Komponentmønstre
|
||
|
||
### Modal/Dialog
|
||
|
||
Modale vinduer kræver omhyggelig fokusstyring og fokusfængsling:
|
||
|
||
```tsx
|
||
function Modal({ isOpen, onClose, children }) {
|
||
const modalRef = useRef<HTMLDivElement>(null);
|
||
const previousFocus = useRef<HTMLElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
// Store current focus
|
||
previousFocus.current = document.activeElement as HTMLElement;
|
||
|
||
// Focus first focusable element in modal
|
||
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||
);
|
||
firstFocusable?.focus();
|
||
|
||
// Prevent body scroll
|
||
document.body.style.overflow = 'hidden';
|
||
} else {
|
||
// Restore focus
|
||
previousFocus.current?.focus();
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
return () => {
|
||
document.body.style.overflow = '';
|
||
};
|
||
}, [isOpen]);
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
onClose();
|
||
}
|
||
|
||
if (e.key === 'Tab') {
|
||
// Trap focus within modal
|
||
const focusables = modalRef.current?.querySelectorAll<HTMLElement>(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||
);
|
||
|
||
if (focusables && focusables.length > 0) {
|
||
const firstFocusable = focusables[0];
|
||
const lastFocusable = focusables[focusables.length - 1];
|
||
|
||
if (e.shiftKey && document.activeElement === firstFocusable) {
|
||
e.preventDefault();
|
||
lastFocusable.focus();
|
||
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
|
||
e.preventDefault();
|
||
firstFocusable.focus();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="modal-title"
|
||
ref={modalRef}
|
||
onKeyDown={handleKeyDown}
|
||
className="modal"
|
||
>
|
||
<button
|
||
onClick={onClose}
|
||
aria-label="Close dialog"
|
||
className="close-button"
|
||
>
|
||
×
|
||
</button>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Dropdown-menu
|
||
|
||
Rullemenuer kræver korrekte ARIA-attributter og tastaturnavigation:
|
||
|
||
```tsx
|
||
function DropdownMenu({ items }) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||
const menuRef = useRef<HTMLUListElement>(null);
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
switch(e.key) {
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
if (!isOpen) {
|
||
setIsOpen(true);
|
||
setSelectedIndex(0);
|
||
} else {
|
||
setSelectedIndex(prev =>
|
||
prev < items.length - 1 ? prev + 1 : 0
|
||
);
|
||
}
|
||
break;
|
||
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
if (isOpen) {
|
||
setSelectedIndex(prev =>
|
||
prev > 0 ? prev - 1 : items.length - 1
|
||
);
|
||
}
|
||
break;
|
||
|
||
case 'Enter':
|
||
case ' ':
|
||
e.preventDefault();
|
||
if (!isOpen) {
|
||
setIsOpen(true);
|
||
} else if (selectedIndex >= 0) {
|
||
items[selectedIndex].onClick();
|
||
setIsOpen(false);
|
||
}
|
||
break;
|
||
|
||
case 'Escape':
|
||
setIsOpen(false);
|
||
setSelectedIndex(-1);
|
||
break;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="dropdown">
|
||
<button
|
||
aria-haspopup="true"
|
||
aria-expanded={isOpen}
|
||
aria-controls="dropdown-menu"
|
||
onKeyDown={handleKeyDown}
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
>
|
||
Menu
|
||
</button>
|
||
|
||
{isOpen && (
|
||
<ul
|
||
id="dropdown-menu"
|
||
role="menu"
|
||
ref={menuRef}
|
||
onKeyDown={handleKeyDown}
|
||
>
|
||
{items.map((item, index) => (
|
||
<li
|
||
key={item.id}
|
||
role="menuitem"
|
||
tabIndex={-1}
|
||
aria-selected={index === selectedIndex}
|
||
onClick={() => {
|
||
item.onClick();
|
||
setIsOpen(false);
|
||
}}
|
||
>
|
||
{item.label}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Faner
|
||
|
||
Faneinterfaces kræver specifikke ARIA-mønstre og tastaturnavigation:
|
||
|
||
```tsx
|
||
function Tabs({ tabs, defaultTab = 0 }) {
|
||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||
let newIndex = index;
|
||
|
||
switch(e.key) {
|
||
case 'ArrowLeft':
|
||
newIndex = index > 0 ? index - 1 : tabs.length - 1;
|
||
break;
|
||
case 'ArrowRight':
|
||
newIndex = index < tabs.length - 1 ? index + 1 : 0;
|
||
break;
|
||
case 'Home':
|
||
newIndex = 0;
|
||
break;
|
||
case 'End':
|
||
newIndex = tabs.length - 1;
|
||
break;
|
||
default:
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
setActiveTab(newIndex);
|
||
|
||
// Focus the newly selected tab
|
||
const tabElement = document.getElementById(`tab-${newIndex}`);
|
||
tabElement?.focus();
|
||
};
|
||
|
||
return (
|
||
<div className="tabs">
|
||
<div role="tablist" aria-label="Tabs">
|
||
{tabs.map((tab, index) => (
|
||
<button
|
||
key={tab.id}
|
||
id={`tab-${index}`}
|
||
role="tab"
|
||
aria-selected={activeTab === index}
|
||
aria-controls={`panel-${index}`}
|
||
tabIndex={activeTab === index ? 0 : -1}
|
||
onClick={() => setActiveTab(index)}
|
||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tabs.map((tab, index) => (
|
||
<div
|
||
key={tab.id}
|
||
id={`panel-${index}`}
|
||
role="tabpanel"
|
||
aria-labelledby={`tab-${index}`}
|
||
hidden={activeTab !== index}
|
||
tabIndex={0}
|
||
>
|
||
{tab.content}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Formularer
|
||
|
||
Formularer behøver klare labels, fejlbeskeder og valideringsfeedback:
|
||
|
||
```tsx
|
||
function AccessibleForm() {
|
||
const [errors, setErrors] = useState({});
|
||
|
||
return (
|
||
<form aria-label="Contact form">
|
||
<div className="form-group">
|
||
<label htmlFor="email">
|
||
Email Address
|
||
<span aria-label="required">*</span>
|
||
</label>
|
||
<input
|
||
id="email"
|
||
type="email"
|
||
aria-required="true"
|
||
aria-invalid={!!errors.email}
|
||
aria-describedby={errors.email ? "email-error" : "email-help"}
|
||
/>
|
||
<span id="email-help" className="help-text">
|
||
We'll never share your email
|
||
</span>
|
||
{errors.email && (
|
||
<span id="email-error" role="alert" className="error">
|
||
{errors.email}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<fieldset>
|
||
<legend>Notification Preferences</legend>
|
||
<div>
|
||
<input
|
||
id="notify-email"
|
||
type="checkbox"
|
||
name="notifications"
|
||
value="email"
|
||
/>
|
||
<label htmlFor="notify-email">Email notifications</label>
|
||
</div>
|
||
<div>
|
||
<input
|
||
id="notify-sms"
|
||
type="checkbox"
|
||
name="notifications"
|
||
value="sms"
|
||
/>
|
||
<label htmlFor="notify-sms">SMS notifications</label>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<button type="submit">Submit</button>
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
|
||
## Fokushåndtering
|
||
|
||
### Synligt fokus
|
||
|
||
Vis fokusindikatorer kun ved tastaturnavigation:
|
||
|
||
```css
|
||
/* Remove default outline */
|
||
*:focus {
|
||
outline: none;
|
||
}
|
||
|
||
/* Show outline only for keyboard focus */
|
||
*:focus-visible {
|
||
outline: 2px solid var(--color-focus);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* Custom focus styles for specific components */
|
||
.button:focus-visible {
|
||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
|
||
}
|
||
```
|
||
|
||
### Fokusfængsling
|
||
|
||
Hold fokus inden for et specifikt område:
|
||
|
||
```tsx
|
||
function useFocusTrap(ref: React.RefObject<HTMLElement>, isActive: boolean) {
|
||
useEffect(() => {
|
||
if (!isActive || !ref.current) return;
|
||
|
||
const element = ref.current;
|
||
const focusableSelector =
|
||
'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex]:not([tabindex="-1"])';
|
||
|
||
const focusableElements = element.querySelectorAll<HTMLElement>(focusableSelector);
|
||
const firstFocusable = focusableElements[0];
|
||
const lastFocusable = focusableElements[focusableElements.length - 1];
|
||
|
||
const handleTabKey = (e: KeyboardEvent) => {
|
||
if (e.key !== 'Tab') return;
|
||
|
||
if (e.shiftKey) {
|
||
if (document.activeElement === firstFocusable) {
|
||
e.preventDefault();
|
||
lastFocusable?.focus();
|
||
}
|
||
} else {
|
||
if (document.activeElement === lastFocusable) {
|
||
e.preventDefault();
|
||
firstFocusable?.focus();
|
||
}
|
||
}
|
||
};
|
||
|
||
element.addEventListener('keydown', handleTabKey);
|
||
firstFocusable?.focus();
|
||
|
||
return () => {
|
||
element.removeEventListener('keydown', handleTabKey);
|
||
};
|
||
}, [ref, isActive]);
|
||
}
|
||
```
|
||
|
||
### Gendan fokus
|
||
|
||
Returnér fokus til det passende element efter interaktioner:
|
||
|
||
```tsx
|
||
function useRestoreFocus() {
|
||
const previousFocus = useRef<HTMLElement | null>(null);
|
||
|
||
const saveFocus = () => {
|
||
previousFocus.current = document.activeElement as HTMLElement;
|
||
};
|
||
|
||
const restoreFocus = () => {
|
||
previousFocus.current?.focus();
|
||
};
|
||
|
||
return { saveFocus, restoreFocus };
|
||
}
|
||
```
|
||
|
||
## Live-regioner
|
||
|
||
Annoncér dynamiske indholdsændringer til skærmlæsere:
|
||
|
||
### Statusbeskeder
|
||
|
||
```tsx
|
||
// Polite announcement (waits for screen reader to finish)
|
||
<div role="status" aria-live="polite">
|
||
{savedMessage && "Settings saved successfully"}
|
||
</div>
|
||
|
||
// Assertive announcement (interrupts screen reader)
|
||
<div role="alert" aria-live="assertive">
|
||
{errorMessage && `Error: ${errorMessage}`}
|
||
</div>
|
||
|
||
// Loading states
|
||
<div aria-live="polite" aria-busy={isLoading}>
|
||
{isLoading ? "Loading..." : `${items.length} items loaded`}
|
||
</div>
|
||
```
|
||
|
||
### Fremdriftsindikatorer
|
||
|
||
```tsx
|
||
function ProgressBar({ value, max = 100 }) {
|
||
return (
|
||
<div
|
||
role="progressbar"
|
||
aria-valuenow={value}
|
||
aria-valuemin={0}
|
||
aria-valuemax={max}
|
||
aria-label="Upload progress"
|
||
>
|
||
<div
|
||
className="progress-fill"
|
||
style={{ width: `${(value / max) * 100}%` }}
|
||
/>
|
||
<span className="sr-only">
|
||
{Math.round((value / max) * 100)}% complete
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
## Farve og kontrast
|
||
|
||
### Kontrastkrav
|
||
|
||
Følg WCAG-retningslinjerne for farvekontrast:
|
||
|
||
```css
|
||
/* Normal text (< 18pt or < 14pt bold) */
|
||
.text {
|
||
color: #595959; /* 7:1 ratio against white */
|
||
background: white;
|
||
}
|
||
|
||
/* Large text (≥ 18pt or ≥ 14pt bold) */
|
||
.heading {
|
||
color: #767676; /* 4.5:1 ratio against white */
|
||
font-size: 1.5rem;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* Non-text elements (icons, borders) */
|
||
.icon {
|
||
color: #949494; /* 3:1 ratio against white */
|
||
}
|
||
```
|
||
|
||
### Farveuafhængighed
|
||
|
||
Giv aldrig information kun via farve:
|
||
|
||
```tsx
|
||
// ❌ Color only
|
||
<span className="text-red-500">Error</span>
|
||
|
||
// ✅ Color with text/icon
|
||
<span className="text-red-500">
|
||
<ErrorIcon aria-hidden="true" />
|
||
<span>Error: Invalid input</span>
|
||
</span>
|
||
|
||
// ✅ Multiple indicators
|
||
<input
|
||
className={hasError ? 'border-red-500' : 'border-gray-300'}
|
||
aria-invalid={hasError}
|
||
aria-describedby={hasError ? 'error-message' : undefined}
|
||
/>
|
||
{hasError && (
|
||
<span id="error-message" className="text-red-500">
|
||
<ErrorIcon aria-hidden="true" /> This field is required
|
||
</span>
|
||
)}
|
||
```
|
||
|
||
## Mobiltilgængelighed
|
||
|
||
### Berøringsmål
|
||
|
||
Sørg for, at berøringsmål er store nok:
|
||
|
||
```css
|
||
/* Minimum 44x44px for iOS, 48x48dp for Android */
|
||
.button {
|
||
min-height: 44px;
|
||
min-width: 44px;
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
/* Add invisible touch area for small icons */
|
||
.icon-button {
|
||
position: relative;
|
||
padding: 8px;
|
||
}
|
||
|
||
.icon-button::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: -8px;
|
||
right: -8px;
|
||
bottom: -8px;
|
||
left: -8px;
|
||
}
|
||
```
|
||
|
||
### Viewport og zoom
|
||
|
||
Tillad brugere at zoome:
|
||
|
||
```html
|
||
<!-- ✅ Allows zooming -->
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
|
||
<!-- ❌ Prevents zooming -->
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||
```
|
||
|
||
## Almindelige faldgruber
|
||
|
||
### Pladsholdertekst som etiketter
|
||
|
||
Brug ikke pladsholdere som den eneste label:
|
||
|
||
```tsx
|
||
// ❌ Placeholder disappears when typing
|
||
<input placeholder="Email address" />
|
||
|
||
// ✅ Persistent label
|
||
<label>
|
||
Email address
|
||
<input type="email" />
|
||
</label>
|
||
|
||
// ✅ Floating label pattern
|
||
<div className="form-field">
|
||
<input id="email" placeholder=" " />
|
||
<label htmlFor="email">Email address</label>
|
||
</div>
|
||
```
|
||
|
||
### Tomme knapper
|
||
|
||
Giv altid tilgængelig tekst for ikonknapper:
|
||
|
||
```tsx
|
||
// ❌ No accessible name
|
||
<button onClick={handleDelete}>
|
||
<TrashIcon />
|
||
</button>
|
||
|
||
// ✅ Screen reader text
|
||
<button onClick={handleDelete} aria-label="Delete item">
|
||
<TrashIcon aria-hidden="true" />
|
||
</button>
|
||
|
||
// ✅ Visually hidden text
|
||
<button onClick={handleDelete}>
|
||
<TrashIcon aria-hidden="true" />
|
||
<span className="sr-only">Delete item</span>
|
||
</button>
|
||
```
|
||
|
||
### Deaktiverede formelementer
|
||
|
||
Deaktiverede elementer kan ikke fokuseres, hvilket kan forvirre brugere:
|
||
|
||
```tsx
|
||
// ❌ User can't understand why button is disabled
|
||
<button disabled={!isValid}>
|
||
Submit
|
||
</button>
|
||
|
||
// ✅ Use aria-disabled and explain
|
||
<button
|
||
aria-disabled={!isValid}
|
||
aria-describedby="submit-help"
|
||
onClick={isValid ? handleSubmit : undefined}
|
||
className={!isValid ? 'opacity-50 cursor-not-allowed' : ''}
|
||
>
|
||
Submit
|
||
</button>
|
||
<span id="submit-help">
|
||
{!isValid && 'Please fill in all required fields'}
|
||
</span>
|
||
``` |