En el capítulo de patrones construiste un sistema de pestañas con role="tab" y aria-selected. Pruébalo con el teclado: llega a las pestañas con Tab y luego pulsa las flechas para cambiar de una a otra. No pasa nada. Las flechas no hacen nada y, encima, Tab te obliga a pasar por cada pestaña una a una. Pusimos los atributos de accesibilidad, pero no el comportamiento que prometen: y un role="tab" que no se navega como un tab es, para quien usa el teclado, una etiqueta que miente.
Este capítulo va de cerrar esa brecha. No es accesibilidad exhaustiva —eso da para un curso entero—, sino el mínimo deliberado que se da por supuesto en una empresa en 2026: atar las cosas que van juntas, llevar el foco a donde toca y hacer que el teclado funcione. Tres herramientas: useId, la gestión del foco y la navegación por teclado.
Por qué esto es parte del trabajo, no un extra#
Una parte de tus usuarios no usa el ratón: navega con teclado, con lector de pantalla, con un conmutador. No es un caso raro y lejano —es gente con una mano ocupada, con el trackpad roto, o que simplemente va más rápido con el teclado—. Y en sectores como la banca o lo público, la accesibilidad es un requisito legal, no una cortesía.
React no te ayuda aquí: es permisivo. Te deja pintar un <div> que hace de botón sin teclado, un input sin etiqueta o un tablist sin flechas, y con el ratón “funciona”. El fallo es silencioso: nadie ve un error en consola, la demo se ve bien, y el problema solo existe para quien no usa el ratón —que es justo quien no estaba en la sala cuando lo probaste—. Por eso hay que ponerlo a propósito.
useId: atar una etiqueta (y un error) a su campo#
Un <label> solo “pertenece” a un input si lo dices explícitamente: el htmlFor del label tiene que coincidir con el id del input. Así, al pulsar la etiqueta el foco salta al campo, y el lector de pantalla lee “Nombre” cuando llegas al input en vez de leer un campo anónimo.
¿Y de dónde sacas ese id? La tentación es escribirlo a mano: id="nombre". Funciona en una página simple. Pero un id debe ser único en todo el documento, y en cuanto ese componente aparece dos veces —dos formularios, o una tarjeta por héroe con un campo cada una— tienes dos id="nombre". El htmlFor apunta al primero, y la asociación de las demás copias se rompe en silencio. Para esto está useId:
import { useId } from "react";
function CampoNombre() {
// useId genera un id único y estable para esta instancia del componente.
// Cada copia en la página recibe uno distinto: nunca chocan.
const id = useId();
return (
<div className="campo">
{/* htmlFor = id del input: ahora el navegador sabe que van juntos. */}
<label htmlFor={id}>Nombre</label>
<input id={id} type="text" />
</div>
);
}El mismo id (con un sufijo) sirve para atar el mensaje de error al campo. Un error que solo es texto rojo debajo del input no existe para un lector de pantalla: la proximidad visual no se “oye”. Hay que conectarlos:
// id viene de useId; derivamos el del error a partir de él.
const id = useId();
// hayError es true cuando este campo ha fallado la validación.
return (
<div className="campo">
<label htmlFor={id}>Nombre</label>
<input
id={id}
type="text"
// aria-invalid marca el campo como inválido para la tecnología asistiva.
aria-invalid={hayError ? true : false}
// aria-describedby ata el input a su mensaje de error por id.
aria-describedby={hayError ? id + "-error" : undefined}
/>
{hayError && (
// role="alert" hace que el lector anuncie el error en cuanto aparece.
<span id={id + "-error"} className="error-msg" role="alert">
El nombre es obligatorio
</span>
)}
</div>
);Nota de SSR:
useIdno es capricho. Como genera el MISMO id en el servidor y en el cliente, no rompe el renderizado en servidor (donde un id “aleatorio” daría un valor distinto en cada lado y React se quejaría). Por eso no se usaMath.random()para esto.
El foco: dónde está y a dónde lo mandas#
El foco es dónde está “puesto” el teclado ahora mismo: el elemento que recibe lo que escribes y sobre el que actúa Enter. Para quien no usa el ratón, mover el foco es la forma de llevarle la atención a un sitio.
El caso clásico: validas un formulario al enviar y aparecen errores arriba, pero el foco se queda en el botón de enviar, abajo. Quien ve la pantalla nota el rojo; quien navega con teclado o lee con voz, no se entera de que algo ha fallado. La solución es llevar el foco al primer campo inválido, con una ref:
import { useRef } from "react";
// Una ref al input para poder darle el foco por código.
const refNombre = useRef<HTMLInputElement>(null);
function alEnviar() {
if (nombre.trim() === "") {
setError("El nombre es obligatorio");
// Llevamos el foco al campo que falla: quien no ve el error rojo, lo nota aquí.
if (refNombre.current) refNombre.current.focus();
return;
}
// ...el envío normal si no hay errores.
}
// En el JSX, conectamos la ref al input:
// <input ref={refNombre} ... />El “¿y qué?” si no lo haces: el formulario parece roto para un usuario de teclado. Pulsa enviar, no pasa nada visible para él, no hay pista de qué corregir, y abandona. No es un detalle estético: es la diferencia entre poder usar el formulario o no.
Navegación por teclado: que un tablist se comporte como un tablist#
Aquí cerramos la brecha del principio. Un grupo de pestañas con role="tab" promete un comportamiento concreto, definido por el estándar WAI-ARIA: Tab entra al grupo una sola vez (cae en la pestaña activa) y, ya dentro, las flechas mueven entre pestañas, con Home/End para ir a los extremos. Esa es la diferencia con un montón de botones sueltos, donde Tab te hace pasar por todos.
La técnica se llama roving tabindex (“tabindex itinerante”): en cada momento, solo la pestaña activa entra en el orden de tabulación (tabIndex={0}); las demás quedan fuera (tabIndex={-1}) y se alcanzan con las flechas, no con Tab. Un onKeyDown calcula a qué pestaña ir y le mueve el foco:
// Refs a todos los botones, para poder enfocar el de destino.
const refs = useRef<Array<HTMLButtonElement | null>>([]);
function alPulsarTecla(e: React.KeyboardEvent<HTMLButtonElement>, i: number) {
let destino = i;
// Las flechas mueven entre pestañas (en bucle con el módulo %).
if (e.key === "ArrowRight") destino = (i + 1) % TABS.length;
else if (e.key === "ArrowLeft") destino = (i - 1 + TABS.length) % TABS.length;
// Home y End van a la primera y a la última.
else if (e.key === "Home") destino = 0;
else if (e.key === "End") destino = TABS.length - 1;
// Otra tecla cualquiera (incluido Tab) sigue su curso normal.
else return;
// Evita que la flecha haga scroll de la página.
e.preventDefault();
setActiva(destino);
// Mueve el foco real al botón de destino.
const btn = refs.current[destino];
if (btn) btn.focus();
}
// Y cada botón:
// <button
// role="tab"
// aria-selected={i === activa}
// // La activa es tabbable (0); las demás salen del Tab (-1).
// tabIndex={i === activa ? 0 : -1}
// ref={(el) => { refs.current[i] = el; }}
// onKeyDown={(e) => alPulsarTecla(e, i)}
// >El “¿y qué?” otra vez: sin esto, el role="tab" que ya tenías engaña. Un lector de pantalla anuncia “pestaña, 1 de 4”, el usuario pulsa la flecha derecha esperando ir a la siguiente… y no se mueve nada. Le has prometido un comportamiento con el atributo y no se lo has dado. Poner el role sin la navegación es peor que no ponerlo: crea una expectativa que rompes.
Pruébalo#
Haz clic en una pestaña; luego pulsa Tab para entrar al grupo y muévete con las flechas (y prueba Home/End). El foco salta de pestaña en pestaña, la selección cambia con él y el filtro activo se actualiza. Eso es un tablist que cumple lo que su role promete.
Comprueba lo que sabes#
Pregunta 1 de 3
¿Por qué usar useId en lugar de escribir id="nombre" a mano para atar un label a su input?
Tu turno#
Ejercicio · en esta página
El Team Builder, accesible
Partes de un Team Builder que funciona con el ratón pero falla con el teclado y el lector de pantalla: las etiquetas no están atadas a sus campos, los errores no se anuncian y el filtro no se navega con flechas. Hazlo accesible, capa a capa.
Paso 1: Etiquetas atadas con useId
- Cada label se ata a su input con useId (htmlFor en el label, el mismo id en el input)
- Pulsar la etiqueta enfoca su campo
- El filtro por rol sigue funcionando al hacer clic
Paso 2: Errores anunciados y foco gestionado
- Cada input con error lleva aria-invalid y aria-describedby apuntando a su mensaje (con role="alert")
- Al enviar con errores, el foco va al primer campo inválido
- Se mantiene todo lo del tier anterior (useId en las etiquetas)
Paso 3: Filtro navegable por teclado
- El tablist usa roving tabindex: solo la pestaña activa es tabbable
- Las flechas izquierda/derecha mueven entre pestañas y Home/End van a los extremos, moviendo el foco
- Aguanta a 375px sin romperse, y se conserva todo lo anterior
Ver soluciones
import { useId, useState } from "react";
interface Heroe {
id: number;
nombre: string;
rol: "Daño" | "Apoyo" | "Tanque";
partidas: number;
}
const ROSTER_INICIAL: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95 },
];
const FILTROS = ["Todos", "Daño", "Apoyo", "Tanque"] as const;
type Filtro = (typeof FILTROS)[number];
let proximoId = 4;
export default function App() {
const [roster, setRoster] = useState<Heroe[]>(ROSTER_INICIAL);
const [filtro, setFiltro] = useState<Filtro>("Todos");
// useId genera un id único y estable por campo, sin hardcodear id="nombre".
// Si el formulario se montara dos veces, cada copia tendría ids distintos y la
// asociación label↔input no se rompería (dos id="nombre" sí la romperían).
const idNombre = useId();
const idPartidas = useId();
const [nombre, setNombre] = useState("");
const [partidas, setPartidas] = useState("");
const [errores, setErrores] = useState<{ nombre?: string; partidas?: string }>({});
const visibles = filtro === "Todos" ? roster : roster.filter((h) => h.rol === filtro);
function alEnviar() {
const nuevos: { nombre?: string; partidas?: string } = {};
if (nombre.trim().length < 2) nuevos.nombre = "El nombre debe tener al menos 2 caracteres";
const num = Number(partidas);
if (!Number.isInteger(num) || num < 1) nuevos.partidas = "Escribe un número de al menos 1";
setErrores(nuevos);
if (nuevos.nombre || nuevos.partidas) return;
setRoster((prev) => [...prev, { id: proximoId++, nombre: nombre.trim(), rol: "Daño", partidas: num }]);
setNombre("");
setPartidas("");
}
return (
<section>
<h1 className="titulo">Team Builder — Roster</h1>
{/* role="tablist" + role="tab" + aria-selected: el filtro tiene la semántica correcta. */}
<div className="barra-filtro" role="tablist" aria-label="Filtrar por rol">
{FILTROS.map((f) => (
<button
key={f}
type="button"
className="filtro-tab"
role="tab"
aria-selected={f === filtro}
onClick={() => setFiltro(f)}
>
{f}
</button>
))}
</div>
{/* preventDefault corta la recarga de página del submit; luego validamos a mano. */}
<form className="formulario" onSubmit={(e) => { e.preventDefault(); alEnviar(); }} noValidate>
<div className="campo">
{/* htmlFor del label = id del input: al pulsar el label, el foco va al input. */}
<label htmlFor={idNombre}>Nombre</label>
<input
id={idNombre}
type="text"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
/>
{errores.nombre && <span className="error-msg">{errores.nombre}</span>}
</div>
<div className="campo">
<label htmlFor={idPartidas}>Partidas jugadas</label>
<input
id={idPartidas}
type="number"
value={partidas}
onChange={(e) => setPartidas(e.target.value)}
/>
{errores.partidas && <span className="error-msg">{errores.partidas}</span>}
</div>
<button type="submit" className="boton">
Añadir héroe
</button>
</form>
<h2 className="subtitulo">{visibles.length} héroe(s)</h2>
<div className="lista">
{visibles.length === 0 ? (
<p className="vacio">No hay héroes de ese rol.</p>
) : (
visibles.map((h) => (
<article key={h.id} className="tarjeta">
<h3 className="tarjeta__nombre">{h.nombre}</h3>
<p className="tarjeta__rol">{h.rol} · {h.partidas} partidas</p>
</article>
))
)}
</div>
</section>
);
} Por qué este nivel
- useId resuelve el problema de los ids: hardcodear id="nombre" funciona... hasta que ese componente se monta dos veces en la página (dos formularios, o una tarjeta con un campo por héroe). Entonces hay dos id="nombre", el htmlFor del label apunta al primero y la asociación se rompe sin avisar. useId da un id único por instancia.
- Con esto ya tienes lo mínimo: cada label apunta a su input, así que pulsar la etiqueta enfoca el campo y un lector de pantalla lee 'Nombre, campo de texto' en vez de un input anónimo.
import { useId, useRef, useState } from "react";
interface Heroe {
id: number;
nombre: string;
rol: "Daño" | "Apoyo" | "Tanque";
partidas: number;
}
const ROSTER_INICIAL: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95 },
];
const FILTROS = ["Todos", "Daño", "Apoyo", "Tanque"] as const;
type Filtro = (typeof FILTROS)[number];
let proximoId = 4;
export default function App() {
const [roster, setRoster] = useState<Heroe[]>(ROSTER_INICIAL);
const [filtro, setFiltro] = useState<Filtro>("Todos");
// useId da ids únicos y estables para atar cada label a su input (y cada error a su campo).
const idNombre = useId();
const idPartidas = useId();
const [nombre, setNombre] = useState("");
const [partidas, setPartidas] = useState("");
const [errores, setErrores] = useState<{ nombre?: string; partidas?: string }>({});
// Refs a los inputs para llevar el foco al primer campo con error al enviar.
const refNombre = useRef<HTMLInputElement>(null);
const refPartidas = useRef<HTMLInputElement>(null);
const visibles = filtro === "Todos" ? roster : roster.filter((h) => h.rol === filtro);
function alEnviar() {
const nuevos: { nombre?: string; partidas?: string } = {};
if (nombre.trim().length < 2) nuevos.nombre = "El nombre debe tener al menos 2 caracteres";
const num = Number(partidas);
if (!Number.isInteger(num) || num < 1) nuevos.partidas = "Escribe un número de al menos 1";
setErrores(nuevos);
// Si hay errores, el foco va al primer campo inválido: así quien usa teclado o lector
// de pantalla sabe que algo falló y dónde, sin tener que buscarlo a ciegas.
if (nuevos.nombre) {
if (refNombre.current) refNombre.current.focus();
return;
}
if (nuevos.partidas) {
if (refPartidas.current) refPartidas.current.focus();
return;
}
setRoster((prev) => [...prev, { id: proximoId++, nombre: nombre.trim(), rol: "Daño", partidas: num }]);
setNombre("");
setPartidas("");
}
return (
<section>
<h1 className="titulo">Team Builder — Roster</h1>
{/* role="tablist" + role="tab" + aria-selected: el filtro es semánticamente correcto. */}
<div className="barra-filtro" role="tablist" aria-label="Filtrar por rol">
{FILTROS.map((f) => (
<button
key={f}
type="button"
className="filtro-tab"
role="tab"
aria-selected={f === filtro}
onClick={() => setFiltro(f)}
>
{f}
</button>
))}
</div>
{/* preventDefault corta la recarga de página del submit; luego validamos a mano. */}
<form className="formulario" onSubmit={(e) => { e.preventDefault(); alEnviar(); }} noValidate>
<div className="campo">
{/* htmlFor del label = id del input: al pulsar el label, el foco va al input. */}
<label htmlFor={idNombre}>Nombre</label>
<input
id={idNombre}
ref={refNombre}
type="text"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
// aria-invalid anuncia el error a la tecnología asistiva.
aria-invalid={errores.nombre ? true : false}
// aria-describedby ata el input a su mensaje de error.
aria-describedby={errores.nombre ? `${idNombre}-error` : undefined}
/>
{errores.nombre && (
// role="alert" hace que el lector de pantalla anuncie el error al aparecer.
<span id={`${idNombre}-error`} className="error-msg" role="alert">
{errores.nombre}
</span>
)}
</div>
<div className="campo">
<label htmlFor={idPartidas}>Partidas jugadas</label>
<input
id={idPartidas}
ref={refPartidas}
type="number"
value={partidas}
onChange={(e) => setPartidas(e.target.value)}
aria-invalid={errores.partidas ? true : false}
aria-describedby={errores.partidas ? `${idPartidas}-error` : undefined}
/>
{errores.partidas && (
<span id={`${idPartidas}-error`} className="error-msg" role="alert">
{errores.partidas}
</span>
)}
</div>
<button type="submit" className="boton">
Añadir héroe
</button>
</form>
<h2 className="subtitulo">{visibles.length} héroe(s)</h2>
<div className="lista">
{visibles.length === 0 ? (
<p className="vacio">No hay héroes de ese rol.</p>
) : (
visibles.map((h) => (
<article key={h.id} className="tarjeta">
<h3 className="tarjeta__nombre">{h.nombre}</h3>
<p className="tarjeta__rol">{h.rol} · {h.partidas} partidas</p>
</article>
))
)}
</div>
</section>
);
} Por qué es mejor que el anterior
- Lo que separa esto del 'ok' es que el error deja de ser solo visual. aria-invalid marca el campo como inválido para la tecnología asistiva; aria-describedby ata el input a su mensaje (el lector lo lee al enfocar el campo); role="alert" lo anuncia en cuanto aparece.
- Y el foco: al enviar con errores, lo llevamos al primer campo inválido. Quien navega con teclado o lee con voz no 've' el error rojo aparecer abajo; sin mover el foco, no se entera de que algo ha fallado.
import { useId, useRef, useState } from "react";
interface Heroe {
id: number;
nombre: string;
rol: "Daño" | "Apoyo" | "Tanque";
partidas: number;
}
const ROSTER_INICIAL: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95 },
];
// Los valores del filtro: "Todos" más los tres roles.
const FILTROS = ["Todos", "Daño", "Apoyo", "Tanque"] as const;
type Filtro = (typeof FILTROS)[number];
let proximoId = 4;
export default function App() {
const [roster, setRoster] = useState<Heroe[]>(ROSTER_INICIAL);
const [filtro, setFiltro] = useState<Filtro>("Todos");
// useId genera ids únicos y estables para atar cada label a su input.
// Si este formulario se montara dos veces en la página, cada copia tendría ids distintos:
// por eso NO se hardcodea id="nombre" (dos "nombre" romperían la asociación label↔input).
const idNombre = useId();
const idPartidas = useId();
const [nombre, setNombre] = useState("");
const [partidas, setPartidas] = useState("");
const [errores, setErrores] = useState<{ nombre?: string; partidas?: string }>({});
// Refs a los inputs para poder MOVER el foco al primer campo con error al enviar.
const refNombre = useRef<HTMLInputElement>(null);
const refPartidas = useRef<HTMLInputElement>(null);
// Refs a los botones del filtro: el teclado necesita poder enfocar el botón de destino.
const refsTabs = useRef<Array<HTMLButtonElement | null>>([]);
const visibles = filtro === "Todos" ? roster : roster.filter((h) => h.rol === filtro);
function alEnviar() {
// Validación mínima; lo que enseña el capítulo es cómo se COMUNICA el error, no la regla.
const nuevos: { nombre?: string; partidas?: string } = {};
if (nombre.trim().length < 2) nuevos.nombre = "El nombre debe tener al menos 2 caracteres";
const num = Number(partidas);
if (!Number.isInteger(num) || num < 1) nuevos.partidas = "Escribe un número de al menos 1";
setErrores(nuevos);
// Gestión del foco: si hay errores, lleva el foco al PRIMER campo inválido.
// Sin esto, quien usa teclado o lector de pantalla no sabe que ha aparecido un error.
if (nuevos.nombre) {
if (refNombre.current) refNombre.current.focus();
return;
}
if (nuevos.partidas) {
if (refPartidas.current) refPartidas.current.focus();
return;
}
setRoster((prev) => [...prev, { id: proximoId++, nombre: nombre.trim(), rol: "Daño", partidas: num }]);
setNombre("");
setPartidas("");
}
// Navegación por teclado del tablist: las flechas mueven entre pestañas, Home/End van a los extremos.
function alPulsarTecla(e: React.KeyboardEvent<HTMLButtonElement>, indice: number) {
let destino = indice;
if (e.key === "ArrowRight") destino = (indice + 1) % FILTROS.length;
else if (e.key === "ArrowLeft") destino = (indice - 1 + FILTROS.length) % FILTROS.length;
else if (e.key === "Home") destino = 0;
else if (e.key === "End") destino = FILTROS.length - 1;
// Cualquier otra tecla (Tab incluido) sigue su curso normal.
else return;
// Evita que la flecha haga scroll de la página.
e.preventDefault();
setFiltro(FILTROS[destino]);
// Mueve el foco al botón de destino para que la navegación se sienta nativa.
const btn = refsTabs.current[destino];
if (btn) btn.focus();
}
return (
<section>
<h1 className="titulo">Team Builder — Roster</h1>
{/* role="tablist" agrupa las pestañas; aria-label le da un nombre al grupo. */}
<div className="barra-filtro" role="tablist" aria-label="Filtrar por rol">
{FILTROS.map((f, i) => {
const activa = f === filtro;
return (
<button
key={f}
type="button"
className="filtro-tab"
role="tab"
// aria-selected marca la pestaña activa (y el CSS la estila por este atributo).
aria-selected={activa}
// Roving tabindex: solo la pestaña activa entra en el orden de tabulación (0);
// las demás se quedan fuera (-1) y se alcanzan con las flechas, no con Tab.
tabIndex={activa ? 0 : -1}
// Guarda la ref de cada botón en su posición del array.
ref={(el) => {
refsTabs.current[i] = el;
}}
onClick={() => setFiltro(f)}
onKeyDown={(e) => alPulsarTecla(e, i)}
>
{f}
</button>
);
})}
</div>
{/* preventDefault corta la recarga de página del submit; luego validamos a mano. */}
<form className="formulario" onSubmit={(e) => { e.preventDefault(); alEnviar(); }} noValidate>
<div className="campo">
{/* htmlFor del label = id del input: al pulsar el label, el foco va al input. */}
<label htmlFor={idNombre}>Nombre</label>
<input
id={idNombre}
ref={refNombre}
type="text"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
// aria-invalid anuncia a la tecnología asistiva que el campo tiene un error.
aria-invalid={errores.nombre ? true : false}
// aria-describedby ata el input a su mensaje de error (lo lee el lector de pantalla).
aria-describedby={errores.nombre ? `${idNombre}-error` : undefined}
/>
{errores.nombre && (
// role="alert" hace que el lector de pantalla anuncie el error en cuanto aparece.
<span id={`${idNombre}-error`} className="error-msg" role="alert">
{errores.nombre}
</span>
)}
</div>
<div className="campo">
<label htmlFor={idPartidas}>Partidas jugadas</label>
<input
id={idPartidas}
ref={refPartidas}
type="number"
value={partidas}
onChange={(e) => setPartidas(e.target.value)}
aria-invalid={errores.partidas ? true : false}
aria-describedby={errores.partidas ? `${idPartidas}-error` : undefined}
/>
{errores.partidas && (
<span id={`${idPartidas}-error`} className="error-msg" role="alert">
{errores.partidas}
</span>
)}
</div>
<button type="submit" className="boton">
Añadir héroe
</button>
</form>
<h2 className="subtitulo">{visibles.length} héroe(s)</h2>
<div className="lista">
{visibles.length === 0 ? (
<p className="vacio">No hay héroes de ese rol.</p>
) : (
visibles.map((h) => (
<article key={h.id} className="tarjeta">
<h3 className="tarjeta__nombre">{h.nombre}</h3>
<p className="tarjeta__rol">{h.rol} · {h.partidas} partidas</p>
</article>
))
)}
</div>
</section>
);
} Por qué es mejor que el anterior
- El filtro ya era un tablist semántico, pero la semántica sin comportamiento es media verdad: un tablist de verdad es UN solo punto de tabulación, y dentro te mueves con las flechas. El roving tabindex lo consigue: solo la activa tiene tabIndex 0; las demás, -1 (fuera del Tab). Las flechas mueven foco y selección; Home/End van a los extremos.
- Así un usuario de teclado no tiene que tabular cuatro veces para cruzar el filtro, igual que en los controles nativos. Y aguanta 375px: la accesibilidad también es el móvil.