TechLead
Volver a Sistemas de Diseño
Tema 7 de 8

Accesibilidad en Sistemas de Diseño

Construye componentes inclusivos con cumplimiento WCAG, patrones ARIA y navegación por teclado

Por Qué la Accesibilidad es No Negociable

Los sistemas de diseño son el lugar perfecto para incorporar accesibilidad. Cuando tus componentes base son accesibles, cada producto construido sobre ellos hereda esa accesibilidad. Hazlo bien una vez, benefíciate en todas partes.

♿ Referencia Rápida WCAG 2.1

Perceptible

Alternativas de texto, subtítulos, contraste de color

Operable

Acceso por teclado, sin convulsiones, navegable

Comprensible

Legible, predecible, asistencia de entrada

Robusto

Compatible con tecnologías asistivas

📖 Directrices WCAG 2.1 Completas →

Requisitos de Contraste de Color

// Requisitos de Contraste WCAG 2.1:
// - AA: 4.5:1 para texto normal, 3:1 para texto grande (18px+ o 14px negrita)
// - AAA: 7:1 para texto normal, 4.5:1 para texto grande

// Usa la biblioteca colord para verificar contraste
import { colord, extend } from 'colord';
import a11yPlugin from 'colord/plugins/a11y';

extend([a11yPlugin]);

function checkContrast(foreground: string, background: string) {
  const ratio = colord(foreground).contrast(background);
  return {
    ratio: ratio.toFixed(2),
    passesAA: ratio >= 4.5,
    passesAALarge: ratio >= 3,
    passesAAA: ratio >= 7,
  };
}

// Prueba tus tokens de diseño
checkContrast('#ffffff', '#3b82f6'); // { ratio: '4.68', passesAA: true }
checkContrast('#6b7280', '#ffffff'); // { ratio: '4.69', passesAA: true }

// Herramientas:
// - Verificador de Contraste WebAIM: https://webaim.org/resources/contrastchecker/
// - Stark (plugin Figma): https://www.getstark.co/

Navegación por Teclado

// Cada elemento interactivo debe ser accesible por teclado
// Usa HTML semántico primero, agrega manejadores de teclado personalizados cuando sea necesario

import { useCallback, KeyboardEvent } from 'react';

// Hook personalizado para tabindex itinerante (menús, barras de herramientas)
function useRovingTabindex<T extends HTMLElement>(items: T[]) {
  const [focusedIndex, setFocusedIndex] = useState(0);

  const handleKeyDown = useCallback((e: KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
      case 'ArrowRight':
        e.preventDefault();
        setFocusedIndex((i) => (i + 1) % items.length);
        break;
      case 'ArrowUp':
      case 'ArrowLeft':
        e.preventDefault();
        setFocusedIndex((i) => (i - 1 + items.length) % items.length);
        break;
      case 'Home':
        e.preventDefault();
        setFocusedIndex(0);
        break;
      case 'End':
        e.preventDefault();
        setFocusedIndex(items.length - 1);
        break;
    }
  }, [items.length]);

  return { focusedIndex, handleKeyDown };
}

// Ejemplo de dropdown accesible
function Dropdown({ options, onSelect }) {
  const [isOpen, setIsOpen] = useState(false);
  const buttonRef = useRef<HTMLButtonElement>(null);

  return (
    <div>
      <button
        ref={buttonRef}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={(e) => {
          if (e.key === 'Escape') {
            setIsOpen(false);
            buttonRef.current?.focus();
          }
        }}
      >
        Seleccionar opción
      </button>
      {isOpen && (
        <ul role="listbox" aria-label="Opciones">
          {options.map((opt, i) => (
            <li
              key={opt.value}
              role="option"
              tabIndex={0}
              onClick={() => onSelect(opt)}
              onKeyDown={(e) => e.key === 'Enter' && onSelect(opt)}
            >
              {opt.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Patrones ARIA

// Usa ARIA solo cuando la semántica HTML no es suficiente
// "No ARIA es mejor que ARIA malo"

// ❌ Malo: Usar ARIA cuando HTML funciona
<div role="button" tabindex="0" onclick="...">Haz clic en mí</div>

// ✅ Bueno: Usa HTML nativo
<button onclick="...">Haz clic en mí</button>

// Patrones ARIA comunes para sistemas de diseño:

// 1. Diálogo Modal
<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-description"
>
  <h2 id="dialog-title">Eliminar Elemento</h2>
  <p id="dialog-description">¿Estás seguro de que quieres eliminar esto?</p>
  <button>Cancelar</button>
  <button>Eliminar</button>
</div>

// 2. Pestañas
<div role="tablist" aria-label="Pestañas de configuración">
  <button role="tab" aria-selected="true" aria-controls="panel-1">General</button>
  <button role="tab" aria-selected="false" aria-controls="panel-2">Privacidad</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
  Contenido de configuración general
</div>

// 3. Alerta / Toast
<div role="alert" aria-live="polite">
  Tus cambios han sido guardados.
</div>

// 4. Estado de carga
<button disabled aria-busy="true">
  <span className="spinner" aria-hidden="true" />
  Cargando...
</button>

// Referencia: Prácticas de Autoría WAI-ARIA
// https://www.w3.org/WAI/ARIA/apg/patterns/

Gestión del Foco

// useFocusTrap - mantiene el foco dentro de modales/diálogos
import { useEffect, useRef } from 'react';

function useFocusTrap<T extends HTMLElement>() {
  const containerRef = useRef<T>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const focusableElements = container.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0] as HTMLElement;
    const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;

    // Enfocar primer elemento al montar
    firstElement?.focus();

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement?.focus();
      } else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement?.focus();
      }
    };

    container.addEventListener('keydown', handleKeyDown);
    return () => container.removeEventListener('keydown', handleKeyDown);
  }, []);

  return containerRef;
}

// Uso en componente Dialog
function Dialog({ isOpen, onClose, children }) {
  const dialogRef = useFocusTrap<HTMLDivElement>();
  const previouslyFocused = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      previouslyFocused.current = document.activeElement as HTMLElement;
    } else {
      previouslyFocused.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div ref={dialogRef} role="dialog" aria-modal="true">
      {children}
    </div>
  );
}

Pruebas con Lector de Pantalla

// ¡Probar con lectores de pantalla es esencial!

// macOS: VoiceOver (incorporado)
// - Cmd + F5 para habilitar
// - Usa teclas VO: Ctrl + Opción

// Windows: NVDA (gratis)
// - Descarga desde https://www.nvaccess.org/
// - La tecla Insert es el modificador NVDA

// Pruebas automatizadas con axe-core
npm install @axe-core/react

// Configuración en desarrollo
import React from 'react';
import ReactDOM from 'react-dom';

if (process.env.NODE_ENV !== 'production') {
  import('@axe-core/react').then((axe) => {
    axe.default(React, ReactDOM, 1000);
    // Reporta problemas de accesibilidad a la consola
  });
}

// Complemento de accesibilidad de Storybook
npm install @storybook/addon-a11y

// .storybook/main.js
module.exports = {
  addons: ['@storybook/addon-a11y'],
};

// ¡Ejecuta axe en cada historia automáticamente!

// jest-axe para pruebas unitarias
npm install jest-axe

import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

test('Button es accesible', async () => {
  const { container } = render(<Button>Haz clic en mí</Button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Bibliotecas de Sistemas de Diseño Accesibles

Radix UI

Primitivas sin estilo y accesibles con soporte completo de teclado

React Aria (Adobe)

Hooks para primitivas de interfaz accesibles, extremadamente completos

Headless UI

Componentes sin estilo y accesibles de Tailwind Labs

Downshift

Primitivas de autocompletar/dropdown accesibles

✅ Lista de Verificación de Accesibilidad

  • ☐ Todos los elementos interactivos son accesibles por teclado
  • ☐ Los estados de foco son visibles y claros
  • ☐ El contraste de color cumple WCAG AA (4.5:1)
  • ☐ Las imágenes tienen texto alt, imágenes decorativas usan alt=""
  • ☐ Los inputs de formulario tienen etiquetas asociadas
  • ☐ Los mensajes de error se anuncian a lectores de pantalla
  • ☐ La página tiene jerarquía de encabezados adecuada (h1 → h2 → h3)
  • ☐ El foco está atrapado en modales/diálogos
  • ☐ Se proporcionan enlaces de salto para navegación