Skip to content

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:

  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.
  2. Operable — toda interacción es accesible por teclado. Sin trampas de focus. Tiempos de respuesta configurables. El movimiento no provoca ataques (sin parpadeos >3Hz).
  3. Comprensible — texto legible, comportamiento predecible, errores claramente identificados, instrucciones de corrección provistas.
  4. 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, no OK).
  • Botones solo de iconoaria-label es 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 solo aria-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 por htmlFor/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-describedby apuntando 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).
  • autoComplete apropiado en cada field (email, current-password, given-name, etc.).

2.3 Color y contraste

ContenidoContraste 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 Tab en orden lógico (orden DOM = orden visual; evita tabindex >0).
  • Enter/Space activan botones y enlaces.
  • Escape cierra modales, drawers, popovers, dialogs.
  • Las flechas (Arrow*) navegan dentro de listas, menús, comboboxes (según el role).
  • Home/End saltan a los extremos en listas y tablas.
  • Focus siempre visible. No sobrescribas el :focus-visible global 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 un h1.
  • <main id="main-content"> para el contenido principal. SkipLink apunta 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 usan alt="" (string vacío, no omitido).
  • Iconos decorativos: aria-hidden="true". Iconos funcionales (botón solo de icono) usan aria-label en el botón padre y aria-hidden en 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

tsx
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.
  • onEscape opcional — cierra el modal/drawer.
  • Restaura el focus al elemento previamente focuseado (configurable).

useAnnounce

tsx
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 document no existe.
tsx
<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>

tsx
<button>
  <SearchIcon aria-hidden="true" />
  <VisuallyHidden>Search</VisuallyHidden>
</button>
  • Visualmente oculto, mantenido en screen readers.
  • Tag configurable vía as.

<LiveRegion>

tsx
<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> o aria-label/aria-labelledby.
  • [ ] Los errores de form tienen aria-invalid + aria-describedby apuntando al mensaje.
  • [ ] Tab recorre toda la página en orden lógico.
  • [ ] Escape cierra cualquier overlay (modal, drawer, menu).
  • [ ] :focus-visible es visible en todo elemento focuseable (sin outline: none global).
  • [ ] Contraste verificado (color picker de DevTools o plugin).
  • [ ] Las imágenes tienen alt descriptivo (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 useAnnounce para acciones async.
  • [ ] axe-core pasa 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 .tsx en apps/ buscando patrones sospechosos (img sin alt, button sin aria-label, etc.).
  • CI — Playwright con @axe-core/playwright corriendo en smoke E2E.

5.3 Screen readers

Plataformas que testeamos antes de release:

OSScreen readerVersión
macOSVoiceOverlatest
WindowsNVDA2024+
iOSVoiceOver17+
AndroidTalkBack14+

6. Patrones comunes

6.1 Modal / Dialog

tsx
<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

tsx
<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:

tsx
useEffect(() => {
  if (loading) announce('Loading...');
}, [loading]);

7. Referencias


Ante la duda: prefiere demasiada a11y. El coste de añadir aria-label es cero, el coste de no tenerlo es un usuario excluido.

Licencia MIT — construido con Inertia + React + Laravel.