Files
Job-Info/svelte-reference/07-ACCESSIBILITY.md
T
aewing 0b76a4b119 Cleanup: Remove old modes, add Svelte 5 reference library, update orchestrator
- 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
2026-01-21 03:56:01 +00:00

731 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
1. **Perceivable** - Users can perceive the content
2. **Operable** - Users can operate the interface
3. **Understandable** - Users can understand the content
4. **Robust** - Content works across assistive technologies
---
## ⌨️ Keyboard Navigation
### Focus Management
```svelte
<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
```svelte
<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
```svelte
<!-- +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
```svelte
<!-- 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
```svelte
<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
```svelte
<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
```svelte
<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
```svelte
<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
```svelte
<!-- 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
```svelte
<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
```svelte
<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
```svelte
<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
```svelte
<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
```css
/* 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;
}
```
```svelte
<!-- 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
```bash
# 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
1. Tab through entire page
2. Use screen reader (VoiceOver, NVDA, JAWS)
3. Zoom to 200%
4. Disable CSS
5. Check in high contrast mode