SUMITSUBO

skill · sumi · v0.6.1

Accesibilidad — WCAG 2.2 AA

Accesibilidad según WCAG 2.2 AA como criterio de aceptación: semántica, teclado y gestión del foco, nombres y descripciones, contraste e indicios que no dependen del color, movimiento, formularios y errores, actualizaciones dinámicas, tamaño de los objetivos, y pruebas con axe más pasadas manuales con teclado y lector de pantalla. Úsala al construir o revisar cualquier componente de UI, página, formulario, modal, menú, carrusel o widget interactivo en cualquier stack, y antes de entregar trabajo visual.

Referencia
sumi
skill
a11y
source
plugins/sumi/skills/a11y/SKILL.md

La accesibilidad forma parte de "terminado", no de una auditoría posterior. El HTML nativo resuelve la mayor parte (sumi:html); esta skill cubre el resto. Las recetas de widgets están en references/patterns.md.

Las cinco preguntas para cada componente

  1. ¿Qué es? El rol correcto: un elemento nativo, o un rol ARIA que corresponda a un patrón conocido. Mejor sin ARIA que con ARIA incorrecto.
  2. ¿Cómo se llama? Nombre accesible a partir del texto visible, <label>, aria-labelledby o aria-label (último recurso; debe coincidir con el texto visible cuando lo hay — WCAG 2.5.3).
  3. ¿En qué estado está? aria-expanded, aria-pressed, aria-selected, aria-current, aria-invalid, aria-busy, disabled, siempre sincronizados con la UI.
  4. ¿Puedo operarlo solo con el teclado? Tab llega hasta él, Enter/Espacio lo activa, Esc lo descarta, las flechas se mueven dentro de los widgets compuestos, y el foco siempre es visible y nunca queda atrapado (salvo dentro de los modales).
  5. ¿Me daré cuenta cuando cambie? El foco se mueve de forma deliberada o una región en vivo anuncia el cambio.

Teclado y foco

  • Orden de tabulación lógico = orden del DOM. Nunca tabindex > 0. tabindex="-1" solo para destinos de foco programático.
  • Indicador :focus-visible visible (2.4.7). Regla de la casa, más estricta que AA y alineada con AAA 2.4.13: ≥ 2px y ≥ 3:1 frente a los colores adyacentes. Nunca tapado por cabeceras fijas (2.4.11 — usa scroll-padding-top).
  • Modales: el foco entra al abrir, queda contenido, Esc cierra y el foco vuelve al disparador. El <dialog>.showModal() nativo hace todo esto; si no, aplica inert al resto de la página.
  • Tras los cambios de ruta en las SPA, mueve el foco al encabezado principal (o anuncia el título de la página) y actualiza document.title.
  • Tras eliminar o filtrar elementos, coloca el foco en un lugar sensato (el elemento siguiente, el encabezado de la lista), nunca en <body>.
  • El contenido que solo aparece al pasar el cursor también debe aparecer con el foco, poder descartarse (Esc) y permitir pasar el cursor sobre él (1.4.13).

Perceptible

  • Contraste del texto ≥ 4.5:1 (≥ 3:1 desde 24px, o desde 18.66px en negrita); los límites de los componentes de UI, los iconos y los anillos de foco ≥ 3:1. Comprueba cada par de tokens en los temas claro y oscuro, incluido el texto sobre imágenes y degradados.
  • Nunca solo el color para transmitir significado (errores, enlaces en el texto corrido, series de gráficos, estados): añade texto, iconos o patrones.
  • El texto se amplía al 200 % y se reajusta a 320 px CSS sin pérdida (1.4.10). Sin texto dentro de imágenes.
  • Medios: subtítulos para el vídeo, transcripciones para el audio, sin reproducción automática con sonido, control de pausa para todo lo que se mueva durante más de 5 s (2.2.2).
  • Movimiento: respeta prefers-reduced-motion; nada parpadea más de 3 veces por segundo.

Operable (novedades de WCAG 2.2 que conviene recordar)

  • Tamaño del objetivo ≥ 24×24 px CSS o espaciado suficiente (2.5.8); apunta a 44×44 en las UI pensadas primero para táctil.
  • Arrastrar tiene una alternativa de puntero único (2.5.7): reordena las listas también con botones o con el teclado.
  • Foco no oculto por barras fijas o banners de cookies (2.4.11).
  • Ayuda coherente: los enlaces de contacto o ayuda en el mismo lugar en todas las páginas (3.2.6).
  • Entrada redundante: no pidas la misma información dos veces en un flujo; autocompleta (3.3.7).
  • Autenticación accesible: sin pruebas cognitivas; permite pegar y los gestores de contraseñas (3.3.8).

Formularios

  • Etiquetas visibles y asociadas mediante programación; los campos obligatorios se indican con texto (no solo con *).
  • Errores: describe el problema y la solución en texto, enlázalos con aria-describedby, establece aria-invalid="true" y, al enviar, mueve el foco a un resumen de errores o al primer campo inválido.
  • Agrupa los radios y casillas con fieldset/legend. Usa los tokens de autocomplete para los datos personales.
  • No valides en cada pulsación de tecla con anuncios; valida al perder el foco o al enviar.

Contenido dinámico

  • Los mensajes de estado ("Añadido al carrito", "3 resultados") mediante una región role="status" (educada) persistente que exista antes de la actualización. role="alert" solo para errores urgentes.
  • Carga: aria-busy="true" en la región que se actualiza y un estado en texto; los esqueletos son aria-hidden.
  • El scroll infinito necesita un pie de página alcanzable y una alternativa de "cargar más".

Contenido

  • Un solo <h1>, sin saltarse niveles, encabezados y textos de enlace descriptivos ("Ver planes y precios", no "Haz clic aquí"; los "Leer más" repetidos reciben contexto mediante texto oculto visualmente).
  • Imágenes: el alt describe el propósito. Decorativa → alt="". Funcional (dentro de enlaces o botones) → describe la acción. Compleja (gráficos) → alt corto + un texto más largo cerca.
  • Idioma establecido en <html> y en los pasajes en otro idioma (atributo lang).
  • Utilidad de ocultación visual (.sr-only) para contexto adicional; nunca ocultes con ella elementos enfocables.

Pruebas (evidencia obligatoria)

  1. Automatizadas: axe (extensión del navegador, @axe-core/playwright o la auditoría de accesibilidad de Lighthouse) → 0 críticos/graves en las vistas afectadas. La automatización detecta ~30–40 %; es el mínimo, no la meta.
  2. Pasada con teclado: completa la tarea principal solo con Tab/Shift+Tab/Enter/Espacio/Esc/flechas; el foco siempre visible y lógico.
  3. Zoom/reajuste: zoom al 200 % y ancho de 320px.
  4. Prueba rápida con lector de pantalla para los widgets nuevos: VoiceOver (Safari) o NVDA (Firefox/Chrome) — nombre, rol y estado anunciados correctamente.
  5. Contraste: comprueba los pares de tokens nuevos (contrast.mjs de sumi-design).

Señales de relleno genérico

  • aria-label en todo, a menudo duplicando el texto visible o contradiciéndolo.
  • role="menu" para la navegación del sitio (usa un patrón de divulgación); role="button" en divs.
  • outline: none sin reemplazo; foco perdido tras cerrar un modal.
  • Botones con solo icono y sin nombre accesible; enlaces "Haz clic aquí".
  • Carruseles que se reproducen solos sin pausa; avisos que desaparecen antes de poder leerse.

Lista de verificación

  • Las cinco preguntas respondidas para cada elemento interactivo nuevo.
  • axe limpio (0 graves/críticos) en las vistas afectadas — evidencia registrada.
  • El recorrido completo con teclado funciona; foco visible, lógico, nunca perdido ni tapado.
  • Contraste verificado para todos los pares de color nuevos en todos los temas.
  • Movimiento reducido, zoom al 200 % y reajuste a 320px comprobados.

Recetas de widgets accesibles

Usa el elemento nativo cuando exista. Estas recetas cubren el resto. Las columnas de teclado listan las teclas obligatorias.

Disclosure (mostrar/ocultar, FAQ, conmutador de navegación móvil)

  • Prefiere <details><summary>. Personalizado: <button aria-expanded="false" aria-controls="panel-id"> + un panel con hidden.
  • Teclas: Enter/Espacio alterna. Esc cierra, opcional, cuando el panel se superpone al contenido.

Accordion

  • Cada cabecera es un encabezado que contiene un botón (<h3><button aria-expanded aria-controls>). Los paneles, opcionalmente, role="region" + aria-labelledby (solo cuando hay pocos paneles).
  • Grupos excluyentes: <details name="group"> nativo.

Navegación del sitio con desplegables

  • <nav aria-label="Main"> + lista de enlaces; los submenús se alternan con un <button aria-expanded> junto al elemento padre (o siendo ese elemento). NO role="menu" / menubar: esos son para menús de aplicación.
  • Teclas: Tab recorre los elementos; Enter/Espacio abre; Esc cierra y devuelve el foco al conmutador. Cierra cuando el foco sale del submenú.
  • Marca la página actual con aria-current="page".

Tabs

  • role="tablist" (con etiqueta) > botones role="tab" con aria-selected, aria-controls; paneles role="tabpanel" + aria-labelledby, tabindex="0" si el panel no tiene contenido enfocable.
  • Tabindex itinerante: solo la pestaña activa está en el orden de tabulación. Teclas: ←/→ se mueven (con vuelta al inicio), Home/End, Enter/Espacio activa (o activación automática si los paneles son baratos).
  • Si las pestañas cambian la URL o son secciones de contenido largas, considera enlaces simples + encabezados en su lugar.

Diálogo modal / drawer (carrito, filtros, menús en móvil)

  • <dialog> + showModal(); etiquétalo con aria-labelledby apuntando a su encabezado. Botón de cierre con nombre accesible.
  • Al abrir: enfoca el primer control con sentido (o el encabezado del diálogo si tiene mucho contenido). Al cerrar: devuelve el foco al disparador.
  • El fondo es inerte (nativo con showModal). Scroll del cuerpo bloqueado. Esc cierra. Evita los modales anidados.
  • Los drawers son diálogos que se deslizan; el movimiento debe respetar el movimiento reducido.

Popover no modal (tooltips, menús rápidos)

  • Atributo popover: popover="auto" para menús con cierre ligero, popover="hint"/manual para tooltips donde esté disponible.
  • Tooltips: el disparador tiene aria-describedby apuntando al texto del tooltip; se muestra al pasar el cursor Y con el foco; Esc lo oculta; nunca pongas contenido interactivo en un tooltip.

Botón de menú (menú de acciones, no navegación)

  • <button aria-haspopup="menu" aria-expanded> + role="menu" con hijos role="menuitem". Teclas: ↑/↓, Home/End, Enter activa, Esc cierra, escritura anticipada opcional. Solo para listas de acciones de tipo aplicación.

Combobox / autocompletar / búsqueda predictiva

  • Input role="combobox" aria-expanded aria-controls="listbox-id" aria-autocomplete="list"; el popup role="listbox" con elementos role="option"; aria-activedescendant sigue a la opción resaltada mientras el foco permanece en el input.
  • Teclas: ↓/↑ se mueven, Enter selecciona, Esc cierra (un segundo Esc limpia), escribir filtra. Anuncia el número de resultados en una región en vivo educada.
  • Prefiere una biblioteca bien probada (React Aria, Headless UI, Radix, Reka UI) para los combobox complejos.

Carrusel / slider de contenido

  • Contenedor role="region" + aria-roledescription="carousel" + etiqueta; diapositivas role="group" + aria-roledescription="slide" + etiquetas "n de N".
  • Botones de anterior/siguiente con nombre; pausa/reproducción visible si se reproduce solo (mejor sin reproducción automática). Pausa al pasar el cursor y cuando el foco está dentro.
  • Las diapositivas fuera de pantalla son inert o están ocultas para la tecnología de asistencia. Los carruseles basados en scroll-snap deben mantener el acceso con teclado a cada diapositiva.

Range slider

  • <input type="range"> nativo con etiqueta y aria-valuetext para valores legibles ("$40"). Filtros de precio con dos tiradores: dos inputs range o una biblioteca probada, más inputs numéricos como alternativa.

Toast / snackbar / estado

  • Contenedor role="status" persistente en el DOM desde la carga de la página; inyecta el texto del mensaje. Los errores usan role="alert".
  • Permanecen ≥ 5 s más el tiempo de lectura, se pausan al pasar el cursor o con el foco, y llevan botón de cierre si son persistentes. Nunca pongas el único camino hacia una acción dentro de un toast.

Tarjeta de producto (e-commerce)

  • <article> etiquetado por el título del producto. Un enlace principal (el título) extendido sobre la tarjeta con un pseudoelemento; las acciones secundarias (añadir rápido, lista de deseos) son botones reales colocados por encima del enlace extendido en el orden de apilamiento, visibles también con el foco y no solo al pasar el cursor.
  • Precio: oferta + original anunciados con claridad ("Precio de oferta $40, precio normal $60"), no solo con el tachado.

Filtros (búsqueda por facetas)

  • Cada faceta: un botón de disclosure + fieldset/legend de casillas o radios. Aplica de inmediato con una región en vivo educada que anuncie el número de resultados, o con un botón Aplicar explícito en los drawers móviles.
  • Los filtros activos se listan como botones que se pueden quitar ("Quitar filtro: Azul").

Tabla de datos

  • <table> real con <caption>, <th scope="col|row">. Encabezados ordenables: un botón dentro de <th> + aria-sort en la columna activa. Adaptable: contenedor con scroll horizontal con tabindex="0", role="region" y una etiqueta; no conviertas las tablas en cuadrículas de divs.

Enlace de salto

  • Primer elemento enfocable; visible con el foco; apunta a <main id="main" tabindex="-1">.