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
- ¿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.
- ¿Cómo se llama? Nombre accesible a partir del texto visible,
<label>,aria-labelledbyoaria-label(último recurso; debe coincidir con el texto visible cuando lo hay — WCAG 2.5.3). - ¿En qué estado está?
aria-expanded,aria-pressed,aria-selected,aria-current,aria-invalid,aria-busy,disabled, siempre sincronizados con la UI. - ¿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).
- ¿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-visiblevisible (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 — usascroll-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, aplicainertal 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, establecearia-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 deautocompletepara 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 sonaria-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 (atributolang). - Utilidad de ocultación visual (
.sr-only) para contexto adicional; nunca ocultes con ella elementos enfocables.
Pruebas (evidencia obligatoria)
- Automatizadas: axe (extensión del navegador,
@axe-core/playwrighto 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. - Pasada con teclado: completa la tarea principal solo con Tab/Shift+Tab/Enter/Espacio/Esc/flechas; el foco siempre visible y lógico.
- Zoom/reajuste: zoom al 200 % y ancho de 320px.
- Prueba rápida con lector de pantalla para los widgets nuevos: VoiceOver (Safari) o NVDA (Firefox/Chrome) — nombre, rol y estado anunciados correctamente.
- Contraste: comprueba los pares de tokens nuevos (
contrast.mjsde sumi-design).
Señales de relleno genérico
aria-labelen 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: nonesin 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 conhidden. - 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). NOrole="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) > botonesrole="tab"conaria-selected,aria-controls; panelesrole="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 conaria-labelledbyapuntando 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-describedbyapuntando 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 hijosrole="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-expandedaria-controls="listbox-id"aria-autocomplete="list"; el popuprole="listbox"con elementosrole="option";aria-activedescendantsigue 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; diapositivasrole="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
inerto 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 yaria-valuetextpara 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 usanrole="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/legendde 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-sorten la columna activa. Adaptable: contenedor con scroll horizontal contabindex="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">.