Plantilla, ranura y sombra

El beneficio de los componentes web es su reutilización: puedes crear un widget de la IU una vez y volver a usarlo varias veces. Si bien necesitas JavaScript para crear componentes web, no necesitas una biblioteca de JavaScript. HTML y las APIs asociadas te proporcionan todo lo que necesitas.

El estándar de los componentes web consta de tres partes: plantillas HTML, elementos personalizados y Shadow DOM. En conjunto, permiten crear elementos personalizados, autónomos (encapsulados) reutilizables que se pueden integrar sin problemas en aplicaciones existentes, como todos los demás elementos HTML que ya vimos.

En esta sección, crearemos el elemento <star-rating>, un componente web que permite a los usuarios calificar una experiencia en una escala de una a cinco estrellas. Cuando asignes un nombre a un elemento personalizado, se recomienda utilizar solo letras minúsculas. Además, incluye un guion, ya que esto ayuda a distinguir entre los elementos normales y los personalizados.

Analizaremos el uso de los elementos <template> y <slot>, el atributo slot y JavaScript para crear una plantilla con un Shadow DOM encapsulado. Luego, reutilizaremos el elemento definido y personalizaremos una sección de texto, al igual que lo harías con cualquier elemento o componente web. También analizaremos brevemente el uso de CSS desde y hacia el elemento personalizado.

El elemento <template>

El elemento <template> se usa para declarar fragmentos de HTML que se clonarán y, luego, insertarán en el DOM con JavaScript. El contenido del elemento no se renderiza de forma predeterminada. sino que se crean instancias de ellas con JavaScript.

<template id="star-rating-template">
  <form>
    <fieldset>
      <legend>Rate your experience:</legend>
      <rating>
        <input type="radio" name="rating" value="1" aria-label="1 star" required />
        <input type="radio" name="rating" value="2" aria-label="2 stars" />
        <input type="radio" name="rating" value="3" aria-label="3 stars" />
        <input type="radio" name="rating" value="4" aria-label="4 stars" />
        <input type="radio" name="rating" value="5" aria-label="5 stars" />
      </rating>
    </fieldset>
    <button type="reset">Reset</button>
    <button type="submit">Submit</button>
  </form>
</template>

Como el contenido de un elemento <template> no se escribe en la pantalla, no se renderiza el <form> ni su contenido. Sí, este CodePen está en blanco, pero si inspeccionas la pestaña HTML, verás el lenguaje de marcado <template>.

En este ejemplo, <form> no es un elemento secundario de un <template> en el DOM. En cambio, el contenido de los elementos <template> son elementos secundarios de un DocumentFragment que muestra la propiedad HTMLTemplateElement.content. Para que sea visible, se debe usar JavaScript para tomar el contenido y agregarlo al DOM.

Este breve JavaScript no creó un elemento personalizado. En este ejemplo, se agregó el contenido de <template> a <body>. El contenido ahora forma parte del DOM visible y con estilo.

Captura de pantalla de la pluma de código anterior como se muestra en el DOM.

Requerir que JavaScript implemente una plantilla para una sola calificación por estrellas no es muy útil, pero crear un componente web para un widget de calificación por estrellas personalizable que se use repetidamente es útil.

El elemento <slot>

Incluimos un espacio para incluir una leyenda personalizada por caso. El HTML proporciona un elemento <slot> como marcador de posición dentro de un <template> que, si se proporciona un nombre, crea un "espacio con nombre". Se puede usar un espacio con nombre para personalizar contenido dentro de un componente web. El elemento <slot> nos brinda una manera de controlar dónde se deben insertar los elementos secundarios de un elemento personalizado dentro de su shadow tree.

En nuestra plantilla, cambiamos <legend> a <slot>:

<template id="star-rating-template">
  <form>
    <fieldset>
      <slot name="star-rating-legend">
        <legend>Rate your experience:</legend>
      </slot>

El atributo name se usa para asignar ranuras a otros elementos si el elemento tiene un atributo slot cuyo valor coincide con el nombre de un slot con nombre. Si el elemento personalizado no coincide con un espacio, se renderizará el contenido de <slot>. Por lo tanto, incluimos una <legend> con contenido genérico que se puede renderizar si alguien simplemente incluye <star-rating></star-rating>, sin contenido, en su código HTML.

<star-rating>
  <legend slot="star-rating-legend">Blendan Smooth</legend>
</star-rating>
<star-rating>
  <legend slot="star-rating-legend">Hoover Sukhdeep</legend>
</star-rating>
<star-rating>
  <legend slot="star-rating-legend">Toasty McToastface</legend>
  <p>Is this text visible?</p>
</star-rating>

El atributo slot es un atributo global que se usa para reemplazar el contenido de <slot> dentro de un <template>. En nuestro elemento personalizado, el elemento con el atributo de ranura es un <legend>. No tiene que serlo. En nuestra plantilla, <slot name="star-rating-legend"> se reemplazará por <anyElement slot="star-rating-legend">, donde <anyElement> puede ser cualquier elemento, incluso otro personalizado.

Elementos no definidos

En nuestra <template>, usamos un elemento <rating>. Este no es un elemento personalizado. Más bien, es un elemento desconocido. Los navegadores no fallan cuando no reconocen un elemento. El navegador trata los elementos HTML no reconocidos como elementos intercalados anónimos a los que se les puede dar estilo con CSS. Al igual que <span>, los elementos <rating> y <star-rating> no tienen estilos ni semántica aplicados por el usuario-agente.

Ten en cuenta que <template> y el contenido no se renderizan. El <template> es un elemento conocido que incluye contenido que no se renderizará. Aún no se definió el elemento <star-rating>. Hasta que definamos un elemento, el navegador lo mostrará como todos los elementos no reconocidos. Por ahora, el <star-rating> no reconocido se trata como un elemento intercalado anónimo, por lo que el contenido que incluye las leyendas y los <p> de la tercera <star-rating> se muestran como lo harían si estuvieran en un <span>.

Definamos nuestro elemento para convertir este elemento no reconocido en un elemento personalizado.

Elementos personalizados

Se requiere JavaScript para definir los elementos personalizados. Cuando se define, el contenido del elemento <star-rating> se reemplaza por una shadow root que incluye todo el contenido de la plantilla que asociamos. Los elementos <slot> de la plantilla se reemplazan por el contenido del elemento dentro del <star-rating> cuyo valor del atributo slot coincide con el valor del nombre de <slot>, si hay uno. De lo contrario, se mostrará el contenido de las ranuras de la plantilla.

El contenido dentro de un elemento personalizado que no está asociado con un espacio (el <p>Is this text visible?</p> de nuestra tercera <star-rating>) no se incluye en la shadow root y, por lo tanto, no se muestra.

Definimos el elemento personalizado llamado star-rating extendiendo HTMLElement:

customElements.define('star-rating',
  class extends HTMLElement {
    constructor() {
      super(); // Always call super first in constructor
      const starRating = document.getElementById('star-rating-template').content;
      const shadowRoot = this.attachShadow({
        mode: 'open'
      });
      shadowRoot.appendChild(starRating.cloneNode(true));
    }
  });

Ahora que el elemento está definido, cada vez que el navegador encuentre un elemento <star-rating>, se renderizará tal como lo define el elemento con #star-rating-template, que es nuestra plantilla. El navegador adjuntará un árbol de shadow DOM al nodo y agregará una clonación del contenido de la plantilla a ese shadow DOM. Ten en cuenta que los elementos en los que puedes attachShadow() están limitados.

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(starRating.cloneNode(true));

Si observas las herramientas para desarrolladores, notarás que el <form> de <template> forma parte de la shadow root de cada elemento personalizado. Un clon del contenido de <template> es evidente en cada elemento personalizado en las herramientas para desarrolladores y es visible en el navegador, pero el contenido del elemento personalizado en sí no se renderiza en la pantalla.

Captura de pantalla de Herramientas para desarrolladores que muestra el contenido de la plantilla clonada en cada elemento personalizado.

En el ejemplo de <template>, agregamos el contenido de la plantilla al cuerpo del documento y agregamos el contenido al DOM normal. En la definición de customElements, usamos el mismo appendChild(), pero el contenido de la plantilla clonada se agregó a un shadow DOM encapsulado.

¿Notas cómo las estrellas volvieron a ser botones de selección sin estilo? Al ser parte de un shadow DOM y no del DOM estándar, no se aplica el estilo en la pestaña CSS de Codepen. Los estilos de CSS de esa pestaña se aplican al documento, no al shadow DOM, por lo que no se aplican los estilos. Tenemos que crear estilos encapsulados para nuestro contenido de Shadow DOM encapsulado.

Shadow DOM

El Shadow DOM aplica el alcance de los estilos CSS a cada shadow tree, de modo que se aísla del resto del documento. Esto significa que las CSS externas no se aplican a tu componente y los estilos de componentes no tienen efecto en el resto del documento, a menos que se los dirija de manera intencional.

Debido a que agregamos el contenido a un shadow DOM, podemos incluir un elemento <style> que proporcione CSS encapsulada al elemento personalizado.

Dado que el alcance se define en función del elemento personalizado, no debemos preocuparnos de que los estilos se filtren en el resto del documento. Podemos reducir de forma sustancial la especificidad de los selectores. Por ejemplo, como las únicas entradas que se usan en el elemento personalizado son los botones de selección, podemos usar input en lugar de input[type="radio"] como selector.

 <template id="star-rating-template">
  <style>
    rating {
      display: inline-flex;
    }
    input {
      appearance: none;
      margin: 0;
      box-shadow: none;
    }
    input::after {
      content: '\2605'; /* solid star */
      font-size: 32px;
    }
    rating:hover input:invalid::after,
    rating:focus-within input:invalid::after {
      color: #888;
    }
    input:invalid::after,
      rating:hover input:hover ~ input:invalid::after,
      input:focus ~ input:invalid::after  {
      color: #ddd;
    }
    input:valid {
      color: orange;
    }
    input:checked ~ input:not(:checked)::after {
      color: #ccc;
      content: '\2606'; /* hollow star */
    }
  </style>
  <form>
    <fieldset>
      <slot name="star-rating-legend">
        <legend>Rate your experience:</legend>
      </slot>
      <rating>
        <input type="radio" name="rating" value="1" aria-label="1 star" required/>
        <input type="radio" name="rating" value="2" aria-label="2 stars"/>
        <input type="radio" name="rating" value="3" aria-label="3 stars"/>
        <input type="radio" name="rating" value="4" aria-label="4 stars"/>
        <input type="radio" name="rating" value="5" aria-label="5 stars"/>
      </rating>
    </fieldset>
    <button type="reset">Reset</button>
    <button type="submit">Submit</button>
  </form>
</template>

Si bien los componentes web se encapsulan con lenguaje de marcado en <template> y los estilos CSS tienen alcance en el shadow DOM y se ocultan de todo fuera de los componentes, el contenido de ranuras que se renderiza, la parte <anyElement slot="star-rating-legend"> de <star-rating>, no se encapsula.

Diseños fuera del alcance actual

Es posible, aunque no es sencillo, aplicar estilo al documento desde un shadow DOM y al contenido de un shadow DOM a partir de estilos globales. El límite de sombras, donde termina el shadow DOM y comienza el DOM normal, se puede atravesar, pero solo de manera intencional.

El shadow tree es el árbol del DOM dentro del shadow DOM. La shadow root es el nodo raíz del shadow tree.

La seudoclase :host selecciona <star-rating>, el elemento de host paralelo. El shadow host es el nodo del DOM al que se adjunta el shadow DOM. Para establecer la segmentación solo a versiones específicas del host, usa :host(). Esta acción seleccionará solo los elementos del host paralelo que coincidan con el parámetro que se pasó, como un selector de clase o atributo. Para seleccionar todos los elementos personalizados, puedes usar star-rating { /* styles */ } en el CSS global o :host(:not(#nonExistantId)) en los diseños de plantilla. En términos de especificidad, el CSS global es el ganador.

El seudoelemento ::slotted() cruza el límite del shadow DOM desde el shadow DOM. Selecciona un elemento ranurado si coincide con el selector. En nuestro ejemplo, ::slotted(legend) coincide con nuestras tres leyendas.

Para apuntar a un shadow DOM desde CSS en el alcance global, se debe editar la plantilla. Puedes agregar el atributo part a cualquier elemento al que quieras aplicarle diseño. Luego, usa el seudoelemento ::part() para hacer coincidir los elementos dentro de un árbol de sombra que coincidan con el parámetro que se pasó. El elemento de origen o ancla del seudoelemento es el nombre del host o del elemento personalizado, en este caso, star-rating. El parámetro es el valor del atributo part.

Si el lenguaje de marcado de nuestra plantilla comenzara de la siguiente manera:

<template id="star-rating-template">
  <form part="formPart">
    <fieldset part="fieldsetPart">

Podríamos segmentar los anuncios para <form> y <fieldset> de la siguiente manera:

star-rating::part(formPart) { /* styles */ }
star-rating::part(fieldsetPart) { /* styles */ }

Los nombres de las partes actúan de manera similar a las clases: un elemento puede tener varios nombres de parte separados por espacios, y varios elementos pueden tener el mismo nombre de parte.

Google tiene una lista de tareas fantástica para crear elementos personalizados. También te recomendamos que obtengas información sobre los shadow DOM declarativos.

Verifica tus conocimientos

Pon a prueba tus conocimientos sobre plantillas, ranuras y sombras.

De forma predeterminada, los estilos externos al shadow DOM darán estilo a los elementos internos.

Verdadero.
Vuelve a intentarlo.
Falso
Correcto.

¿Cuál de las siguientes opciones es una descripción correcta del elemento <template>?

Elemento genérico que se usa para mostrar cualquier contenido de tu página.
Vuelve a intentarlo.
Un elemento de marcador de posición.
Vuelve a intentarlo.
Elemento que se usa para declarar fragmentos de HTML que no se renderizará de forma predeterminada.
Correcto.