El Módulo 4 nos dejó una base estática: HTML semántico, formularios con label, ARIA como semántica declarativa y estilos con foco visible. Recuerda que el desplegable del filtro del catálogo de Cursalia quedó "declarado pero sin comportamiento": tenía su role, su nombre accesible y sus estados ARIA preparados, pero al pulsarlo no pasaba nada. En esta lección le damos vida con JavaScript. Aprenderás a construir componentes interactivos (widgets) que no solo funcionan con ratón, sino que exponen su estado a las tecnologías de asistencia y gestionan el foco correctamente. La regla de oro que repetiremos hasta el cansancio: cada vez que cambia el DOM, debe cambiar el estado ARIA a la vez.

Contenido

  1. Qué es un widget y por qué ARIA sin JavaScript no basta
  2. La ARIA Authoring Practices Guide (APG) como catálogo de patrones
  3. Patrón Disclosure: el filtro del catálogo cobra vida
  4. Patrón Acordeón: temario y FAQ de Cursalia
  5. Patrón Tabs: pestañas del panel del alumno
  6. Patrón Diálogo Modal: la matrícula
  7. El ciclo "DOM + estado ARIA a la vez"
  8. Errores comunes y consejos
  9. Ejercicios
  10. Conclusión

  1. Qué es un widget y por qué ARIA sin JavaScript no basta

Un widget es un componente de interfaz compuesto que el navegador no ofrece de forma nativa: un acordeón, unas pestañas, un combobox, un diálogo modal. En el Módulo 4 aprendimos que ARIA describe el rol, el nombre y el estado de un elemento, pero ARIA no añade comportamiento. Un aria-expanded="false" no hace que nada se colapse; solo anuncia que algo está colapsado. Somos nosotros, con JavaScript, quienes debemos:

  • Reaccionar a la interacción del usuario (clic, teclado).
  • Cambiar el DOM (mostrar/ocultar, seleccionar).
  • Sincronizar el estado ARIA para que Marta (con NVDA) oiga el cambio.
  • Gestionar el foco para que Lucía (que navega con teclado) no se pierda.

Sin esa sincronización, tenemos un componente que "funciona" visualmente pero miente a las tecnologías de asistencia. Un botón que despliega un panel pero mantiene aria-expanded="false" es peor que no tener ARIA: promete una información falsa.

  1. La ARIA Authoring Practices Guide (APG) como catálogo de patrones

No tienes que inventar cómo debe comportarse un acordeón. La ARIA Authoring Practices Guide (APG) es la guía oficial del W3C que documenta, para cada patrón de widget, tres cosas:

Qué documenta la APG Ejemplo (patrón Tabs)
Roles, estados y propiedades ARIA role="tablist", role="tab", role="tabpanel", aria-selected, aria-controls
Interacción de teclado esperada Flechas para cambiar de pestaña, Home/End, Tab sale del grupo
Gestión de foco Roving tabindex sobre las pestañas

La APG es tu contrato de comportamiento. Cuando un usuario de lector de pantalla encuentra un role="tablist", su software le anuncia "grupo de pestañas" y espera poder moverse con las flechas. Si tú implementas otra cosa, rompes esa expectativa. Por eso en esta lección seguimos la APG al pie de la letra.

División de trabajo en este módulo. Aquí (05-01) construimos los patrones y gestionamos los estados ARIA dinámicos y el foco de cada componente. Las convenciones de teclas exhaustivas y las mecánicas de foco (tabindex, roving tabindex, focus trap, orden de foco) se detallan en 05-02 (Accesibilidad del Teclado). Cuando aquí digamos "gestiona las flechas" o "atrapa el foco", la técnica concreta está en 05-02. El contenido dinámico de las SPA (enrutado, anuncios de cambio de página) es del Módulo 7.

  1. Patrón Disclosure: el filtro del catálogo cobra vida

Un disclosure es el patrón más simple: un botón que muestra u oculta una región. Es exactamente lo que necesita el desplegable del filtro de Cursalia. Retomemos el HTML que dejamos preparado en el Módulo 4:

<!-- Estático, tal como quedó en el Módulo 4: declarado pero sin comportamiento -->
<button id="btn-filtro" aria-expanded="false" aria-controls="panel-filtro">
  Filtrar cursos
</button>
<div id="panel-filtro" hidden>
  <fieldset>
    <legend>Categoría</legend>
    <label><input type="checkbox" name="cat" value="dev"> Desarrollo</label>
    <label><input type="checkbox" name="cat" value="design"> Diseño</label>
  </fieldset>
</div>

Fíjate en tres detalles que ya estaban listos: es un <button> real (no un <div>), tiene aria-expanded y tiene aria-controls apuntando al panel por id. Ahora el JavaScript:

const btn = document.getElementById('btn-filtro');
const panel = document.getElementById('panel-filtro');

btn.addEventListener('click', () => {
  // 1. Leemos el estado ACTUAL desde el ARIA (fuente de verdad)
  const abierto = btn.getAttribute('aria-expanded') === 'true';

  // 2. Cambiamos el DOM y el estado ARIA A LA VEZ
  btn.setAttribute('aria-expanded', String(!abierto));
  panel.hidden = abierto; // si estaba abierto, ahora lo ocultamos

  // 3. Gestión de foco: al ABRIR, llevamos el foco al primer control del panel
  if (!abierto) {
    panel.querySelector('input, button, a')?.focus();
  }
});

¿Por qué panel.hidden y no display:none por CSS? El atributo hidden retira el contenido del árbol de accesibilidad Y del orden de tabulación a la vez. Marta no lo oye y Lucía no puede tabular a un checkbox invisible. Es la forma más robusta de ocultar.

Sobre la gestión de foco aquí conviene matizar: en un disclosure sencillo (menú de navegación, "leer más") no es obligatorio mover el foco al abrir; muchas implementaciones dejan el foco en el botón. Pero en un panel de filtro con formulario, mover el foco al primer control mejora mucho la experiencia de teclado. Al cerrar, si el foco estaba dentro del panel, hay que devolverlo al botón para no dejar el foco huérfano:

// Al cerrar por Escape o al re-pulsar, devolvemos el foco al disparador
function cerrarPanel() {
  btn.setAttribute('aria-expanded', 'false');
  panel.hidden = true;
  btn.focus(); // el foco vuelve a un lugar predecible
}

  1. Patrón Acordeón: temario y FAQ de Cursalia

El acordeón es una pila de disclosures: varios encabezados que expanden/colapsan su sección. Es el patrón del temario y del bloque de FAQ. La clave APG es que cada cabecera es un <button> envuelto en un encabezado (<h3>):

<h3>
  <button aria-expanded="false" aria-controls="sec-1" id="cab-1">
    Módulo 1: Introducción
  </button>
</h3>
<div id="sec-1" role="region" aria-labelledby="cab-1" hidden>
  <p>Contenido del módulo 1…</p>
</div>

El <h3> permite que Marta salte de sección en sección con la tecla de encabezados de NVDA. El role="region" + aria-labelledby da nombre a cada panel. La lógica JS es idéntica al disclosure, generalizada a varios ítems:

document.querySelectorAll('.acordeon button').forEach((cab) => {
  cab.addEventListener('click', () => {
    const abierto = cab.getAttribute('aria-expanded') === 'true';
    cab.setAttribute('aria-expanded', String(!abierto));
    document.getElementById(cab.getAttribute('aria-controls')).hidden = abierto;
    // En un acordeón NO movemos el foco al panel: el usuario sigue leyendo
    // en orden. El foco se queda en la cabecera (comportamiento APG).
  });
});

Decisión de diseño importante: ¿permitir varios paneles abiertos a la vez o solo uno? La APG permite ambos. Para un temario, deja varios abiertos (el usuario compara módulos); para un FAQ largo, cerrar los demás al abrir uno reduce el ruido. Si cierras los demás, acuérdate de actualizar su aria-expanded a false — es el error más típico.

  1. Patrón Tabs: pestañas del panel del alumno

Las pestañas del panel del alumno (por ejemplo: "Mis cursos", "Progreso", "Certificados") usan tres roles coordinados. Solo el panel activo está visible; los demás llevan hidden.

<div role="tablist" aria-label="Panel del alumno">
  <button role="tab" id="t1" aria-selected="true"  aria-controls="p1">Mis cursos</button>
  <button role="tab" id="t2" aria-selected="false" aria-controls="p2" tabindex="-1">Progreso</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>

Observa el tabindex="-1" en la pestaña no seleccionada: es el roving tabindex, la técnica que hace que Tab entre al grupo por una sola parada y las flechas muevan entre pestañas. Esa mecánica se explica en detalle en 05-02; aquí nos centramos en el estado ARIA dinámico:

const tabs = document.querySelectorAll('[role="tab"]');

function activar(tabSel) {
  tabs.forEach((tab) => {
    const seleccionada = tab === tabSel;
    // Estado ARIA + DOM sincronizados para CADA pestaña
    tab.setAttribute('aria-selected', String(seleccionada));
    tab.tabIndex = seleccionada ? 0 : -1; // roving tabindex
    document.getElementById(tab.getAttribute('aria-controls')).hidden = !seleccionada;
  });
  tabSel.focus(); // el foco acompaña a la pestaña activa
}

tabs.forEach((tab) => tab.addEventListener('click', () => activar(tab)));
// El manejo de flechas (ArrowLeft/Right, Home, End) se implementa según 05-02.

El patrón mental es el mismo de siempre: recorremos todos los elementos del grupo y, para cada uno, ponemos su aria-selected, su tabindex y la visibilidad de su panel coherentes entre sí.

  1. Patrón Diálogo Modal: la matrícula

El diálogo modal de matrícula es el patrón más exigente porque debe atrapar al usuario dentro mientras está abierto. Lo ideal hoy es el elemento nativo <dialog> con showModal(), que aporta mucho gratis (foco inicial, Esc para cerrar, fondo inerte). Cuando no puedas usarlo, replicas su comportamiento con ARIA:

<div role="dialog" aria-modal="true" aria-labelledby="titulo-modal" id="modal" hidden>
  <h2 id="titulo-modal">Matrícula en el curso</h2>
  <form>…</form>
  <button id="cerrar-modal">Cancelar</button>
</div>

role="dialog" + aria-modal="true" le dice al lector de pantalla que el resto de la página está inerte: Marta no debería poder salir del diálogo por error. aria-labelledby da nombre al diálogo con su título. El esqueleto JS de apertura y cierre:

let disparador = null; // recordamos QUIÉN abrió el modal

function abrirModal(botonQueLoAbrio) {
  disparador = botonQueLoAbrio;
  modal.hidden = false;
  // Foco inicial: al primer campo o al título del diálogo
  modal.querySelector('input, button, [tabindex]')?.focus();
  // Activar el FOCUS TRAP (técnica detallada en 05-02)
}

function cerrarModal() {
  modal.hidden = true;
  // Gestión de foco IMPRESCINDIBLE: devolver el foco a quien abrió
  disparador?.focus();
}

Dos cosas críticas que definen un modal accesible:

  1. Focus trap: mientras el modal está abierto, Tab y Shift+Tab deben ciclar dentro del diálogo, sin escaparse al contenido de fondo. La técnica completa (detectar el primer y el último elemento focusable y redirigir el foco) se implementa en 05-02.
  2. Devolver el foco al cerrar: guardamos el elemento que abrió el modal (disparador) y le devolvemos el foco al cerrar. Si no, Lucía cierra el modal y su foco cae al principio de la página: desorientación total.

  1. El ciclo "DOM + estado ARIA a la vez"

Todos los patrones anteriores comparten el mismo esqueleto. Interiorízalo como un ciclo único:

flowchart LR
  A[Interacción<br/>clic o tecla] --> B[Leer estado actual<br/>desde el ARIA]
  B --> C[Cambiar el DOM<br/>mostrar/ocultar/seleccionar]
  C --> D[Sincronizar ARIA<br/>expanded/selected/hidden]
  D --> E[Gestionar el foco<br/>mover o devolver]

La fase 4 (foco) y la fase 3 (ARIA) son las que casi todo el mundo olvida. Un componente que solo hace la fase "cambiar el DOM" es un componente roto para accesibilidad aunque se vea perfecto.

Errores Comunes y Consejos

  • Usar <div> con onclick en lugar de <button>. Un div no es focusable ni operable por teclado; tendrías que añadir tabindex, role="button" y manejar Enter/Espacio a mano. Usa siempre el elemento nativo: te da todo eso gratis.
  • Cambiar el DOM y olvidar el ARIA. El caso clásico: mostrar el panel pero dejar aria-expanded="false". Marta oye "colapsado" mientras ve (quien ve) el panel abierto.
  • No devolver el foco al cerrar un modal o panel. Deja el foco huérfano al principio del documento. Guarda siempre el disparador.
  • Ocultar con visibility:hidden mal aplicada o con opacity:0. opacity:0 deja el elemento focusable y en el árbol de accesibilidad: Lucía tabula a botones invisibles. Usa hidden o display:none.
  • Reinventar las teclas. No inventes que el acordeón se abre con la flecha derecha si la APG dice que va con Enter/Espacio. Sigue el patrón: la consistencia es accesibilidad.
  • Consejo: antes de programar un widget, busca su patrón en la APG y copia su tabla de roles y teclas. Es tu especificación.

Ejercicios

Ejercicio 1. El siguiente disclosure cambia el panel pero tiene un bug de accesibilidad. Identifícalo y corrígelo.

btn.addEventListener('click', () => {
  panel.hidden = !panel.hidden;
});

Ejercicio 2. Escribe la función activar(tab) de un grupo de pestañas que, además de mostrar el panel correcto, deje todos los estados ARIA coherentes. Enumera qué atributos debes tocar en cada pestaña.

Ejercicio 3. Un modal de matrícula se abre con foco correcto pero, al pulsar "Cancelar", el foco salta al inicio de la página. ¿Qué falta y cómo lo arreglas?

Soluciones

Solución 1. El bug es que nunca se actualiza aria-expanded. El panel se muestra/oculta visualmente, pero el lector de pantalla sigue anunciando el estado antiguo. Corrección sincronizando ambos:

btn.addEventListener('click', () => {
  const abierto = btn.getAttribute('aria-expanded') === 'true';
  btn.setAttribute('aria-expanded', String(!abierto));
  panel.hidden = abierto;
});

Solución 2. Por cada pestaña hay que sincronizar tres cosas: aria-selected (true solo en la activa), tabindex (0 en la activa, -1 en el resto: roving tabindex) y el hidden de su panel asociado. Además, mover el foco a la pestaña activada.

function activar(tabSel) {
  document.querySelectorAll('[role="tab"]').forEach((tab) => {
    const sel = tab === tabSel;
    tab.setAttribute('aria-selected', String(sel));
    tab.tabIndex = sel ? 0 : -1;
    document.getElementById(tab.getAttribute('aria-controls')).hidden = !sel;
  });
  tabSel.focus();
}

Solución 3. Falta guardar el disparador y devolverle el foco al cerrar. Se almacena el botón que abrió el modal en una variable y, en cerrarModal(), se llama a disparador.focus(). Sin eso, al ocultar el modal el foco cae al <body> y de ahí al inicio del documento.

Conclusión

Ya sabes construir los widgets clave de Cursalia siguiendo la ARIA Authoring Practices Guide: disclosure (el filtro que dejamos pendiente en el Módulo 4), acordeón, tabs y diálogo modal. La idea central es el ciclo "cambia el DOM y el estado ARIA a la vez, y gestiona el foco". Sin embargo, hemos ido dejando pendientes las mecánicas de teclado: qué teclas mueven entre pestañas, cómo funciona el roving tabindex, cómo se implementa exactamente el focus trap del modal y cómo evitar las trampas de teclado. Todo eso es la base sobre la que se apoya cualquier widget, y es justo lo que veremos en la siguiente lección, 05-02: Accesibilidad del Teclado, con Lucía y Marta como protagonistas.

© Copyright 2026. Todos los derechos reservados