La accesibilidad web no es una simple lista de verificación de maquetación: es un invariante arquitectónico indispensable para construir interfaces sólidas y fiables. En plataformas corporativas y aplicaciones enterprise, desatender los estándares de accesibilidad genera contingencias legales directas (cumplimiento de la European Accessibility Act (EAA), Americans with Disabilities Act (ADA)) y excluye del producto a millones de personas que dependen de tecnologías de asistencia (lectores de pantalla Screen Readers, navegación exclusiva por teclado físico, pantallas braille).
La accesibilidad en aplicaciones web modernas se vertebra en tres niveles interconectados:
En aplicaciones dinámicas SPA con React, la accesibilidad convencional se quiebra de inmediato si el equipo de desarrollo desatiende la gestión del foco y de los estados:
Tab a través de los enlaces de fondo de la página, sin vía de acceso al contenido del modal.onClick a un elemento <div> lo despoja de su rol semántico en el Accessibility Tree: el lector de pantalla lo anunciará como texto estático y las teclas Enter o Space no dispararán la acción.aria-live).Tab impone una sobrecarga motriz y cognitiva inasumible (Cognitive & Motor overload).El motor del navegador convierte el DOM de HTML en una estructura paralela: el Accessibility Tree (Árbol de accesibilidad). Los lectores de pantalla (NVDA, JAWS, VoiceOver) y los administradores de navegación por teclado leen esta jerarquía semántica, ignorando las reglas visuales de CSS.
En un SPA dinámico en React se distinguen tres áreas clave de responsabilidad en a11y:
aria-live="polite" o aria-live="assertive".Uso de button, dialog, nav y main nativos en vez de div y span, siguiendo la First Rule of ARIA.
Incorporación de atributos aria-expanded, aria-controls y aria-haspopup en paneles desplegables.
Implementación de Tab, Esc, navegación por flechas (Roving Tabindex) y retención cíclica Focus Trap.
Integración de axe-core en pruebas unitarias y validación con lectores de pantalla (NVDA / VoiceOver).
¿Cuál es la ventaja primordial del patrón Roving Tabindex frente a la navegación común con la tecla Tab en listas de más de 50 elementos?
Aislamiento del usuario en la capa emergente: bloqueo del scroll de la página de fondo, captura de la tecla Escape para el cierre y ciclado estricto del foco de la tecla Tab dentro del modal.
Aplicación de Roving Tabindex: el usuario presiona Tab una vez para ingresar en la tabla y navega fluidamente con las flechas [←] [↑] [→] [↓] por millares de celdas sin saturar el historial de tabulación del navegador.
Configuración de aria-live="polite" o aria-live="assertive" para que los lectores de pantalla transmitan actualizaciones del sistema ("El archivo se ha guardado con éxito") sin interrumpir el flujo de lectura del usuario.
La accesibilidad en React requiere sincronizar el ciclo de vida de los nodos DOM, los manejadores de eventos de teclado y la limpieza de referencias:
Antes de desplegar el diálogo se guarda la referencia al elemento que inició la acción (por ejemplo, el botón 'Editar perfil').
Inspección del subárbol DOM del contenedor en busca de nodos enfocables: button, input, select, [tabindex='0'].
Pulsar Tab en el último elemento redirige al primero. Shift+Tab en el primero redirige al último. Escape invoca el cierre.
Al desmontarse el modal, el foco se devuelve de manera determinista al elemento de activación original.
¿En qué circunstancia específica está justificado recurrir a aria-live='assertive' en lugar de aria-live='polite'?
Este hook detecta los elementos enfocables dentro del contenedor y restringe cíclicamente el recorrido del teclado:
import * as React from 'react';
// Selector de todos los elementos interactivos enfocables del DOM
const FOCUSABLE_ELEMENTS_SELECTOR = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
export interface UseFocusTrapOptions {
/** Indica si la trampa de foco está activa */
isActive: boolean;
/** Función ejecutada al presionar la tecla Escape */
onEscape?: () => void;
/** Indica si se debe devolver el foco al elemento de origen al desactivarse */
returnFocusOnDeactivate?: boolean;
}
export function useFocusTrap<T extends HTMLElement>(
options: UseFocusTrapOptions
): React.RefObject<T | null> {
const {
isActive,
onEscape,
returnFocusOnDeactivate = true,
} = options;
const containerRef = React.useRef<T | null>(null);
const previousActiveElementRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
if (!isActive) return;
// 1. Guardar el elemento que poseía el foco antes de abrir la trampa
if (typeof document !== 'undefined') {
previousActiveElementRef.current = document.activeElement as HTMLElement | null;
}
const container = containerRef.current;
if (!container) return;
// 2. Obtener los nodos enfocables accesibles
const getFocusableNodes = (): HTMLElement[] => {
const nodes = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS_SELECTOR)
);
// Filtrar elementos ocultos con display: none o dimensiones cero
return nodes.filter(
(node) => node.offsetWidth > 0 || node.offsetHeight > 0 || node.getClientRects().length > 0
);
};
const focusableNodes = getFocusableNodes();
const firstNode = focusableNodes[0];
// Establecer el foco inicial
if (firstNode) {
firstNode.focus();
} else {
// Si no existen elementos interactivos hijos, enfocar el contenedor
container.tabIndex = -1;
container.focus();
}
// 3. Manejador de eventos de teclado
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
onEscape?.();
return;
}
if (event.key !== 'Tab') return;
const nodes = getFocusableNodes();
if (nodes.length === 0) {
event.preventDefault();
return;
}
const firstElement = nodes[0];
const lastElement = nodes[nodes.length - 1];
// Shift + Tab: navegación inversa
if (event.shiftKey) {
if (document.activeElement === firstElement || document.activeElement === container) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: navegación hacia adelante
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown, true);
// 4. Limpieza y restauración del foco
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
if (returnFocusOnDeactivate && previousActiveElementRef.current) {
// Devolución asíncrona del foco en el siguiente microtask
setTimeout(() => {
previousActiveElementRef.current?.focus();
}, 0);
}
};
}, [isActive, onEscape, returnFocusOnDeactivate]);
return containerRef;
}Componente de utilidad para transmitir novedades del sistema a los usuarios de lectores de pantalla sin recargar la interfaz visual:
'use client';
import * as React from 'react';
type Politeness = 'polite' | 'assertive';
interface Announcement {
id: string;
message: string;
politeness: Politeness;
}
interface AnnouncerContextValue {
announce: (message: string, politeness?: Politeness) => void;
}
const AnnouncerContext = React.createContext<AnnouncerContextValue | null>(null);
export function useAnnouncer(): AnnouncerContextValue {
const context = React.useContext(AnnouncerContext);
if (!context) {
throw new Error('useAnnouncer debe utilizarse dentro de un <AnnouncerProvider>');
}
return context;
}
export function AnnouncerProvider({ children }: { children: React.ReactNode }) {
const [announcements, setAnnouncements] = React.useState<Announcement[]>([]);
const announce = React.useCallback((message: string, politeness: Politeness = 'polite') => {
const id = Math.random().toString(36).substring(2, 9);
setAnnouncements((prev) => [...prev, { id, message, politeness }]);
// Descartar anuncio tras su locución
setTimeout(() => {
setAnnouncements((prev) => prev.filter((item) => item.id !== id));
}, 1000);
}, []);
const politeAnnouncements = announcements.filter((a) => a.politeness === 'polite');
const assertiveAnnouncements = announcements.filter((a) => a.politeness === 'assertive');
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
{/* Regiones vivas ocultas visualmente (sr-only) */}
<div className="sr-only">
<div role="status" aria-live="polite" aria-atomic="true">
{politeAnnouncements.map((a) => (
<p key={a.id}>{a.message}</p>
))}
</div>
<div role="alert" aria-live="assertive" aria-atomic="true">
{assertiveAnnouncements.map((a) => (
<p key={a.id}>{a.message}</p>
))}
</div>
</div>
</AnnouncerContext.Provider>
);
}Comprobar la accesibilidad con pruebas de integración en Vitest previene regresiones en contraste cromático, roles ARIA y etiquetas descriptivas:
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import * as React from 'react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
expect.extend(toHaveNoViolations);
describe('Accessibility Audit (axe-core)', () => {
it('El diálogo modal debe cumplir los criterios WCAG 2.2 AA sin infracciones', async () => {
const { container } = render(
<Dialog open={true}>
<DialogContent>
<DialogTitle>Configuración del perfil</DialogTitle>
<DialogDescription>
Gestione las opciones de privacidad y las sesiones activas.
</DialogDescription>
<form className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium">
Nombre de usuario
</label>
<input
id="username"
type="text"
className="mt-1 block w-full rounded border px-3 py-2"
defaultValue="principal_architect"
/>
</div>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer">
Guardar
</button>
</form>
</DialogContent>
</Dialog>
);
// Ejecutar auditoría axe-core bajo las reglas de WCAG 2.2
const results = await axe(container, {
rules: {
// Verificación de contraste de color (mínimo 4.5:1 para texto normal)
'color-contrast': { enabled: true },
// Validez de roles ARIA
'aria-roles': { enabled: true },
// Nombres accesibles en campos y controles
'label': { enabled: true },
},
});
expect(results).toHaveNoViolations();
});
});Asociar onClick a un <div> priva a los usuarios de teclado de interactuar con el control: el <div> no recibe foco por Tab, no responde a las pulsaciones de Enter ni Space y el lector de pantalla lo transmite como un bloque estático de texto.
// ❌ ANTIPATRÓN: Div clickeable inaccesible
<div onClick={handleSubmit} className="btn-primary">
Enviar
</div>
// ✅ RECOMENDADO: Elemento button semántico con accesibilidad nativa
<button type="button" onClick={handleSubmit} className="btn-primary">
Enviar
</button>Aplicar outline-none sin proporcionar estilos alternativos (focus-visible:ring-2 focus-visible:ring-accent-violet) ciega a los usuarios que operan el navegador mediante teclado, impidiéndoles identificar qué control está activo en pantalla.
// ❌ ANTIPATRÓN: Eliminación absoluta del anillo de foco
<button className="outline-none">Pulsar</button>
// ✅ RECOMENDADO: Foco visible accesible acotado a la interacción por teclado
<button className="focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-violet focus-visible:ring-offset-2">
Pulsar
</button>El modo assertive corta inmediatamente la síntesis de voz del lector de pantalla a mitad de frase. Usarlo para confirmaciones ordinarias («Borrador guardado») genera un rechazo profundo. En el 99% de las alertas debe utilizarse aria-live="polite", que aguarda a que finalice la frase actual.
Cuando un usuario elimina un elemento mediante un botón «Eliminar» y ese nodo se desmonta, el foco del navegador se restablece en el tag <body>, obligando al usuario a tabular de nuevo desde el inicio de la página. Al suprimir un nodo activo, traslade explícitamente el foco al elemento adyacente o al encabezado del bloque.