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

15 KiB
Raw Blame History

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

<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>
<!-- +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

  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