Hub

Accessible Components Hub

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>
<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 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

  1. W3C - Prácticas de Autoría WAI-ARIA
  2. W3C - Tutorial de Formularios
  3. MDN - Roles ARIA
  4. WebAIM - Formularios Accesibles
  5. A11Y Project - Recursos
  6. Deque - Biblioteca de Código

Artículos relacionados

En la misma categoría

Hub

Wcag Compliance Hub

La accesibilidad web asegura que los sitios web y aplicaciones puedan ser utilizados por todos, incluyendo personas con discapacidades

Introducción

Form Labels Explained

Las etiquetas de formulario son descripciones de texto que identifican el propósito de los campos de formulario

Otros temas

Hub

Basic Seo Fundamentals Hub

Todo sitio web exitoso se construye sobre una base sólida de fundamentos SEO