# Accessible Patterns

Complex UI patterns with accessibility built in. For simple elements, prefer semantic HTML without these patterns.

## Contents
- [Modal dialogs](#modal-dialogs)
- [Dropdown menus](#dropdown-menus)
- [Tabs](#tabs)
- [Accordions](#accordions)
- [Toast notifications](#toast-notifications)
- [Data tables](#data-tables)
- [Skip links](#skip-links)
- [Forms with validation](#forms-with-validation)

## Modal dialogs

```html
<button onclick="openModal()" aria-haspopup="dialog">
  Open settings
</button>

<div 
  id="modal" 
  role="dialog" 
  aria-modal="true" 
  aria-labelledby="modal-title"
  hidden
>
  <h2 id="modal-title">Settings</h2>
  <div><!-- modal content --></div>
  <button onclick="closeModal()">Close</button>
</div>
```

**Requirements:**
1. `role="dialog"` and `aria-modal="true"`
2. Label via `aria-labelledby` pointing to heading
3. Focus moves into modal on open (to first focusable element or the dialog itself)
4. Focus trapped inside modal while open
5. Escape key closes modal
6. Focus returns to trigger button on close
7. Background content hidden from screen readers (via `aria-modal` or `inert`)

**Focus trap pattern (JS):**
```js
function trapFocus(modal) {
  const focusable = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  modal.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;
    
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  });
}
```

## Dropdown menus

```html
<div class="menu-container">
  <button 
    id="menu-button"
    aria-expanded="false" 
    aria-haspopup="menu"
    aria-controls="menu"
  >
    Options
  </button>
  
  <ul id="menu" role="menu" aria-labelledby="menu-button" hidden>
    <li role="menuitem" tabindex="-1">Edit</li>
    <li role="menuitem" tabindex="-1">Duplicate</li>
    <li role="menuitem" tabindex="-1">Delete</li>
  </ul>
</div>
```

**Requirements:**
1. `aria-expanded` toggles on button
2. `aria-haspopup="menu"` on trigger
3. `role="menu"` on container, `role="menuitem"` on items
4. Arrow keys navigate items (not Tab)
5. Enter/Space activates item
6. Escape closes and returns focus to button
7. Home/End jump to first/last item

**Keyboard handling:**
```js
menu.addEventListener('keydown', (e) => {
  const items = [...menu.querySelectorAll('[role="menuitem"]')];
  const current = items.indexOf(document.activeElement);
  
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      items[(current + 1) % items.length].focus();
      break;
    case 'ArrowUp':
      e.preventDefault();
      items[(current - 1 + items.length) % items.length].focus();
      break;
    case 'Home':
      e.preventDefault();
      items[0].focus();
      break;
    case 'End':
      e.preventDefault();
      items[items.length - 1].focus();
      break;
    case 'Escape':
      closeMenu();
      button.focus();
      break;
  }
});
```

## Tabs

```html
<div class="tabs">
  <div role="tablist" aria-label="Account settings">
    <button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">
      Profile
    </button>
    <button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">
      Security
    </button>
    <button role="tab" aria-selected="false" aria-controls="panel-3" id="tab-3" tabindex="-1">
      Notifications
    </button>
  </div>
  
  <div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
    <!-- Profile content -->
  </div>
  <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
    <!-- Security content -->
  </div>
  <div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
    <!-- Notifications content -->
  </div>
</div>
```

**Requirements:**
1. `role="tablist"` container with `role="tab"` buttons
2. `aria-selected="true"` on active tab
3. `tabindex="-1"` on inactive tabs (arrow key navigation, not Tab)
4. `role="tabpanel"` for content, linked via `aria-controls`/`aria-labelledby`
5. Left/Right arrows move between tabs
6. Home/End jump to first/last tab

## Accordions

```html
<div class="accordion">
  <h3>
    <button 
      aria-expanded="false" 
      aria-controls="section1"
      id="accordion1"
    >
      Section 1
    </button>
  </h3>
  <div id="section1" role="region" aria-labelledby="accordion1" hidden>
    <p>Content for section 1</p>
  </div>
  
  <h3>
    <button 
      aria-expanded="false" 
      aria-controls="section2"
      id="accordion2"
    >
      Section 2
    </button>
  </h3>
  <div id="section2" role="region" aria-labelledby="accordion2" hidden>
    <p>Content for section 2</p>
  </div>
</div>
```

**Requirements:**
1. Headings wrap buttons (preserves heading hierarchy for screen readers)
2. `aria-expanded` indicates open/closed state
3. `aria-controls` links button to panel
4. Panel has `role="region"` and `aria-labelledby`
5. Space/Enter toggles the panel

## Toast notifications

```html
<!-- Container for all toasts -->
<div 
  role="status" 
  aria-live="polite" 
  aria-atomic="true" 
  class="toast-container"
>
  <!-- Toasts injected here -->
</div>

<!-- For urgent/error messages -->
<div 
  role="alert" 
  aria-live="assertive"
  class="alert-container"
>
  <!-- Alerts injected here -->
</div>
```

**Usage:**
- `aria-live="polite"`: Announced after current speech (success messages, updates)
- `aria-live="assertive"`: Interrupts immediately (errors, critical alerts)
- `role="status"`: Implicit `aria-live="polite"`
- `role="alert"`: Implicit `aria-live="assertive"`

**Requirements:**
1. Live region must exist in DOM before content is added
2. Don't use for navigation or frequent updates (too noisy)
3. Error toasts should be `assertive`, success should be `polite`
4. Provide dismiss button if toast persists
5. Don't auto-dismiss error messages

## Data tables

```html
<table>
  <caption>Quarterly sales by region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">Q1</th>
      <th scope="col">Q2</th>
      <th scope="col">Q3</th>
      <th scope="col">Q4</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">North</th>
      <td>$1.2M</td>
      <td>$1.4M</td>
      <td>$1.1M</td>
      <td>$1.6M</td>
    </tr>
    <tr>
      <th scope="row">South</th>
      <td>$0.9M</td>
      <td>$1.0M</td>
      <td>$1.2M</td>
      <td>$1.3M</td>
    </tr>
  </tbody>
</table>
```

**Requirements:**
1. `<caption>` describes the table's purpose
2. `<th>` for headers with `scope="col"` or `scope="row"`
3. Don't use tables for layout
4. For complex tables with multi-level headers, use `id`/`headers` attributes
5. Sortable columns need `aria-sort="ascending|descending|none"`

## Skip links

```html
<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>
  
  <header><!-- navigation --></header>
  
  <main id="main-content" tabindex="-1">
    <!-- page content -->
  </main>
</body>
```

```css
.skip-link {
  position: absolute;
  left: -9999px;
  z-index: 999;
  padding: 1em;
  background: #000;
  color: #fff;
}

.skip-link:focus {
  left: 50%;
  transform: translateX(-50%);
  top: 0;
}
```

**Requirements:**
1. First focusable element on page
2. Links to `<main>` or primary content area
3. Visible on focus (hidden until needed)
4. Target element needs `tabindex="-1"` for programmatic focus

## Forms with validation

```html
<form novalidate>
  <div class="field">
    <label for="email">Email address <span aria-hidden="true">*</span></label>
    <input 
      type="email" 
      id="email" 
      name="email"
      required
      aria-required="true"
      aria-invalid="false"
      aria-describedby="email-error"
    >
    <span id="email-error" class="error" hidden></span>
  </div>
  
  <button type="submit">Submit</button>
</form>
```

**On validation error:**
```js
function showError(input, message) {
  const errorEl = document.getElementById(input.id + '-error');
  input.setAttribute('aria-invalid', 'true');
  errorEl.textContent = message;
  errorEl.hidden = false;
  input.focus();
}
```

**Requirements:**
1. `<label>` for every input (visible, not just `aria-label`)
2. Required fields: `required` + `aria-required="true"` + visual indicator
3. Errors linked via `aria-describedby`
4. `aria-invalid="true"` on invalid fields
5. Move focus to first error on submit
6. Error messages must be specific ("Email must include @" not "Invalid")
7. Don't clear fields on error (let users fix without retyping)
