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
This commit is contained in:
@@ -0,0 +1,730 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user