Accesibilidad Frontend

Guía A11y — AgroSmart React / Next.js

Correcciones de accesibilidad para el PR #47 "Pantalla de configuración de alertas". Patrones MALO vs BUENO, código listo para aplicar.

AgroSmart SaaS B2B
Next.js 14 · TypeScript
WCAG 2.1 AA
12 issues CodeRabbit PR #47
🌱

Contexto del proyecto

El equipo de frontend de AgroSmart ha recibido 12 issues de accesibilidad en el PR #47. Esta guía documenta cada patrón incorrecto detectado en AlertForm, SensorDropdown, DeleteModal e iconos de estado, con la corrección exacta que debe aplicarse.

AlertForm.tsx SensorDropdown.tsx DeleteModal.tsx StatusIcons.tsx aria-live focus-trap
1

AlertForm — Inputs y errores

Archivo: components/alerts/AlertForm.tsx · Issues #1–#5
MALO — código actual PR #47
// ❌ label sin htmlFor, input sin id
// ❌ error solo visual, no enlazado
// ❌ campo requerido con * visual solo

function AlertForm() {
  return (
    <form>
      <label>Nombre de alerta *</label>
      <input type="text" />

      <label>Umbral (°C)</label>
      <input type="number" />

      {error && (
        <span className="text-red-500">
          {error}
        </span>
      )}

      <button>Guardar</button>
    </form>
  );
}
BUENO — corrección aplicada
// ✅ htmlFor + id conectados
// ✅ aria-required + aria-invalid
// ✅ error enlazado con aria-describedby

function AlertForm() {
  return (
    <form noValidate>
      <label htmlFor="alert-name">
        Nombre de alerta
        <span aria-hidden="true"> *</span>
      </label>
      <input
        id="alert-name"
        type="text"
        aria-required="true"
        aria-invalid={!!error}
        aria-describedby="alert-name-err"
      />
      {error && (
        <span
          id="alert-name-err"
          role="alert"
        >{error}</span>
      )}

      <label htmlFor="threshold">
        Umbral (°C)
      </label>
      <input
        id="threshold"
        type="number"
        min="0"
        max="100"
      />

      <button type="submit">
        Guardar
      </button>
    </form>
  );
}
⚠️

Issue #1–#3: Sin htmlFor/id los lectores de pantalla (VoiceOver, NVDA) no pueden asociar la etiqueta al input al hacer focus. El * visual es invisible para screen readers — usa aria-hidden="true" en el span decorativo y aria-required="true" en el input.

Issue #4–#5: role="alert" anuncia el error automáticamente al aparecer. aria-describedby lo enlaza al input para que el lector lo lea al hacer focus sobre él.

2

SensorDropdown — Elemento interactivo no semántico

Archivo: components/alerts/SensorDropdown.tsx · Issues #6–#8
MALO — div clickable sin teclado
// ❌ div onClick sin role ni tabIndex
// ❌ lista no anunciada como menú
// ❌ sin soporte ArrowDown/Escape

function SensorDropdown({ sensors }) {
  const [open, setOpen] = useState(false);

  return (
    <div
      className="dropdown"
      onClick={() => setOpen(!open)}
    >
      Seleccionar sensor ▾
      {open && (
        <div className="dropdown-list">
          {sensors.map(s => (
            <div
              onClick={() => select(s)}
            >
              {s.name}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
BUENO — combobox accesible
// ✅ role="combobox" + tabIndex={0}
// ✅ aria-expanded + aria-controls
// ✅ ArrowDown/Up/Enter/Escape

function SensorDropdown({ sensors, onSelect }) {
  const [open, setOpen] = useState(false);
  const [idx, setIdx] = useState(0);
  const listId = useId();

  const handleKey = (e) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setIdx(i =>
          Math.min(i+1, sensors.length-1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setIdx(i => Math.max(i-1, 0));
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (open) onSelect(sensors[idx]);
        setOpen(v => !v);
        break;
      case 'Escape':
        setOpen(false); break;
    }
  };

  return (
    <div
      role="combobox"
      aria-expanded={open}
      aria-haspopup="listbox"
      aria-controls={listId}
      aria-label="Seleccionar sensor IoT"
      tabIndex={0}
      onKeyDown={handleKey}
      onClick={() => setOpen(v => !v)}
    >
      {sensors[idx].name} ▾
      {open && (
        <ul id={listId} role="listbox">
          {sensors.map((s, i) => (
            <li
              key={s.id}
              role="option"
              aria-selected={i === idx}
              onClick={() => {
                onSelect(s);
                setOpen(false);
              }}
            >{s.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}
⚠️

Issues #6–#8: Un <div onClick> sin role ni tabIndex es completamente inaccesible por teclado. Los usuarios de solo-teclado y lectores de pantalla no pueden alcanzarlo ni activarlo. El patrón combobox + listbox es el estándar ARIA WAI para este widget.

3

DeleteModal — Gestión de foco en modales

Archivo: components/alerts/DeleteModal.tsx · Issues #9–#11
MALO — modal sin gestión de foco
// ❌ sin role="dialog" ni aria-modal
// ❌ foco no se mueve al abrir
// ❌ no restaura foco al cerrar
// ❌ Escape no cierra el modal

function DeleteModal({ isOpen, onClose, onConfirm }) {
  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div className="modal">
        <h2>Eliminar alerta</h2>
        <p>
          ¿Seguro que quieres eliminar
          esta alerta? Esta acción
          no se puede deshacer.
        </p>
        <button onClick={onClose}>
          Cancelar
        </button>
        <button onClick={onConfirm}>
          Eliminar
        </button>
      </div>
    </div>
  );
}
BUENO — modal ARIA completo
// ✅ role="dialog" + aria-modal="true"
// ✅ aria-labelledby apunta al h2
// ✅ foco inicial + restauración
// ✅ Escape cierra el modal

function DeleteModal({ isOpen, onClose, onConfirm }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const prevFocus = useRef<HTMLElement | null>(null);

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

  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="del-title"
      tabIndex={-1}
      onKeyDown={e =>
        e.key === 'Escape' && onClose()}
    >
      <h2 id="del-title">
        Eliminar alerta
      </h2>
      <p id="del-desc">
        Esta acción no se puede deshacer.
      </p>
      <button
        onClick={onClose}
        aria-label="Cancelar eliminación"
      >Cancelar</button>
      <button
        onClick={onConfirm}
        aria-describedby="del-desc"
      >Eliminar</button>
    </div>
  );
}
⚠️

Issues #9–#11: Sin role="dialog" los lectores de pantalla no anuncian que hay un modal abierto. Sin gestión de foco, el cursor del lector de pantalla queda "atrás" del overlay y el usuario no puede interactuar. Para focus-trap completo (ciclo Tab/Shift+Tab) usa focus-trap-react.

4

StatusIcons — Imágenes e iconos SVG

Archivo: components/shared/StatusIcons.tsx · Issue #12
MALO — iconos sin alt / sin label
// ❌ img sin alt — NVDA lee el filename
// ❌ botón icono sin aria-label
// ❌ icono SVG decorativo anunciado

function SensorStatus({ sensor }) {
  return (
    <div>
      <img
        src="/icons/sensor-active.svg"
      />
      <span>{sensor.name}</span>

      <button onClick={onDelete}>
        <TrashIcon />
      </button>

      <img
        src="/decorations/wave.png"
        className="bg-wave"
      />
    </div>
  );
}
BUENO — alt, aria-hidden, aria-label
// ✅ alt descriptivo en imagen funcional
// ✅ aria-label en botón icono
// ✅ decorativas: alt="" + aria-hidden

function SensorStatus({ sensor }) {
  return (
    <div>
      {/* Imagen funcional: alt descriptivo */}
      <img
        src="/icons/sensor-active.svg"
        alt="Sensor activo"
        width={16}
        height={16}
      />
      <span>{sensor.name}</span>

      {/* Botón icono: aria-label obligatorio */}
      <button
        onClick={onDelete}
        aria-label={
          `Eliminar sensor ${sensor.name}`
        }
      >
        <TrashIcon aria-hidden="true" />
      </button>

      {/* Decorativa: ocultar a screen readers */}
      <img
        src="/decorations/wave.png"
        alt=""
        aria-hidden="true"
        className="bg-wave"
      />
    </div>
  );
}
5

Bonus: aria-live para notificaciones de sensor

Patrón proactivo — notificaciones en tiempo real accesibles

SensorAlert — componente de notificación accesible

Código listo para usar
// Notificaciones IoT en tiempo real accesibles con aria-live
// polite: espera acción actual del usuario antes de anunciar
// assertive: interrumpe inmediatamente — solo para alertas críticas

interface SensorAlertProps {
  message: string;
  severity: 'info' | 'warning' | 'critical';
}

export function SensorAlert({ message, severity }: SensorAlertProps) {
  const isCritical = severity === 'critical';

  return (
    <div
      role="status"
      aria-live={isCritical ? 'assertive' : 'polite'}
      aria-atomic="true"
      className={severity}
    >
      {message}
    </div>
  );
}

// Uso en dashboard de sensores:
// <SensorAlert message="Sensor S-04 supera umbral: 38°C" severity="warning" />
// <SensorAlert message="ALERTA CRÍTICA: Sensor S-01 sin señal" severity="critical" />
6

Anti-patrones detectados en el PR #47

Resumen de issues CodeRabbit con severidad WCAG 2.1 AA
# Archivo Anti-patrón Severidad Criterio WCAG
1–2 AlertForm.tsx BAD <label> sin htmlFor / <input> sin id Alta 1.3.1 Info & Relationships
3 AlertForm.tsx BAD * visual como único indicador de campo requerido Media 1.3.3 Sensory Characteristics
4–5 AlertForm.tsx BAD Error sin role="alert" ni aria-describedby Alta 3.3.1 Error Identification
6–7 SensorDropdown.tsx BAD <div onClick> sin role, tabIndex, onKeyDown Alta 2.1.1 Keyboard
8 SensorDropdown.tsx BAD Lista de opciones sin role="listbox" / role="option" Media 4.1.2 Name, Role, Value
9 DeleteModal.tsx BAD Modal sin role="dialog" ni aria-modal="true" Alta 4.1.2 Name, Role, Value
10–11 DeleteModal.tsx BAD Foco no gestionado al abrir/cerrar modal Alta 2.4.3 Focus Order
12 StatusIcons.tsx BAD <img> sin alt, botón icono sin aria-label Alta 1.1.1 Non-text Content

✅ Checklist de revisión — PR #47

Verificar antes de marcar el PR como listo para merge

Todo <input>, <select> y <textarea> tiene <label htmlFor> enlazado
Mensajes de error usan role="alert" y aria-describedby apuntando al input
Ningún <div onClick> o <span onClick> sin role, tabIndex={0} y onKeyDown
Botones icono (TrashIcon, EditIcon…) tienen aria-label descriptivo
Imágenes decorativas usan alt="" + aria-hidden="true"
Modal restaura foco al elemento que lo abrió al cerrarse
Contenido dinámico (alertas de sensor) usa aria-live="polite" o "assertive"
Instalar focus-trap-react para ciclo Tab/Shift+Tab en modales (pendiente sprint 14)