0b76a4b119
- Delete Mode1Test, Mode2Test, Mode3Test, Mode5Test (keep only Mode6Test) - Delete old documentation, Docker files, and k8s configs - Add comprehensive Svelte 5 reference library (svelte-reference/) - Update ModeSwitch orchestrator: - Fix port to 3005 (never use 3000/3001) - Add API endpoints for mode switching - Add bun-types and proper tsconfig - Clean up mode folder mappings
15 KiB
15 KiB
Svelte Accessibility (A11y) Guide
Overview
Accessibility is crucial for inclusive web applications. Svelte provides built-in accessibility warnings, and this guide covers best practices for building accessible Svelte applications.
🎯 Core Principles (POUR)
- Perceivable - Users can perceive the content
- Operable - Users can operate the interface
- Understandable - Users can understand the content
- Robust - Content works across assistive technologies
⌨️ Keyboard Navigation
Focus Management
<script lang="ts">
import { tick } from 'svelte';
let dialogOpen = $state(false);
let triggerButton: HTMLButtonElement;
let dialogContent: HTMLDivElement;
async function openDialog() {
dialogOpen = true;
await tick();
// Move focus to dialog
dialogContent?.focus();
}
function closeDialog() {
dialogOpen = false;
// Return focus to trigger
triggerButton?.focus();
}
</script>
<button bind:this={triggerButton} onclick={openDialog}>
Open Dialog
</button>
{#if dialogOpen}
<div
bind:this={dialogContent}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
>
<h2 id="dialog-title">Dialog Title</h2>
<p>Dialog content...</p>
<button onclick={closeDialog}>Close</button>
</div>
{/if}
Focus Trap
<script lang="ts">
let container: HTMLElement;
function trapFocus(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
</script>
<div bind:this={container} onkeydown={trapFocus}>
<!-- Focusable content -->
</div>
Skip Links
<!-- +layout.svelte -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<nav>
<!-- Navigation -->
</nav>
<main id="main-content" tabindex="-1">
<slot />
</main>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
background: var(--color-primary);
color: white;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
</style>
🏷️ ARIA Attributes
Roles
<!-- Landmark roles -->
<header role="banner">...</header>
<nav role="navigation" aria-label="Main">...</nav>
<main role="main">...</main>
<aside role="complementary">...</aside>
<footer role="contentinfo">...</footer>
<!-- Widget roles -->
<div role="alert">Important message!</div>
<div role="status" aria-live="polite">Status update</div>
<div role="dialog" aria-modal="true">Dialog content</div>
<div role="tablist">...</div>
<div role="menu">...</div>
States & Properties
<script lang="ts">
let expanded = $state(false);
let selected = $state(false);
let loading = $state(false);
</script>
<!-- Expandable content -->
<button
aria-expanded={expanded}
aria-controls="panel-1"
onclick={() => expanded = !expanded}
>
Toggle Panel
</button>
<div id="panel-1" hidden={!expanded}>
Panel content
</div>
<!-- Selection -->
<div
role="option"
aria-selected={selected}
onclick={() => selected = !selected}
>
Option
</div>
<!-- Loading state -->
<button
aria-busy={loading}
disabled={loading}
>
{loading ? 'Loading...' : 'Submit'}
</button>
<!-- Described by -->
<input
type="email"
aria-describedby="email-hint email-error"
/>
<p id="email-hint">We'll never share your email</p>
<p id="email-error" role="alert">Invalid email format</p>
Live Regions
<script lang="ts">
let message = $state('');
let items = $state<string[]>([]);
function addItem(item: string) {
items.push(item);
message = `${item} added to cart`;
// Clear message after announcement
setTimeout(() => message = '', 1000);
}
</script>
<!-- Polite announcements (waits for user to finish) -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{message}
</div>
<!-- Assertive announcements (interrupts immediately) -->
<div aria-live="assertive" role="alert">
{#if error}
{error}
{/if}
</div>
<!-- Status updates -->
<div role="status" aria-live="polite">
{items.length} items in cart
</div>
📝 Forms
Accessible Form Pattern
<script lang="ts">
interface FormState {
name: string;
email: string;
}
interface FormErrors {
name?: string;
email?: string;
}
let form = $state<FormState>({ name: '', email: '' });
let errors = $state<FormErrors>({});
let submitted = $state(false);
function validate(): boolean {
errors = {};
if (!form.name.trim()) {
errors.name = 'Name is required';
}
if (!form.email.trim()) {
errors.email = 'Email is required';
} else if (!form.email.includes('@')) {
errors.email = 'Please enter a valid email';
}
return Object.keys(errors).length === 0;
}
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
submitted = true;
if (validate()) {
// Submit form
}
}
</script>
<form onsubmit={handleSubmit} novalidate>
<div class="field">
<label for="name">
Name
<span aria-hidden="true">*</span>
</label>
<input
id="name"
type="text"
bind:value={form.name}
aria-required="true"
aria-invalid={submitted && !!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{#if submitted && errors.name}
<p id="name-error" class="error" role="alert">
{errors.name}
</p>
{/if}
</div>
<div class="field">
<label for="email">
Email
<span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
bind:value={form.email}
aria-required="true"
aria-invalid={submitted && !!errors.email}
aria-describedby="email-hint {errors.email ? 'email-error' : ''}"
/>
<p id="email-hint" class="hint">
We'll use this to send you updates
</p>
{#if submitted && errors.email}
<p id="email-error" class="error" role="alert">
{errors.email}
</p>
{/if}
</div>
<button type="submit">Submit</button>
</form>
Field Group
<fieldset>
<legend>Shipping Address</legend>
<div class="field">
<label for="street">Street</label>
<input id="street" type="text" />
</div>
<div class="field">
<label for="city">City</label>
<input id="city" type="text" />
</div>
</fieldset>
<!-- Radio group -->
<fieldset>
<legend>Preferred contact method</legend>
<div>
<input type="radio" id="contact-email" name="contact" value="email" />
<label for="contact-email">Email</label>
</div>
<div>
<input type="radio" id="contact-phone" name="contact" value="phone" />
<label for="contact-phone">Phone</label>
</div>
</fieldset>
🖼️ Images & Media
Image Alternatives
<!-- Informative image -->
<img src="/chart.png" alt="Sales increased 25% in Q3 2024" />
<!-- Decorative image -->
<img src="/decoration.svg" alt="" role="presentation" />
<!-- Complex image with long description -->
<figure>
<img
src="/complex-chart.png"
alt="Revenue comparison chart"
aria-describedby="chart-description"
/>
<figcaption id="chart-description">
This chart shows quarterly revenue for 2024. Q1: $1.2M, Q2: $1.5M,
Q3: $1.9M, Q4: $2.1M. Total annual revenue was $6.7M, representing
a 15% increase over 2023.
</figcaption>
</figure>
<!-- Icon with meaning -->
<button>
<svg aria-hidden="true"><!-- icon --></svg>
<span class="sr-only">Delete item</span>
</button>
<!-- Or -->
<button aria-label="Delete item">
<svg aria-hidden="true"><!-- icon --></svg>
</button>
Video Accessibility
<video controls>
<source src="/video.mp4" type="video/mp4" />
<track kind="captions" src="/captions.vtt" srclang="en" label="English" default />
<track kind="descriptions" src="/descriptions.vtt" srclang="en" label="Audio descriptions" />
<p>Your browser doesn't support HTML5 video. <a href="/video.mp4">Download the video</a>.</p>
</video>
🧩 Accessible Components
Accordion
<script lang="ts">
interface Item {
id: string;
title: string;
content: string;
}
let { items }: { items: Item[] } = $props();
let openId = $state<string | null>(null);
function toggle(id: string) {
openId = openId === id ? null : id;
}
function handleKeydown(e: KeyboardEvent, index: number) {
const buttons = document.querySelectorAll<HTMLButtonElement>('[data-accordion-trigger]');
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
buttons[(index + 1) % buttons.length]?.focus();
break;
case 'ArrowUp':
e.preventDefault();
buttons[(index - 1 + buttons.length) % buttons.length]?.focus();
break;
case 'Home':
e.preventDefault();
buttons[0]?.focus();
break;
case 'End':
e.preventDefault();
buttons[buttons.length - 1]?.focus();
break;
}
}
</script>
<div class="accordion">
{#each items as item, index (item.id)}
{@const isOpen = openId === item.id}
<h3>
<button
data-accordion-trigger
id="accordion-header-{item.id}"
aria-expanded={isOpen}
aria-controls="accordion-panel-{item.id}"
onclick={() => toggle(item.id)}
onkeydown={(e) => handleKeydown(e, index)}
>
{item.title}
<span aria-hidden="true">{isOpen ? '−' : '+'}</span>
</button>
</h3>
<div
id="accordion-panel-{item.id}"
role="region"
aria-labelledby="accordion-header-{item.id}"
hidden={!isOpen}
>
<p>{item.content}</p>
</div>
{/each}
</div>
Tabs
<script lang="ts">
interface Tab {
id: string;
label: string;
content: string;
}
let { tabs }: { tabs: Tab[] } = $props();
let activeTab = $state(tabs[0]?.id);
function handleKeydown(e: KeyboardEvent) {
const currentIndex = tabs.findIndex(t => t.id === activeTab);
switch (e.key) {
case 'ArrowRight':
activeTab = tabs[(currentIndex + 1) % tabs.length].id;
break;
case 'ArrowLeft':
activeTab = tabs[(currentIndex - 1 + tabs.length) % tabs.length].id;
break;
case 'Home':
activeTab = tabs[0].id;
break;
case 'End':
activeTab = tabs[tabs.length - 1].id;
break;
}
}
</script>
<div class="tabs">
<div role="tablist" aria-label="Content tabs">
{#each tabs as tab (tab.id)}
{@const isActive = activeTab === tab.id}
<button
role="tab"
id="tab-{tab.id}"
aria-selected={isActive}
aria-controls="panel-{tab.id}"
tabindex={isActive ? 0 : -1}
onclick={() => activeTab = tab.id}
onkeydown={handleKeydown}
>
{tab.label}
</button>
{/each}
</div>
{#each tabs as tab (tab.id)}
<div
role="tabpanel"
id="panel-{tab.id}"
aria-labelledby="tab-{tab.id}"
hidden={activeTab !== tab.id}
tabindex="0"
>
{tab.content}
</div>
{/each}
</div>
Modal Dialog
<script lang="ts">
import type { Snippet } from 'svelte';
import { tick } from 'svelte';
interface Props {
open: boolean;
title: string;
onclose: () => void;
children: Snippet;
}
let { open, title, onclose, children }: Props = $props();
let dialog: HTMLDialogElement;
let previousFocus: HTMLElement | null = null;
$effect(() => {
if (open) {
previousFocus = document.activeElement as HTMLElement;
tick().then(() => {
dialog?.showModal();
});
} else {
dialog?.close();
previousFocus?.focus();
}
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onclose();
}
}
function handleBackdrop(e: MouseEvent) {
if (e.target === dialog) {
onclose();
}
}
</script>
{#if open}
<dialog
bind:this={dialog}
onkeydown={handleKeydown}
onclick={handleBackdrop}
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
aria-modal="true"
>
<div class="dialog-content">
<header>
<h2 id="dialog-title">{title}</h2>
<button
onclick={onclose}
aria-label="Close dialog"
>
×
</button>
</header>
<div id="dialog-description">
{@render children()}
</div>
</div>
</dialog>
{/if}
<style>
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
</style>
📱 Screen Reader Utilities
Visually Hidden Content
/* In app.css or component */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Can be focused (for skip links) */
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}
<!-- Usage -->
<span class="sr-only">Total price:</span>
<span>$99.99</span>
<button>
<svg aria-hidden="true"><!-- icon --></svg>
<span class="sr-only">Add to cart</span>
</button>
✅ Accessibility Checklist
Structure
- Proper heading hierarchy (h1 → h2 → h3)
- Landmark regions (header, nav, main, footer)
- Skip links for keyboard users
- Logical reading order
Interactivity
- All functionality keyboard accessible
- Visible focus indicators
- Focus management for modals/dialogs
- No keyboard traps
Forms
- Labels associated with inputs
- Error messages linked with aria-describedby
- Required fields indicated
- Validation errors announced
Images & Media
- Meaningful alt text
- Decorative images have empty alt
- Video captions/transcripts
- Audio descriptions where needed
Color & Contrast
- 4.5:1 contrast for normal text
- 3:1 contrast for large text
- Information not conveyed by color alone
- Focus indicators visible
Dynamic Content
- Live regions for updates
- Loading states announced
- Error states announced
- Changes in context warned
🧪 Testing Tools
# Install axe-core for automated testing
npm install -D @axe-core/playwright
# Or use browser extensions:
# - axe DevTools
# - WAVE Evaluation Tool
# - Accessibility Insights
Manual Testing Checklist
- Tab through entire page
- Use screen reader (VoiceOver, NVDA, JAWS)
- Zoom to 200%
- Disable CSS
- Check in high contrast mode