Skip to main content

loom-accessibility

Web accessibility patterns, WCAG compliance, and inclusive design.

Datos de origen

Repositorio
cosmix/loom
Última actividad en el origen
29 de agosto de 2026 a las 21:19
Idioma detectado de SKILL.md
inglés
Estrellas
56
Forks
3

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
name
loom-accessibility
description
Web accessibility patterns, WCAG compliance, and inclusive design.
triggers
["accessibility","a11y","WCAG","ARIA","screen reader","keyboard navigation","focus","tab order","tabindex","alt text","color contrast","semantic HTML","landmark","role","aria-label","aria-labelledby","aria-describedby","aria-live","aria-expanded","aria-selected","aria-hidden","focus trap","roving tabindex","skip link","assistive technology","prefers-reduced-motion","accessible name","inert"]
# Accessibility ## Overview Making web UIs usable by everyone, including keyboard-only and assistive-technology users. Covers WCAG 2.1/2.2 AA, semantic HTML, ARIA, keyboard/focus management, screen readers, contrast, and testing. Optimize for AA — that is the legal bar (ADA, EN 301 549, AODA) in most jurisdictions. ## Core Rules (internalize these first) 1. **First rule of ARIA: don't use ARIA.** A native `<button>`/`<a>`/`<input>`/`<nav>` ships focus, keyboard, role, and state for free. ARIA only *describes*; it never adds behavior. Bad ARIA is worse than none. 2. **Second rule: don't change native semantics.** `<button role="heading">` is a footgun. Don't override roles of interactive elements. 3. **`aria-hidden="true"` on a focusable element is a trap** — it hides the element from the a11y tree while leaving it in the Tab order, so SR users land on "nothing." Hide the whole subtree and remove it from tab order (`inert`), never one or the other. 4. **Never `outline: none` without a replacement.** Removing the focus ring with no `:focus-visible` style is the single most common WCAG 2.4.7 failure. 5. **Automated tools catch ~30–40% of issues.** axe/Lighthouse find contrast, missing alt/labels, dup IDs — never "is the focus order sane," "does the SR announcement make sense," "is this keyboard-operable." Manual keyboard + SR passes are mandatory. ## Accessible Name Computation (the thing people get wrong) The accessible name is what a screen reader announces. Resolution order (first non-empty wins): 1. `aria-labelledby` (space-separated IDs; concatenated text of targets) — **highest priority, overrides visible content** 2. `aria-label` 3. Native labeling: `<label for>`/wrapping `<label>`, `<img alt>`, `<fieldset><legend>`, `<figcaption>`, wrapped text content of a button/link 4. `title` / `placeholder` — **fallback only; never rely on these** (placeholder vanishes on input, `title` is not exposed on touch/keyboard reliably) ⚠ Gotchas: - `aria-labelledby` referencing a *hidden* (`display:none`) element still contributes its text — intentional, useful for SR-only names. - `aria-label`/`aria-labelledby` are ignored on non-interactive, non-landmark elements (`<div>`, `<span>`, `<p>`) unless they have a role. Don't expect `<span aria-label>` to announce. - Icon-only controls MUST have a name: `<button aria-label="Close">✕</button>`. A bare glyph/SVG announces nothing or reads the character. - `aria-describedby` adds *supplementary* description (hint, error) read after the name — it does not replace the name. ## WCAG: what actually gets flagged (AA) Grouped by how you catch it. Full spec at w3.org/WAI/WCAG21/quickref; these are the high-frequency failures. | Area | Criterion | Rule | | ---- | --------- | ---- | | Text alt | 1.1.1 (A) | Every `<img>` has `alt`; decorative → `alt=""` (not missing). Functional image → describe the action. | | Contrast | 1.4.3 (AA) | Body text ≥ 4.5:1; large text (≥18.66px bold / ≥24px) ≥ 3:1. | | Non-text contrast | 1.4.11 (AA) | UI components, focus indicators, and meaningful graphics ≥ 3:1 vs adjacent colors. | | Color alone | 1.4.1 (A) | Never convey info by color only (add icon/text/underline). | | Reflow | 1.4.10 (AA) | Usable at 320px width / 400% zoom, no horizontal scroll. | | Keyboard | 2.1.1 (A) | All functionality via keyboard. | | No trap | 2.1.2 (A) | Focus can leave any component (except intentional modal traps with Esc). | | Focus order | 2.4.3 (A) | Tab order matches visual/reading order. | | Focus visible | 2.4.7 (AA) | Visible focus indicator on every focusable element. | | Name/role/value | 4.1.2 (A) | Custom widgets expose correct role + current state. | | Labels | 3.3.2 (A) | Every input has a programmatic label. | | Error id | 3.3.1 (A) | Errors identified in text (not color) and tied to the field. | | Lang | 3.1.1 (A) | `<html lang="…">` set (and `lang` on inline language switches). | **WCAG 2.2 adds:** at **AA**, 2.4.11 Focus Not Obscured (sticky headers must not fully cover the focused element), 2.5.7 Dragging Movements, 2.5.8 Target Size ≥ 24×24px, and 3.3.8 Accessible Authentication (no cognitive-test-only auth; allow password managers and paste into OTP). At **A**, also meet 3.2.6 Consistent Help and 3.3.7 Redundant Entry. WCAG 2.2 also makes 4.1.1 Parsing obsolete and removed; do not use it as a conformance criterion. ## Semantic HTML Reach for the native element before any `<div role>`: ```html <!-- Good: roles, focus, keyboard all free --> <header> <nav aria-label="Main"> <ul><li><a href="/">Home</a></li><li><a href="/about">About</a></li></ul> </nav> </header> <main id="main"> <article><h1>Title</h1><p>…</p></article> </main> <footer>…</footer> ``` - One `<main>` and one `<h1>` per page; never skip heading levels (h2→h4 breaks the SR outline). - Landmarks (`header`/`nav`/`main`/`aside`/`footer`) let SR users jump by region — don't replace them with `<div class="header">`. - Two same-type landmarks on one page (e.g. two `<nav>`) MUST be disambiguated with `aria-label` ("Main"/"Footer"). - `<button>` for actions in-page, `<a href>` for navigation. A clickable `<div>` needs role + `tabindex="0"` + Enter/Space handlers + focus style — i.e. reinventing `<button>` badly. - Group radios/checkboxes in `<fieldset><legend>`; `<legend>` names the group for SR. ## ARIA Attributes Reference ```typescript // Labels/descriptions "aria-label" | "aria-labelledby" | "aria-describedby" // see name computation above // States (keep in sync with reality on every change) "aria-expanded": boolean // disclosure/accordion/combobox trigger "aria-selected": boolean // tab/option "aria-checked": boolean | "mixed" // checkbox/switch/radio "aria-pressed": boolean | "mixed" // toggle button "aria-current": "page" | "step" | "location" | "date" | "time" | true // active item in a set "aria-disabled": boolean // disabled but still focusable (vs native `disabled`) "aria-invalid": boolean | "grammar" | "spelling" "aria-required": boolean // Live regions "aria-live": "off" | "polite" | "assertive" "aria-atomic": boolean // re-read whole region vs only changed node // Relationships "aria-controls" | "aria-owns" | "aria-haspopup": "menu" | "dialog" | "listbox" | true ``` Landmark roles (prefer the native element that implies them): `banner`(header), `navigation`(nav), `main`, `complementary`(aside), `contentinfo`(footer), `search`, `region`(needs a name), `form`. ### Live regions — the #1 subtle bug **The container must exist in the DOM before you change its content.** Injecting a populated `aria-live` node in the same tick is often NOT announced — SRs only announce *mutations* to a region they already observe. ```tsx // Wrong: node created+filled together → frequently silent container.innerHTML = '<div aria-live="polite">Saved</div>'; // Right: region is present and empty on mount; update its text later <div aria-live="polite" aria-atomic="true" className="sr-only" /> // rendered once // ...later: regionEl.textContent = "Saved"; ``` - `polite` = wait for a pause (status, "5 results"). `assertive`/`role="alert"` = interrupt immediately (errors only). Overusing assertive is hostile. - `role="alert"` implies `aria-live="assertive"` + `aria-atomic="true"`; `role="status"` implies polite. - One shared visually-hidden live region toggled via a helper is more reliable than many. ## Focus Management The highest-value, least-automatable a11y work. **Modal open:** save `document.activeElement`, move focus into the dialog (the dialog or its first control), trap Tab inside, close on Esc, **restore focus to the trigger on close.** Make the background non-interactive with `inert` (native `inert` disables focus + pointer + hides from a11y tree in one attribute; falls back to `aria-hidden`+removing tabbables on older browsers). **SPA route change:** browsers reset focus on full loads but NOT on client-side navigation — focus is stranded, and the SR announces nothing. On route change, move focus to the new page's `<h1>` (give it `tabindex="-1"`) or announce the new title via a live region. This is the most-missed SPA a11y bug. **Roving tabindex** (toolbar/menu/tablist/grid): exactly one item has `tabindex="0"`, the rest `-1`; arrow keys move focus and shift the `0`. Keeps composite widgets to a single Tab stop. ```tsx // Focus trap core — cycle Tab within a container, Esc to close function trapFocus(container: HTMLElement, onEscape: () => void) { const sel = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") return onEscape(); if (e.key !== "Tab") return; const els = [...container.querySelectorAll<HTMLElement>(sel)]; const first = els[0], last = els[els.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); } }; container.addEventListener("keydown", onKey); return () => container.removeEventListener("keydown", onKey); } ``` ⚠ `disabled` buttons are skipped by Tab and not announced as present — if users must discover a disabled action (e.g. "why can't I submit?"), use `aria-disabled="true"` + intercept activation instead, so it stays focusable. Prefer native `disabled` for simple forms. ⚠ Prefer the native `<dialog>` element (`.showModal()`): it provides the focus trap, Esc-to-close, backdrop, and top-layer stacking for free — far less to get wrong than a `role="dialog"` div. ## Keyboard Patterns (WAI-ARIA APG) | Widget | Keys | | ------ | ---- | | Button | Enter **and** Space activate | | Link | Enter only | | Tabs | ←/→ move between tabs, Home/End first/last, Tab moves to panel (roving tabindex; auto- vs manual-activation) | | Menu / Menubar | ↑/↓ items, →/← submenu, Esc closes + returns focus to trigger, type-ahead | | Combobox | ↓ opens, ↑/↓ options, Enter selects, Esc closes, type filters | | Accordion | Tab between headers, Enter/Space toggles panel | | Dialog | Tab/Shift+Tab trapped, Esc closes, focus restored to trigger | | Radio group | ↑/↓/←/→ move AND select (single Tab stop) | Implement composite widgets against the WAI-ARIA Authoring Practices patterns rather than inventing key handling. Don't hijack browser/SR shortcuts (single-key handlers can collide with SR quick-nav — gate them behind a modifier or a focused control, per WCAG 2.1.4). ## Screen Readers | Reader | Platform | Notes | | ------ | -------- | ----- | | NVDA | Windows | Free, dominant for testing. H=next heading, K=link, F=form field, D=landmark, NVDA+F7=elements list | | JAWS | Windows | Paid, enterprise. H=heading, Insert+F6=headings list | | VoiceOver | macOS/iOS | Built-in (Cmd+F5). VO=Ctrl+Opt; VO+U=rotor, VO+→/← navigate | | TalkBack | Android | Built-in. Swipe →/← navigate, double-tap activate | Test matrix that matters: **NVDA+Firefox and VoiceOver+Safari** cover most real usage. Chrome+NVDA second. Behavior differs across pairs — a name that reads on one may not on another. SR test checklist: - [ ] Page title announces on load / route change - [ ] Headings form a logical outline (no skipped levels) - [ ] Landmarks present and uniquely labeled; skip link is the first Tab stop - [ ] Images: informative have meaningful alt, decorative have `alt=""` - [ ] Links/buttons have descriptive names (no bare "click here"/icon-only without label) - [ ] Form fields announce label, required, hint, and error - [ ] Custom widgets announce role + state, and state updates are heard - [ ] Dynamic changes announce via live regions (and only the right ones) ## Contrast & Visual - Body ≥ 4.5:1, large text ≥ 3:1, UI/graphics/focus ring ≥ 3:1 (1.4.11). - Contrast ratio = (L_lighter + 0.05) / (L_darker + 0.05), L = relative luminance. Don't hand-roll — use axe, the browser DevTools contrast picker, or a lib; verify against the *actual* rendered background (gradients/overlays included). - `:focus-visible` (not `:focus`) shows the ring for keyboard users without flashing it on mouse click. Never remove the ring without a ≥3:1 replacement. - Respect `prefers-reduced-motion` — vestibular disorders. Also `prefers-contrast`, `forced-colors` (Windows High Contrast: don't set colors via background-image; use `currentColor` and system color keywords). ```css :focus-visible { outline: 2px solid #005fcc; outline-offset: 2px; } @media (prefers-contrast: more) { :focus-visible { outline-width: 3px; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } } /* Screen-reader-only text — visible to SR, off-screen visually. NOT display:none (that hides from SR too) */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
Ver en GitHub
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion. Ver en GitHub