Accesibilidad (a11y)
Guía canónica de accesibilidad de Arqel. Baseline: WCAG 2.1 nivel AA. Este documento es la referencia única para autores de componentes, plugins, temas y aplicaciones construidas sobre Arqel. Los pull requests que regresen a11y serán rechazados.
La accesibilidad no es "pulido final" en Arqel — es un requisito funcional. Un admin panel inaccesible excluye a usuarios reales (operadores con baja visión, movilidad reducida, discapacidades cognitivas, ambientes ruidosos donde el audio es inviable, etc.). Como Arqel es la capa de operaciones de muchos sistemas internos en Brasil y el mundo, perjudicamos directamente el trabajo de personas reales cuando la a11y falla.
1. Principios
Seguimos los cuatro principios POUR de WCAG 2.1:
- Perceptible — la información y la UI deben presentarse de forma que cualquier usuario pueda percibirlas. Las imágenes tienen alt text, los videos tienen captions, el contraste es suficiente, el contenido no depende solo del color.
- Operable — toda interacción es accesible por teclado. Sin trampas de focus. Tiempos de respuesta configurables. El movimiento no provoca ataques (sin parpadeos >3Hz).
- Comprensible — texto legible, comportamiento predecible, errores claramente identificados, instrucciones de corrección provistas.
- Robusto — markup válido, semántica correcta, compatibilidad con tecnologías asistivas (screen readers, switches, eye-trackers, magnifiers).
Nivel objetivo
- AA es nuestra baseline. Las features que no alcancen AA no entran en una release estable.
- AAA es deseable donde sea posible (especialmente contraste 7:1 en modo "contrast-extra").
2. Convenciones obligatorias
2.1 Botones
- Botones con texto — usa texto descriptivo (
Save, noOK). - Botones solo de icono —
aria-labeles obligatorio, describiendo la acción:tsx<button type="button" aria-label="Close dialog"> <XIcon aria-hidden="true" /> </button> - Estado disabled — usa el atributo HTML
disabled(no soloaria-disabled); evita ocultar visualmente botones no disponibles. Cuando un botón pase a disabled tras una acción, anuncia el motivo vía live region.
2.2 Forms
- Cada
<input>debe tener un<label>asociado porhtmlFor/id:tsx<label htmlFor="email">Email</label> <input id="email" type="email" name="email" required /> - Para forms con layout compacto, usa
<VisuallyHidden as="label">en lugar de remover el label. - Los mensajes de error deben tener
aria-describedbyapuntando al input:tsx<input id="email" aria-invalid={hasError} aria-describedby="email-error" /> {hasError && <p id="email-error" role="alert">Invalid email</p>} - Campos requeridos marcados con HTML
required+ indicador visual (no te apoyes solo en color). autoCompleteapropiado en cada field (email, current-password, given-name, etc.).
2.3 Color y contraste
| Contenido | Contraste mínimo |
|---|---|
| Texto normal (<18.66px regular o <24px bold) | 4.5:1 |
| Texto grande (≥18.66px regular o ≥24px bold) | 3:1 |
| Componentes UI y gráficos (bordes, iconos funcionales) | 3:1 |
- Nunca transmitas información solo por color (e.g. "los campos rojos son obligatorios" sin otro indicador visual y textual).
- Los estados hover/focus/active deben tener un cambio detectable más allá del color (subrayado, borde, icono, etc.).
2.4 Teclado
- Todo focuseable vía
Taben orden lógico (orden DOM = orden visual; evitatabindex>0). Enter/Spaceactivan botones y enlaces.Escapecierra modales, drawers, popovers, dialogs.- Las flechas (
Arrow*) navegan dentro de listas, menús, comboboxes (según el role). Home/Endsaltan a los extremos en listas y tablas.- Focus siempre visible. No sobrescribas el
:focus-visibleglobal del design system para removerlo. Si el diseño lo considera "feo", diseña tu propio focus ring — nunca lo ocultes.
2.5 Estructura semántica
- Usa elementos nativos:
<button>,<a href>,<form>,<nav>,<main>,<aside>,<header>,<footer>,<section>,<article>. Traen semántica, focus y soporte de teclado gratis. Crea roles ARIA solo cuando no exista un elemento nativo. - Jerarquía de headings (
h1...h6) sin saltar niveles. Cada página tiene unh1. <main id="main-content">para el contenido principal.SkipLinkapunta a ese id.- Los landmarks ARIA son implícitos vía tags semánticos — no dupliques con
role="navigation"en<nav>.
2.6 Imágenes e iconos
<img alt="...">siempre. Las imágenes decorativas usanalt=""(string vacío, no omitido).- Iconos decorativos:
aria-hidden="true". Iconos funcionales (botón solo de icono) usanaria-labelen el botón padre yaria-hiddenen el icono. - SVGs con
<title>cuando transmitan significado por sí solos.
2.7 Live regions y anuncios
- Los resultados de acciones async (save, delete, import) deben anunciarse vía el helper
useAnnounce()de@arqel-dev/a11y. - Errores críticos: prioridad
assertive(interrumpe al SR). - Confirmaciones y progreso: prioridad
polite(espera a que el SR esté libre).
3. Helpers de @arqel-dev/a11y
El paquete @arqel-dev/a11y expone primitivas reutilizables. Prefiérelas siempre a reescribir focus traps o live regions por tu cuenta.
useFocusTrap
import { useFocusTrap } from '@arqel-dev/a11y';
const ref = useFocusTrap<HTMLDivElement>(open, { onEscape: onClose });
return <div ref={ref} role="dialog" aria-modal="true">...</div>;- Pone el focus en el primer elemento al activarse.
- Cicla Tab/Shift+Tab dentro del container.
onEscapeopcional — cierra el modal/drawer.- Restaura el focus al elemento previamente focuseado (configurable).
useAnnounce
const { announce } = useAnnounce();
announce('Record saved successfully');
announce('Failed to save — try again', 'assertive');- Reutiliza live regions globales (una por prioridad).
- SSR-safe: ignora silenciosamente cuando
documentno existe.
<SkipLink targetId>
<SkipLink targetId="main-content" />
<main id="main-content" tabIndex={-1}>...</main>- Renderiza un enlace visible solo en focus.
- Salta navegación repetitiva — primer elemento focuseable de la página.
<VisuallyHidden>
<button>
<SearchIcon aria-hidden="true" />
<VisuallyHidden>Search</VisuallyHidden>
</button>- Visualmente oculto, mantenido en screen readers.
- Tag configurable vía
as.
<LiveRegion>
<LiveRegion message={status} priority="polite" />- Para cuando necesitas controlar la región vía prop en lugar del hook (e.g. contenido reactivo a un store).
4. Checklist de PR
Usa este checklist en cualquier PR que toque UI:
- [ ] Markup semántico (no
<div onClick>para botones). - [ ] Botones solo de icono tienen
aria-label. - [ ] Los inputs tienen
<label htmlFor>oaria-label/aria-labelledby. - [ ] Los errores de form tienen
aria-invalid+aria-describedbyapuntando al mensaje. - [ ]
Tabrecorre toda la página en orden lógico. - [ ]
Escapecierra cualquier overlay (modal, drawer, menu). - [ ]
:focus-visiblees visible en todo elemento focuseable (sinoutline: noneglobal). - [ ] Contraste verificado (color picker de DevTools o plugin).
- [ ] Las imágenes tienen
altdescriptivo (o vacío si son decorativas). - [ ] Headings en orden (sin saltar
h2 → h4). - [ ] El color no es el único portador de información.
- [ ] Anuncios vía
useAnnouncepara acciones async. - [ ]
axe-corepasa con cero violaciones en el componente.
5. Herramientas
5.1 Manuales
- axe DevTools (extensión Chrome/Firefox) — análisis completo de la página.
- Lighthouse (Chrome DevTools) — score básico + sugerencias.
- VoiceOver (macOS,
Cmd+F5) — testea narración y navegación reales. - NVDA (Windows, gratis) — testea narración y navegación reales.
- Tab through — desconecta el mouse y navega con el teclado por 5 minutos.
5.2 Automatizadas
- vitest-axe en tests de componentes:ts
import { axe } from 'vitest-axe'; expect(await axe(container)).toHaveNoViolations(); pnpm --filter @arqel-dev/a11y audit:scan— barrido heurístico de archivos.tsxenapps/buscando patrones sospechosos (img sin alt, button sin aria-label, etc.).- CI — Playwright con
@axe-core/playwrightcorriendo en smoke E2E.
5.3 Screen readers
Plataformas que testeamos antes de release:
| OS | Screen reader | Versión |
|---|---|---|
| macOS | VoiceOver | latest |
| Windows | NVDA | 2024+ |
| iOS | VoiceOver | 17+ |
| Android | TalkBack | 14+ |
6. Patrones comunes
6.1 Modal / Dialog
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
>
<h2 id="dialog-title">Confirm</h2>
<p id="dialog-desc">Are you sure?</p>
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Confirm</button>
</div>6.2 Tabla ordenable
<th aria-sort={sortBy === 'name' ? sortDir : 'none'}>
<button onClick={() => sort('name')}>Name</button>
</th>6.3 Combobox / Autocomplete
Sigue WAI-ARIA Authoring Practices §3.5. role="combobox", aria-expanded, aria-controls, aria-activedescendant. El paquete @arqel-dev/ui ya encapsula esto — prefiere la primitiva ya hecha antes que reimplementar.
6.4 Estado de loading
No ocultes el focus durante la carga. Anuncia vía useAnnounce:
useEffect(() => {
if (loading) announce('Loading...');
}, [loading]);7. Referencias
- WCAG 2.1: https://www.w3.org/TR/WCAG21/
- WAI-ARIA Authoring Practices: https://www.w3.org/WAI/ARIA/apg/
- A11y Project Checklist: https://www.a11yproject.com/checklist/
- MDN Accessibility: https://developer.mozilla.org/en-US/docs/Web/Accessibility
- Inclusive Components (Heydon Pickering): https://inclusive-components.design/
Ante la duda: prefiere demasiada a11y. El coste de añadir aria-label es cero, el coste de no tenerlo es un usuario excluido.