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 — Inputs y errores
// ❌ 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> ); }
// ✅ 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.
SensorDropdown — Elemento interactivo no semántico
// ❌ 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> ); }
// ✅ 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.
DeleteModal — Gestión de foco en modales
// ❌ 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> ); }
// ✅ 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.
StatusIcons — Imágenes e iconos SVG
// ❌ 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> ); }
// ✅ 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> ); }
Bonus: aria-live para notificaciones de sensor
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" />
Anti-patrones detectados en el PR #47
| # | 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
<input>, <select> y <textarea> tiene <label htmlFor> enlazado
role="alert" y aria-describedby apuntando al input
<div onClick> o <span onClick> sin role, tabIndex={0} y onKeyDown
aria-label descriptivo
alt="" + aria-hidden="true"
aria-live="polite" o "assertive"
focus-trap-react para ciclo Tab/Shift+Tab en modales (pendiente sprint 14)