Ver contenido
Hub de Componentes Web Accesibles: Formularios, Navegación y Elementos Interactivos
Introducción
Construir componentes web accesibles requiere entender tanto los aspectos visuales como programáticos de las interfaces de usuario. Mientras que los elementos HTML nativos vienen con accesibilidad incorporada, los componentes interactivos personalizados necesitan consideración adicional para funcionar correctamente con tecnologías de asistencia.
Esta página hub proporciona guía comprehensiva sobre la creación de formularios accesibles, menús de navegación, diálogos, pestañas y otros elementos interactivos que funcionan para todos los usuarios, sin importar cómo interactúen con tu sitio web.
Por Qué Importa la Accesibilidad de Componentes
El Desafío
Problemas de Componentes Personalizados:
┌─────────────────────────────────────────────────────────┐
│ 97% de páginas de inicio tienen fallas WCAG detectables│
│ Accesibilidad de formularios es #3 categoría de fallas │
│ Controles personalizados a menudo sin soporte teclado │
│ Mal uso de ARIA es peor que no usar ARIA │
└─────────────────────────────────────────────────────────┘
Principios Clave
| Principio | Descripción |
|---|---|
| Perceptible | Usuarios pueden percibir estado y contenido del componente |
| Operable | Funciona con teclado, ratón, táctil y tecnología asistiva |
| Comprensible | Comportamiento predecible y etiquetas claras |
| Robusto | Funciona en todos los navegadores y tecnologías asistivas |
Formularios Accesibles
Etiquetas de Formulario
Cada input de formulario debe tener una etiqueta asociada:
<!-- Método 1: Asociación explícita de etiqueta -->
<label for="usuario">Nombre de Usuario</label>
<input type="text" id="usuario" name="usuario">
<!-- Método 2: Etiqueta implícita (envolviendo) -->
<label>
Correo Electrónico
<input type="email" name="email">
</label>
<!-- Método 3: aria-labelledby (para casos complejos) -->
<span id="label-nombre">Nombre Completo</span>
<span id="hint-nombre">Como aparece en tu ID</span>
<input type="text" aria-labelledby="label-nombre hint-nombre">
Campos Requeridos
<!-- Indicar campos requeridos correctamente -->
<label for="email">
Correo Electrónico
<span aria-hidden="true">*</span>
</label>
<input type="email" id="email" name="email"
required aria-required="true">
<!-- Leyenda explicando asterisco -->
<p aria-hidden="true">* Campos requeridos</p>
Manejo de Errores
<!-- Mensajes de error accesibles -->
<label for="password">Contraseña</label>
<input type="password" id="password" name="password"
aria-describedby="password-error"
aria-invalid="true">
<span id="password-error" role="alert">
La contraseña debe tener al menos 8 caracteres
</span>
Fieldsets de Formulario
<!-- Agrupar campos relacionados -->
<fieldset>
<legend>Dirección de Envío</legend>
<label for="calle">Dirección</label>
<input type="text" id="calle" name="calle">
<label for="ciudad">Ciudad</label>
<input type="text" id="ciudad" name="ciudad">
</fieldset>
Ejemplo Completo de Formulario Accesible
<form aria-labelledby="form-titulo" novalidate>
<h2 id="form-titulo">Contáctanos</h2>
<div class="form-group">
<label for="nombre">
Nombre Completo
<span class="required" aria-hidden="true">*</span>
</label>
<input type="text" id="nombre" name="nombre"
required aria-required="true"
autocomplete="name">
</div>
<div class="form-group">
<label for="email">
Correo
<span class="required" aria-hidden="true">*</span>
</label>
<input type="email" id="email" name="email"
required aria-required="true"
autocomplete="email">
</div>
<div class="form-group">
<label for="mensaje">Mensaje</label>
<textarea id="mensaje" name="mensaje" rows="5"></textarea>
</div>
<button type="submit">Enviar Mensaje</button>
</form>
Navegación Accesible
Navegación Principal
<nav aria-label="Principal">
<ul role="list">
<li><a href="/" aria-current="page">Inicio</a></li>
<li><a href="/productos">Productos</a></li>
<li><a href="/nosotros">Nosotros</a></li>
<li><a href="/contacto">Contacto</a></li>
</ul>
</nav>
Menús Desplegables
<nav aria-label="Principal">
<ul role="list">
<li>
<button aria-expanded="false" aria-haspopup="true"
aria-controls="menu-productos">
Productos
<span aria-hidden="true">▼</span>
</button>
<ul id="menu-productos" role="menu" hidden>
<li role="none">
<a role="menuitem" href="/software">Software</a>
</li>
<li role="none">
<a role="menuitem" href="/hardware">Hardware</a>
</li>
</ul>
</li>
</ul>
</nav>
Patrón de Navegación por Teclado
// Soporte de teclado para dropdown
const dropdown = document.querySelector('[aria-haspopup]');
const menu = document.querySelector('[role="menu"]');
const items = menu.querySelectorAll('[role="menuitem"]');
dropdown.addEventListener('keydown', (e) => {
switch(e.key) {
case 'ArrowDown':
case 'Enter':
case ' ':
e.preventDefault();
menu.hidden = false;
dropdown.setAttribute('aria-expanded', 'true');
items[0].focus();
break;
}
});
menu.addEventListener('keydown', (e) => {
const current = document.activeElement;
const index = Array.from(items).indexOf(current);
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
items[(index + 1) % items.length].focus();
break;
case 'ArrowUp':
e.preventDefault();
items[(index - 1 + items.length) % items.length].focus();
break;
case 'Escape':
menu.hidden = true;
dropdown.setAttribute('aria-expanded', 'false');
dropdown.focus();
break;
}
});
Migas de Pan (Breadcrumbs)
<nav aria-label="Ruta de navegación">
<ol>
<li><a href="/">Inicio</a></li>
<li><a href="/productos">Productos</a></li>
<li><a href="/productos/software" aria-current="page">Software</a></li>
</ol>
</nav>
Enlaces de Salto
<!-- Colocar al inicio del body -->
<a href="#contenido-principal" class="skip-link">
Saltar al contenido principal
</a>
<!-- CSS para enlace de salto -->
<style>
.skip-link {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus {
position: fixed;
top: 10px;
left: 10px;
width: auto;
height: auto;
padding: 1rem;
background: #000;
color: #fff;
z-index: 10000;
}
</style>
Diálogos/Modales Accesibles
Estructura del Modal
<div id="modal" role="dialog" aria-modal="true"
aria-labelledby="modal-titulo"
aria-describedby="modal-descripcion"
hidden>
<div class="modal-content">
<h2 id="modal-titulo">Confirmar Eliminación</h2>
<p id="modal-descripcion">
¿Estás seguro de que quieres eliminar este elemento?
Esta acción no se puede deshacer.
</p>
<div class="modal-actions">
<button type="button" class="btn-secondary"
data-dismiss="modal">Cancelar</button>
<button type="button" class="btn-danger">Eliminar</button>
</div>
<button type="button" class="modal-close"
aria-label="Cerrar diálogo">×</button>
</div>
</div>
Gestión de Foco
class ModalAccesible {
constructor(modalElement) {
this.modal = modalElement;
this.elementosEnfocables = this.modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
this.primerEnfocable = this.elementosEnfocables[0];
this.ultimoEnfocable = this.elementosEnfocables[this.elementosEnfocables.length - 1];
this.anteriormenteFocused = null;
}
abrir() {
this.anteriormenteFocused = document.activeElement;
this.modal.hidden = false;
this.primerEnfocable.focus();
document.body.classList.add('modal-open');
this.atraparFoco();
}
cerrar() {
this.modal.hidden = true;
document.body.classList.remove('modal-open');
this.anteriormenteFocused?.focus();
}
atraparFoco() {
this.modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === this.primerEnfocable) {
e.preventDefault();
this.ultimoEnfocable.focus();
} else if (!e.shiftKey && document.activeElement === this.ultimoEnfocable) {
e.preventDefault();
this.primerEnfocable.focus();
}
}
if (e.key === 'Escape') {
this.cerrar();
}
});
}
}
Pestañas Accesibles
Patrón de Panel de Pestañas
<div class="tabs">
<div role="tablist" aria-label="Información del Producto">
<button role="tab" id="tab-1" aria-selected="true"
aria-controls="panel-1" tabindex="0">
Descripción
</button>
<button role="tab" id="tab-2" aria-selected="false"
aria-controls="panel-2" tabindex="-1">
Especificaciones
</button>
<button role="tab" id="tab-3" aria-selected="false"
aria-controls="panel-3" tabindex="-1">
Reseñas
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
<h3>Descripción del Producto</h3>
<p>...</p>
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
<h3>Especificaciones Técnicas</h3>
<p>...</p>
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
<h3>Reseñas de Clientes</h3>
<p>...</p>
</div>
</div>
Navegación por Teclado de Pestañas
// Navegación con teclas de flecha entre pestañas
const tablist = document.querySelector('[role="tablist"]');
const tabs = tablist.querySelectorAll('[role="tab"]');
tablist.addEventListener('keydown', (e) => {
const currentTab = document.activeElement;
const index = Array.from(tabs).indexOf(currentTab);
let newIndex;
switch(e.key) {
case 'ArrowRight':
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
activarTab(tabs[newIndex]);
});
function activarTab(tab) {
// Desactivar todas las pestañas
tabs.forEach(t => {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
document.getElementById(t.getAttribute('aria-controls')).hidden = true;
});
// Activar pestaña seleccionada
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
tab.focus();
document.getElementById(tab.getAttribute('aria-controls')).hidden = false;
}
Acordeones Accesibles
<div class="accordion">
<h3>
<button aria-expanded="false" aria-controls="seccion1-contenido"
id="seccion1-header" class="accordion-trigger">
Sección 1
<span aria-hidden="true">+</span>
</button>
</h3>
<div id="seccion1-contenido" role="region"
aria-labelledby="seccion1-header" hidden>
<p>Contenido de la sección 1...</p>
</div>
<h3>
<button aria-expanded="false" aria-controls="seccion2-contenido"
id="seccion2-header" class="accordion-trigger">
Sección 2
<span aria-hidden="true">+</span>
</button>
</h3>
<div id="seccion2-contenido" role="region"
aria-labelledby="seccion2-header" hidden>
<p>Contenido de la sección 2...</p>
</div>
</div>
Botones Accesibles
Botón vs Enlace
<!-- Usar botón para acciones -->
<button type="button" onclick="enviarFormulario()">Enviar</button>
<button type="button" onclick="eliminarItem()">Eliminar</button>
<!-- Usar enlaces para navegación -->
<a href="/productos">Ver Productos</a>
<a href="/nosotros">Sobre Nosotros</a>
<!-- Botones con icono necesitan nombres accesibles -->
<button type="button" aria-label="Cerrar">
<svg aria-hidden="true">...</svg>
</button>
<button type="button" aria-label="Buscar">
<span class="icon-search" aria-hidden="true"></span>
</button>
Botones Toggle
<!-- Patrón de botón toggle -->
<button type="button" aria-pressed="false"
onclick="toggleModoOscuro(this)">
<span class="icon" aria-hidden="true">🌙</span>
Modo Oscuro
</button>
<script>
function toggleModoOscuro(button) {
const pressed = button.getAttribute('aria-pressed') === 'true';
button.setAttribute('aria-pressed', !pressed);
document.body.classList.toggle('dark-mode');
}
</script>
Gestión de Foco
Indicadores de Foco Visibles
/* Buenos indicadores de foco */
:focus {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* Foco de alto contraste para fondos oscuros */
.dark-theme :focus {
outline-color: #fff;
box-shadow: 0 0 0 2px #005fcc;
}
/* Foco personalizado para botones */
button:focus {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
/* Nunca solo quitar el foco */
/* MAL: :focus { outline: none; } */
Orden de Foco
<!-- Orden de lectura lógico -->
<header>...</header>
<nav>...</nav>
<main>
<h1>...</h1>
<!-- Contenido en orden lógico -->
</main>
<aside>...</aside>
<footer>...</footer>
<!-- Usar tabindex con moderación -->
<!-- tabindex="0" - agrega al orden de tab -->
<!-- tabindex="-1" - solo enfocable programáticamente -->
<!-- Evitar tabindex > 0 -->
Probando Componentes
Testing Automatizado
// Ejemplo con Jest + axe-core
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Formulario Accesible', () => {
it('no debe tener violaciones de accesibilidad', async () => {
const { container } = render(<FormularioContacto />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Lista de Verificación Manual
- [ ] Todos los elementos interactivos son accesibles por teclado
- [ ] El orden de foco es lógico
- [ ] Los indicadores de foco son visibles
- [ ] El lector de pantalla anuncia correctamente
- [ ] Los atributos ARIA son válidos
- [ ] El color no es el único indicador
Artículos Relacionados
Accesibilidad de Formularios
Foco y Navegación
Hubs Relacionados
Referencias
- W3C - Prácticas de Autoría WAI-ARIA
- W3C - Tutorial de Formularios
- MDN - Roles ARIA
- WebAIM - Formularios Accesibles
- A11Y Project - Recursos
- Deque - Biblioteca de Código