Componente web accesible: valoración con estrellas
Crea un componente reutilizable <valoracion-estrellas> con Custom Elements y Shadow DOM: estrellas configurables, selección con ratón que avisa con un evento propio, manejo completo con teclado como un grupo de radios, roles ARIA y atributos y propiedades que se sincronizan con el componente.
- Custom Elements
- Shadow DOM
- connectedCallback y attributeChangedCallback
- CustomEvent (bubbles, composed)
- role radiogroup y aria-checked
- Tabulador itinerante (tabindex)
Enunciado
La plataforma de cursos de un centro quiere que los alumnos valoren cada curso con estrellas, y el mismo control aparece en varias páginas y en dos aplicaciones distintas. En lugar de copiar el HTML y el JavaScript en cada sitio, se crea un componente: una etiqueta propia, <valoracion-estrellas>, que cualquier página puede usar como si fuera un elemento más de HTML.
Los Web Components son el estándar del navegador para esto: customElements.define registra la etiqueta, una clase que extiende HTMLElement define su comportamiento y el Shadow DOM encapsula su HTML y su CSS para que la página no los rompa ni ellos rompan la página.
Un control de estrellas es, en realidad, un grupo de opciones excluyentes: para un lector de pantalla tiene que comportarse como un grupo de botones de radio, con su nombre, el estado de cada opción y el manejo con las flechas del teclado.
Qué hay que hacer
- Cada apartado reescribe el script completo del componente: empieza con la solución del anterior. La página tiene tres componentes:
#v1(máximo 5, valor 3, etiqueta «Valora el curso»),#v2(sin atributos) y#v3(máximo 10, valor 7). - El componente usa un Shadow DOM abierto (
mode: "open") con un contenedorrole="radiogroup"y unbuttonpor estrella, cuyo texto es★. - Cada apartado se corrige por separado con pruebas que se ejecutan dentro de la página (algunas a varios anchos de pantalla). Debajo del editor tienes el resultado en directo.
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. El esqueleto de un Custom Element
Una clase que extiende HTMLElement, un constructor que llama a super() y crea el Shadow DOM, y customElements.define("nombre-con-guion", Clase). El nombre tiene que llevar un guion para no chocar con las etiquetas de HTML.
class ValoracionEstrellas extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() { this.render(); }
}
customElements.define("valoracion-estrellas", ValoracionEstrellas);2. Atributos como configuración
Lo que se escribe en el HTML (max="5") son atributos de texto. Unos getters que los leen y los convierten en números (con valores por defecto y límites) dejan el resto del código limpio.
3. Redibujar desde el estado
render() vacía el Shadow DOM y lo dibuja entero a partir de los atributos. Así, para cualquier cambio basta con actualizar el atributo y llamar a render(): no hay que ir tocando botones sueltos.
4. Eventos que salen del componente
La página que usa el componente no ve sus botones: se entera de los cambios por un evento propio. new CustomEvent("cambio", { detail: { valor }, bubbles: true, composed: true }) cruza el límite del Shadow DOM y sube por el documento.
5. Teclado como un grupo de radios
En un grupo de radios, el tabulador entra en la opción elegida y las flechas se mueven entre opciones. Eso se consigue con tabindex 0 en una sola estrella y -1 en las demás (tabulador itinerante) y un manejador de keydown.
Resuélvelo aquí
Cada apartado edita un fichero de la página (el HTML, el CSS o el JavaScript) y se corrige con pruebas que se ejecutan en la propia página, también a distintos anchos de pantalla. Debajo del editor ves el resultado en directo.
Define la clase ValoracionEstrellas y regístrala como valoracion-estrellas. Al conectarse a la página dibuja en su Shadow DOM un div con role="radiogroup" y el aria-label del atributo etiqueta (o «Valoración» si no lo tiene), y dentro tantos button como diga el atributo max (5 si no lo tiene), cada uno con el texto ★ y el aria-label «1 estrella», «2 estrellas»…
Añade el valor: el atributo valor (0 si no lo tiene, nunca más que max) marca como llenas (clase llena) las estrellas hasta él. Cada botón tiene role="radio" y aria-checked ("true" solo en la estrella del valor). Al pulsar una estrella, el atributo valor cambia, el componente se redibuja y lanza el evento cambio con detail: { valor }, que debe llegar a la página (bubbles y composed).
Hazlo usable con el teclado como un grupo de radios: solo la estrella elegida (o la primera, si el valor es 0) tiene tabindex="0" y las demás -1, para que el tabulador entre y salga del grupo de una vez; con las flechas derecha y arriba sube una estrella y con izquierda y abajo baja una (entre 1 y max), igual que al pulsar (atributo, redibujo y evento), y el foco pasa a la estrella elegida.
Sincroniza el componente con el exterior: si otro código cambia los atributos valor, max o etiqueta, el componente se redibuja (observedAttributes y attributeChangedCallback). Y añade la propiedad valor: leerla devuelve el número y asignarla (el.valor = 4) cambia el atributo.
Solución explicada
Ver las soluciones de todos los apartados
Apartado 1 · JavaScript
class ValoracionEstrellas extends HTMLElement {
constructor() {
super();
// Shadow DOM abierto: el componente tiene su propio HTML y CSS, aislados de la página
this.attachShadow({ mode: "open" });
}
get max() {
return Math.max(1, parseInt(this.getAttribute("max"), 10) || 5);
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `<style>
button { font-size: 1.5rem; background: none; border: 0; padding: 2px; cursor: pointer; color: #767676; }
.llena { color: #b35c00; }
</style>
<div role="radiogroup"></div>`;
const grupo = this.shadowRoot.querySelector("[role=radiogroup]");
grupo.setAttribute("aria-label", this.getAttribute("etiqueta") || "Valoración");
for (let i = 1; i <= this.max; i++) {
const b = document.createElement("button");
b.type = "button";
b.textContent = "★";
b.setAttribute("aria-label", i === 1 ? "1 estrella" : `${i} estrellas`);
grupo.append(b);
}
}
}
customElements.define("valoracion-estrellas", ValoracionEstrellas);Apartado 2 · JavaScript
class ValoracionEstrellas extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
get max() {
return Math.max(1, parseInt(this.getAttribute("max"), 10) || 5);
}
get valor() {
return Math.min(this.max, Math.max(0, parseInt(this.getAttribute("valor"), 10) || 0));
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `<style>
button { font-size: 1.5rem; background: none; border: 0; padding: 2px; cursor: pointer; color: #767676; }
.llena { color: #b35c00; }
</style>
<div role="radiogroup"></div>`;
const grupo = this.shadowRoot.querySelector("[role=radiogroup]");
grupo.setAttribute("aria-label", this.getAttribute("etiqueta") || "Valoración");
for (let i = 1; i <= this.max; i++) {
const b = document.createElement("button");
b.type = "button";
b.textContent = "★";
b.setAttribute("role", "radio");
b.setAttribute("aria-label", i === 1 ? "1 estrella" : `${i} estrellas`);
b.setAttribute("aria-checked", String(i === this.valor));
if (i <= this.valor) b.classList.add("llena");
b.addEventListener("click", () => this.elegir(i));
grupo.append(b);
}
}
elegir(n) {
this.setAttribute("valor", String(n));
this.render();
// composed: el evento sale del shadow DOM y llega a la página
this.dispatchEvent(new CustomEvent("cambio", { detail: { valor: n }, bubbles: true, composed: true }));
}
}
customElements.define("valoracion-estrellas", ValoracionEstrellas);Apartado 3 · JavaScript
class ValoracionEstrellas extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
get max() {
return Math.max(1, parseInt(this.getAttribute("max"), 10) || 5);
}
get valor() {
return Math.min(this.max, Math.max(0, parseInt(this.getAttribute("valor"), 10) || 0));
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `<style>
button { font-size: 1.5rem; background: none; border: 0; padding: 2px; cursor: pointer; color: #767676; }
.llena { color: #b35c00; }
</style>
<div role="radiogroup"></div>`;
const grupo = this.shadowRoot.querySelector("[role=radiogroup]");
grupo.setAttribute("aria-label", this.getAttribute("etiqueta") || "Valoración");
for (let i = 1; i <= this.max; i++) {
const b = document.createElement("button");
b.type = "button";
b.textContent = "★";
b.setAttribute("role", "radio");
b.setAttribute("aria-label", i === 1 ? "1 estrella" : `${i} estrellas`);
b.setAttribute("aria-checked", String(i === this.valor));
// Tabulador itinerante: solo la estrella elegida (o la primera) entra en el orden del tabulador
b.tabIndex = i === (this.valor || 1) ? 0 : -1;
if (i <= this.valor) b.classList.add("llena");
b.addEventListener("click", () => this.elegir(i));
b.addEventListener("keydown", (e) => this.teclado(e));
grupo.append(b);
}
}
elegir(n) {
this.setAttribute("valor", String(n));
this.render();
this.dispatchEvent(new CustomEvent("cambio", { detail: { valor: n }, bubbles: true, composed: true }));
}
teclado(e) {
const paso = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key];
if (!paso) return;
e.preventDefault();
const n = Math.min(this.max, Math.max(1, this.valor + paso));
this.elegir(n);
this.shadowRoot.querySelectorAll("button")[n - 1].focus();
}
}
customElements.define("valoracion-estrellas", ValoracionEstrellas);Apartado 4 · JavaScript
class ValoracionEstrellas extends HTMLElement {
// Los cambios de estos atributos desde fuera llaman a attributeChangedCallback
static observedAttributes = ["valor", "max", "etiqueta"];
constructor() {
super();
this.attachShadow({ mode: "open" });
}
get max() {
return Math.max(1, parseInt(this.getAttribute("max"), 10) || 5);
}
get valor() {
return Math.min(this.max, Math.max(0, parseInt(this.getAttribute("valor"), 10) || 0));
}
// La propiedad refleja el atributo: el.valor = 4 cambia valor="4" y el componente se redibuja
set valor(n) {
this.setAttribute("valor", String(n));
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
if (this.isConnected) this.render();
}
render() {
this.shadowRoot.innerHTML = `<style>
button { font-size: 1.5rem; background: none; border: 0; padding: 2px; cursor: pointer; color: #767676; }
.llena { color: #b35c00; }
</style>
<div role="radiogroup"></div>`;
const grupo = this.shadowRoot.querySelector("[role=radiogroup]");
grupo.setAttribute("aria-label", this.getAttribute("etiqueta") || "Valoración");
for (let i = 1; i <= this.max; i++) {
const b = document.createElement("button");
b.type = "button";
b.textContent = "★";
b.setAttribute("role", "radio");
b.setAttribute("aria-label", i === 1 ? "1 estrella" : `${i} estrellas`);
b.setAttribute("aria-checked", String(i === this.valor));
b.tabIndex = i === (this.valor || 1) ? 0 : -1;
if (i <= this.valor) b.classList.add("llena");
b.addEventListener("click", () => this.elegir(i));
b.addEventListener("keydown", (e) => this.teclado(e));
grupo.append(b);
}
}
elegir(n) {
this.valor = n;
this.dispatchEvent(new CustomEvent("cambio", { detail: { valor: n }, bubbles: true, composed: true }));
}
teclado(e) {
const paso = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key];
if (!paso) return;
e.preventDefault();
const n = Math.min(this.max, Math.max(1, this.valor + paso));
this.elegir(n);
this.shadowRoot.querySelectorAll("button")[n - 1].focus();
}
}
customElements.define("valoracion-estrellas", ValoracionEstrellas);El componente encapsula estructura, estilo y comportamiento: la página solo escribe <valoracion-estrellas max="5"> y escucha cambio. El Shadow DOM impide que el CSS de la página cambie las estrellas y que el del componente afecte a la página.
Toda la vista sale de render() a partir de los atributos, que son la única fuente de verdad. Con observedAttributes y attributeChangedCallback, cualquier cambio del atributo (desde el propio componente, desde otro script o desde un framework) redibuja el control.
Los roles radiogroup y radio con aria-checked, el tabulador itinerante y las flechas hacen que el componente se comporte exactamente como un grupo de radios nativo para el teclado y para los lectores de pantalla. Sin eso, serían diez botones sin relación entre ellos.
La propiedad valor refleja el atributo: es la convención de los elementos nativos (input.value), y permite usar el componente igual de cómodo desde JavaScript que desde HTML.
Para ir más allá
- Haz que el componente funcione dentro de un formulario con
static formAssociated = trueyattachInternals(), para que su valor se envíe con el resto de campos. - Añade media estrella: valores como 3.5 y su representación visual.
- Publica el componente como módulo y úsalo en una página de React o Vue para comprobar que funciona en cualquier framework.