Modelo, slot e sombra

O benefício dos componentes da Web é a reutilização: é possível criar um widget de IU uma vez e reutilizá-lo várias vezes. Embora você precise do JavaScript para criar componentes da Web, não é preciso ter uma biblioteca de JavaScript. O HTML e as APIs associadas fornecem tudo que você precisa.

O padrão do componente Web é composto de três partes: modelos HTML, elementos personalizados e Shadow DOM. Combinadas, elas permitem a criação de elementos personalizados, autossuficientes (encapsulados) e reutilizáveis que podem ser perfeitamente integrados a aplicativos existentes, como todos os outros elementos HTML que já abordamos.

Nesta seção, vamos criar o elemento <star-rating>, um componente da Web que permite que os usuários avaliem uma experiência em uma escala de uma a cinco estrelas. Ao nomear um elemento personalizado, é recomendável usar somente letras minúsculas. Além disso, inclua um traço, para ajudar a distinguir entre elementos regulares e personalizados.

Veremos como usar os elementos <template> e <slot>, o atributo slot e o JavaScript para criar um modelo com um Shadow DOM encapsulado. Em seguida, reutilizaremos o elemento definido, personalizando uma seção de texto, assim como você faria com qualquer elemento ou componente da Web. Também discutiremos brevemente o uso do CSS dentro e fora do elemento personalizado.

O elemento <template>

O elemento <template> é usado para declarar fragmentos de HTML que serão clonados e inseridos no DOM com JavaScript. O conteúdo do elemento não é renderizado por padrão. Em vez disso, eles são instanciados usando 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 o conteúdo de um elemento <template> não é gravado na tela, a <form> e o conteúdo dela não são renderizados. Sim, ele está em branco, mas se você inspecionar a guia HTML, vai encontrar a marcação <template>.

Neste exemplo, o <form> não é filho de uma <template> no DOM. Em vez disso, o conteúdo de elementos <template> são filhos de uma DocumentFragment retornada pela propriedade HTMLTemplateElement.content. Para ficar visível, o JavaScript precisa ser usado para capturar o conteúdo e anexá-lo ao DOM.

Este JavaScript breve não criou um elemento personalizado. Em vez disso, este exemplo anexou o conteúdo do <template> ao <body>. O conteúdo se tornou parte do DOM visível e estilizado.

Captura de tela do codepen anterior, conforme mostrado no DOM.

Exigir que o JavaScript implemente um modelo para apenas uma nota não é muito útil, mas criar um componente da Web para um widget de avaliação com estrelas personalizável e usado repetidamente é útil.

O elemento <slot>

Incluímos um slot para incluir uma legenda personalizada por ocorrência. O HTML fornece um elemento <slot> como um marcador dentro de um <template> que, se fornecido um nome, cria um "slot nomeado". Um slot nomeado pode ser usado para personalizar o conteúdo em um componente da Web. O elemento <slot> oferece uma maneira de controlar onde os filhos de um elemento personalizado precisam ser inseridos na árvore paralela.

No nosso modelo, mudamos <legend> para <slot>:

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

O atributo name é usado para atribuir slots a outros elementos se o elemento tiver um atributo slot com um valor que corresponde ao nome de um slot nomeado. Se o elemento personalizado não tiver uma correspondência para um slot, o conteúdo do <slot> será renderizado. Por isso, incluímos um <legend> com conteúdo genérico que pode ser renderizado se alguém simplesmente incluir <star-rating></star-rating>, sem conteúdo, no 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>

O atributo slot é um atributo global usado para substituir o conteúdo do <slot> em um <template>. No nosso elemento personalizado, o elemento com o atributo de slot é um <legend>. Não precisa ser assim. No nosso modelo, <slot name="star-rating-legend"> será substituído por <anyElement slot="star-rating-legend">, em que <anyElement> pode ser qualquer elemento, até mesmo outro elemento personalizado.

Elementos indefinidos

No <template>, usamos um elemento <rating>. Este não é um elemento personalizado. Na verdade, é um elemento desconhecido. Os navegadores não falham quando não reconhecem um elemento. Elementos HTML não reconhecidos são tratados pelo navegador como elementos in-line anônimos que podem ser estilizados com CSS. Semelhante a <span>, os elementos <rating> e <star-rating> não têm estilos ou semânticas aplicados por user agent.

O <template> e o conteúdo não são renderizados. O <template> é um elemento conhecido que tem conteúdo que não vai ser renderizado. O elemento <star-rating> ainda não foi definido. Até definirmos um elemento, o navegador o exibe como todos os elementos não reconhecidos. Por enquanto, o <star-rating> não reconhecido é tratado como um elemento inline anônimo. Portanto, o conteúdo que inclui legendas e o <p> na terceira <star-rating> é mostrado como seria se estivesse em um <span>.

Vamos definir nosso elemento para converter esse elemento não reconhecido em um elemento personalizado.

Elementos personalizados

O JavaScript é necessário para definir elementos personalizados. Quando definido, o conteúdo do elemento <star-rating> será substituído por uma raiz paralela com todo o conteúdo do modelo associado a ele. Os elementos <slot> do modelo são substituídos pelo conteúdo do elemento dentro da <star-rating> cujo valor do atributo slot corresponde ao valor do nome do <slot>, se houver um. Caso contrário, o conteúdo dos espaços do modelo será exibido.

O conteúdo em um elemento personalizado que não está associado a um slot (o <p>Is this text visible?</p> em nosso terceiro <star-rating>) não é incluído na raiz paralela e, portanto, não é exibido.

Definimos o elemento personalizado chamado star-rating estendendo o 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));
    }
  });

Agora que o elemento está definido, toda vez que o navegador encontrar um elemento <star-rating>, ele será renderizado de acordo com a definição do elemento com o #star-rating-template, que é nosso modelo. O navegador anexa uma árvore do shadow DOM ao nó, anexando um clone do conteúdo do modelo a esse shadow DOM. Os elementos sobre os quais você pode attachShadow() são limitados.

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

Se você observar as ferramentas para desenvolvedores, vai notar que o <form> do <template> faz parte da raiz paralela de cada elemento personalizado. Um clone do conteúdo de <template> fica aparente em cada elemento personalizado nas ferramentas para desenvolvedores e no navegador, mas o conteúdo do próprio elemento personalizado não é renderizado na tela.

Captura de tela do DevTools mostrando o conteúdo do modelo clonado em cada elemento personalizado.

No exemplo <template>, anexamos o conteúdo do modelo ao corpo do documento, adicionando o conteúdo ao DOM normal. Na definição de customElements, usamos o mesmo appendChild(), mas o conteúdo do modelo clonado foi anexado a um shadow DOM encapsulado.

Viu como as estrelas voltaram a ser botões de opção sem estilo? Como ele faz parte de um shadow DOM em vez de um DOM padrão, o estilo na guia CSS do Codepen não se aplica. Os estilos CSS dessa guia têm o escopo definido como o documento, e não para o shadow DOM. Portanto, os estilos não são aplicados. Precisamos criar estilos encapsulados para estilizar nosso conteúdo encapsulado do Shadow DOM.

Shadow DOM

O Shadow DOM define estilos de CSS para cada árvore de sombra, isolando-a do restante do documento. Isso significa que o CSS externo não se aplica ao componente, e os estilos de componentes não afetam o restante do documento, a menos que sejam direcionados intencionalmente.

Como anexamos o conteúdo a um shadow DOM, podemos incluir um elemento <style> fornecendo CSS encapsulado ao elemento personalizado.

Como o escopo é o elemento personalizado, não precisamos nos preocupar com o fluxo de estilos para o resto do documento. Podemos reduzir substancialmente a especificidade dos seletores. Por exemplo, como as únicas entradas usadas no elemento personalizado são botões de opção, podemos usar input em vez de input[type="radio"] como um seletor.

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

Embora os componentes da Web sejam encapsulados com marcações <template>, e os estilos CSS tenham escopo para o shadow DOM e fiquem ocultos de tudo fora dos componentes, o conteúdo do slot que é renderizado, a parte <anyElement slot="star-rating-legend"> do <star-rating>, não é encapsulado.

Estilo fora do escopo atual

É possível, mas não simples, estilizar o documento de dentro de um shadow DOM e definir o estilo do conteúdo desse shadow DOM usando os estilos globais. O limite do shadow, onde o shadow DOM termina e o DOM normal começa, pode ser percorrido, mas muito intencionalmente.

A árvore de sombra é a árvore do DOM dentro do shadow DOM. A raiz paralela é o nó raiz da árvore paralela.

A pseudoclasse :host seleciona <star-rating>, o elemento host de sombra. O host shadow é o nó DOM ao qual o shadow DOM está anexado. Para segmentar apenas versões específicas do host, use :host(). Isso selecionará apenas os elementos do host sombra que correspondem ao parâmetro passado, como um seletor de classe ou atributo. Para selecionar todos os elementos personalizados, use star-rating { /* styles */ } no CSS global ou :host(:not(#nonExistantId)) nos estilos de modelo. Em termos de especificidade, o CSS global vence.

O pseudoelemento ::slotted() cruza o limite do shadow DOM dentro do shadow DOM. Ela seleciona um elemento com slot se corresponder ao seletor. Em nosso exemplo, ::slotted(legend) corresponde às nossas três legendas.

Para segmentar um shadow DOM do CSS no escopo global, o modelo precisa ser editado. O atributo part pode ser adicionado a qualquer elemento que você queira estilizar. Em seguida, use o pseudoelemento ::part() para combinar elementos em uma árvore paralela que correspondam ao parâmetro transmitido. O elemento âncora ou de origem do pseudoelemento é o nome do host ou do elemento personalizado, neste caso, star-rating. O parâmetro é o valor do atributo part.

Se a marcação do modelo começar assim:

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

É possível segmentar <form> e <fieldset> com:

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

Os nomes das partes agem de forma semelhante às classes: um elemento pode ter vários nomes de partes separados por espaços, e vários elementos podem ter o mesmo nome de parte.

O Google tem uma lista de verificação fantástica para criar elementos personalizados. Se quiser saber mais, saiba mais sobre os shadow DOMs declarativos.

Teste seu conhecimento

Teste seus conhecimentos sobre modelo, slot e sombra.

Por padrão, os estilos de fora do shadow DOM definirão o estilo dos elementos internos.

Verdadeiro
Tente novamente.
Falso
Correto.

Qual resposta é uma descrição correta do elemento <template>?

Um elemento genérico usado para exibir qualquer conteúdo na sua página.
Tente novamente.
Um elemento de marcador.
Tente novamente.
Um elemento usado para declarar fragmentos de HTML que não são renderizados por padrão.
Correto.