patterns.md

  1# Accessible Patterns
  2
  3Complex UI patterns with accessibility built in. For simple elements, prefer semantic HTML without these patterns.
  4
  5## Contents
  6- [Modal dialogs](#modal-dialogs)
  7- [Dropdown menus](#dropdown-menus)
  8- [Tabs](#tabs)
  9- [Accordions](#accordions)
 10- [Toast notifications](#toast-notifications)
 11- [Data tables](#data-tables)
 12- [Skip links](#skip-links)
 13- [Forms with validation](#forms-with-validation)
 14
 15## Modal dialogs
 16
 17```html
 18<button onclick="openModal()" aria-haspopup="dialog">
 19  Open settings
 20</button>
 21
 22<div 
 23  id="modal" 
 24  role="dialog" 
 25  aria-modal="true" 
 26  aria-labelledby="modal-title"
 27  hidden
 28>
 29  <h2 id="modal-title">Settings</h2>
 30  <div><!-- modal content --></div>
 31  <button onclick="closeModal()">Close</button>
 32</div>
 33```
 34
 35**Requirements:**
 361. `role="dialog"` and `aria-modal="true"`
 372. Label via `aria-labelledby` pointing to heading
 383. Focus moves into modal on open (to first focusable element or the dialog itself)
 394. Focus trapped inside modal while open
 405. Escape key closes modal
 416. Focus returns to trigger button on close
 427. Background content hidden from screen readers (via `aria-modal` or `inert`)
 43
 44**Focus trap pattern (JS):**
 45```js
 46function trapFocus(modal) {
 47  const focusable = modal.querySelectorAll(
 48    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
 49  );
 50  const first = focusable[0];
 51  const last = focusable[focusable.length - 1];
 52
 53  modal.addEventListener('keydown', (e) => {
 54    if (e.key !== 'Tab') return;
 55    
 56    if (e.shiftKey && document.activeElement === first) {
 57      e.preventDefault();
 58      last.focus();
 59    } else if (!e.shiftKey && document.activeElement === last) {
 60      e.preventDefault();
 61      first.focus();
 62    }
 63  });
 64}
 65```
 66
 67## Dropdown menus
 68
 69```html
 70<div class="menu-container">
 71  <button 
 72    id="menu-button"
 73    aria-expanded="false" 
 74    aria-haspopup="menu"
 75    aria-controls="menu"
 76  >
 77    Options
 78  </button>
 79  
 80  <ul id="menu" role="menu" aria-labelledby="menu-button" hidden>
 81    <li role="menuitem" tabindex="-1">Edit</li>
 82    <li role="menuitem" tabindex="-1">Duplicate</li>
 83    <li role="menuitem" tabindex="-1">Delete</li>
 84  </ul>
 85</div>
 86```
 87
 88**Requirements:**
 891. `aria-expanded` toggles on button
 902. `aria-haspopup="menu"` on trigger
 913. `role="menu"` on container, `role="menuitem"` on items
 924. Arrow keys navigate items (not Tab)
 935. Enter/Space activates item
 946. Escape closes and returns focus to button
 957. Home/End jump to first/last item
 96
 97**Keyboard handling:**
 98```js
 99menu.addEventListener('keydown', (e) => {
100  const items = [...menu.querySelectorAll('[role="menuitem"]')];
101  const current = items.indexOf(document.activeElement);
102  
103  switch (e.key) {
104    case 'ArrowDown':
105      e.preventDefault();
106      items[(current + 1) % items.length].focus();
107      break;
108    case 'ArrowUp':
109      e.preventDefault();
110      items[(current - 1 + items.length) % items.length].focus();
111      break;
112    case 'Home':
113      e.preventDefault();
114      items[0].focus();
115      break;
116    case 'End':
117      e.preventDefault();
118      items[items.length - 1].focus();
119      break;
120    case 'Escape':
121      closeMenu();
122      button.focus();
123      break;
124  }
125});
126```
127
128## Tabs
129
130```html
131<div class="tabs">
132  <div role="tablist" aria-label="Account settings">
133    <button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">
134      Profile
135    </button>
136    <button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">
137      Security
138    </button>
139    <button role="tab" aria-selected="false" aria-controls="panel-3" id="tab-3" tabindex="-1">
140      Notifications
141    </button>
142  </div>
143  
144  <div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
145    <!-- Profile content -->
146  </div>
147  <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
148    <!-- Security content -->
149  </div>
150  <div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
151    <!-- Notifications content -->
152  </div>
153</div>
154```
155
156**Requirements:**
1571. `role="tablist"` container with `role="tab"` buttons
1582. `aria-selected="true"` on active tab
1593. `tabindex="-1"` on inactive tabs (arrow key navigation, not Tab)
1604. `role="tabpanel"` for content, linked via `aria-controls`/`aria-labelledby`
1615. Left/Right arrows move between tabs
1626. Home/End jump to first/last tab
163
164## Accordions
165
166```html
167<div class="accordion">
168  <h3>
169    <button 
170      aria-expanded="false" 
171      aria-controls="section1"
172      id="accordion1"
173    >
174      Section 1
175    </button>
176  </h3>
177  <div id="section1" role="region" aria-labelledby="accordion1" hidden>
178    <p>Content for section 1</p>
179  </div>
180  
181  <h3>
182    <button 
183      aria-expanded="false" 
184      aria-controls="section2"
185      id="accordion2"
186    >
187      Section 2
188    </button>
189  </h3>
190  <div id="section2" role="region" aria-labelledby="accordion2" hidden>
191    <p>Content for section 2</p>
192  </div>
193</div>
194```
195
196**Requirements:**
1971. Headings wrap buttons (preserves heading hierarchy for screen readers)
1982. `aria-expanded` indicates open/closed state
1993. `aria-controls` links button to panel
2004. Panel has `role="region"` and `aria-labelledby`
2015. Space/Enter toggles the panel
202
203## Toast notifications
204
205```html
206<!-- Container for all toasts -->
207<div 
208  role="status" 
209  aria-live="polite" 
210  aria-atomic="true" 
211  class="toast-container"
212>
213  <!-- Toasts injected here -->
214</div>
215
216<!-- For urgent/error messages -->
217<div 
218  role="alert" 
219  aria-live="assertive"
220  class="alert-container"
221>
222  <!-- Alerts injected here -->
223</div>
224```
225
226**Usage:**
227- `aria-live="polite"`: Announced after current speech (success messages, updates)
228- `aria-live="assertive"`: Interrupts immediately (errors, critical alerts)
229- `role="status"`: Implicit `aria-live="polite"`
230- `role="alert"`: Implicit `aria-live="assertive"`
231
232**Requirements:**
2331. Live region must exist in DOM before content is added
2342. Don't use for navigation or frequent updates (too noisy)
2353. Error toasts should be `assertive`, success should be `polite`
2364. Provide dismiss button if toast persists
2375. Don't auto-dismiss error messages
238
239## Data tables
240
241```html
242<table>
243  <caption>Quarterly sales by region</caption>
244  <thead>
245    <tr>
246      <th scope="col">Region</th>
247      <th scope="col">Q1</th>
248      <th scope="col">Q2</th>
249      <th scope="col">Q3</th>
250      <th scope="col">Q4</th>
251    </tr>
252  </thead>
253  <tbody>
254    <tr>
255      <th scope="row">North</th>
256      <td>$1.2M</td>
257      <td>$1.4M</td>
258      <td>$1.1M</td>
259      <td>$1.6M</td>
260    </tr>
261    <tr>
262      <th scope="row">South</th>
263      <td>$0.9M</td>
264      <td>$1.0M</td>
265      <td>$1.2M</td>
266      <td>$1.3M</td>
267    </tr>
268  </tbody>
269</table>
270```
271
272**Requirements:**
2731. `<caption>` describes the table's purpose
2742. `<th>` for headers with `scope="col"` or `scope="row"`
2753. Don't use tables for layout
2764. For complex tables with multi-level headers, use `id`/`headers` attributes
2775. Sortable columns need `aria-sort="ascending|descending|none"`
278
279## Skip links
280
281```html
282<body>
283  <a href="#main-content" class="skip-link">Skip to main content</a>
284  
285  <header><!-- navigation --></header>
286  
287  <main id="main-content" tabindex="-1">
288    <!-- page content -->
289  </main>
290</body>
291```
292
293```css
294.skip-link {
295  position: absolute;
296  left: -9999px;
297  z-index: 999;
298  padding: 1em;
299  background: #000;
300  color: #fff;
301}
302
303.skip-link:focus {
304  left: 50%;
305  transform: translateX(-50%);
306  top: 0;
307}
308```
309
310**Requirements:**
3111. First focusable element on page
3122. Links to `<main>` or primary content area
3133. Visible on focus (hidden until needed)
3144. Target element needs `tabindex="-1"` for programmatic focus
315
316## Forms with validation
317
318```html
319<form novalidate>
320  <div class="field">
321    <label for="email">Email address <span aria-hidden="true">*</span></label>
322    <input 
323      type="email" 
324      id="email" 
325      name="email"
326      required
327      aria-required="true"
328      aria-invalid="false"
329      aria-describedby="email-error"
330    >
331    <span id="email-error" class="error" hidden></span>
332  </div>
333  
334  <button type="submit">Submit</button>
335</form>
336```
337
338**On validation error:**
339```js
340function showError(input, message) {
341  const errorEl = document.getElementById(input.id + '-error');
342  input.setAttribute('aria-invalid', 'true');
343  errorEl.textContent = message;
344  errorEl.hidden = false;
345  input.focus();
346}
347```
348
349**Requirements:**
3501. `<label>` for every input (visible, not just `aria-label`)
3512. Required fields: `required` + `aria-required="true"` + visual indicator
3523. Errors linked via `aria-describedby`
3534. `aria-invalid="true"` on invalid fields
3545. Move focus to first error on submit
3556. Error messages must be specific ("Email must include @" not "Invalid")
3567. Don't clear fields on error (let users fix without retyping)