Apuntes DAM
Volver al inicio

Componente web accesible: valoración con estrellas

Ejercicio de JavaScriptMuy difícilUnos 100 minutos

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

  1. 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).
  2. El componente usa un Shadow DOM abierto (mode: "open") con un contenedor role="radiogroup" y un button por estrella, cuyo texto es ★.
  3. 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.

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

JS · DOMApartado 1 · JavaScriptMuy difícil

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

index.htmlsolo lectura
script.js
Vista previa
JS · DOMApartado 2 · JavaScriptMuy difícil

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

index.htmlsolo lectura
script.js
Vista previa
JS · DOMApartado 3 · JavaScriptMuy difícil

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.

index.htmlsolo lectura
script.js
Vista previa
JS · DOMApartado 4 · JavaScriptMuy difícil

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.

index.htmlsolo lectura
script.js
Vista previa

Solución explicada

Ver las soluciones de todos los apartados

Apartado 1 · JavaScript

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

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

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

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 = true y attachInternals(), 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.

Dónde se explica