Shadow DOM v1: componentes da Web independentes

O Shadow DOM permite que os desenvolvedores web criem DOM e CSS compartimentados para componentes da Web.

Resumo

O DOM paralelo remove a fragilidade da criação de apps da Web. A fragilidade vem da natureza global de HTML, CSS e JS. Ao longo dos anos, inventamos um número exorbitante de ferramentas para contornar os problemas. Por exemplo, quando você usa um novo ID/classe HTML, não é possível saber se ele vai entrar em conflito com um nome já usado pela página. Bugs sutis aparecem, a especificidade do CSS se torna um grande problema (!important tudo!), os seletores de estilo saem do controle e o desempenho pode ser prejudicado. E assim por diante.

O Shadow DOM corrige o CSS e o DOM. Ele introduz estilos com escopo na plataforma da Web. Sem ferramentas ou convenções de nomenclatura, é possível agrupar CSS com marcação, ocultar detalhes de implementação e criar componentes independentes em JavaScript puro.

Introdução

O Shadow DOM é um dos três padrões de componentes da Web: modelos HTML, Shadow DOM e elementos personalizados. As importações de HTML costumavam fazer parte da lista, mas agora são consideradas descontinuadas.

Não é necessário criar componentes da Web que usem o DOM paralelo. Mas, quando você faz isso, aproveita os benefícios (escopo de CSS, encapsulamento de DOM, composição) e cria elementos personalizados reutilizáveis, que são resilientes, altamente configuráveis e extremamente reutilizáveis. Se os elementos personalizados são a maneira de criar um novo HTML (com uma API JS), o shadow DOM é a maneira de fornecer o HTML e o CSS. As duas APIs se combinam para criar um componente com HTML, CSS e JavaScript independentes.

O shadow DOM foi projetado como uma ferramenta para criar apps baseados em componentes. Portanto, ele traz soluções para problemas comuns no desenvolvimento da Web:

  • DOM isolado: o DOM de um componente é independente (por exemplo, document.querySelector() não retorna nós no shadow DOM do componente).
  • CSS com escopo: o CSS definido dentro do shadow DOM tem escopo para ele. As regras de estilo não vazam e os estilos de página não se misturam.
  • Composição: projete uma API declarativa baseada em marcação para seu componente.
  • Simplifica o CSS: o DOM com escopo significa que você pode usar seletores de CSS simples, nomes de ID/classe mais genéricos e não se preocupar com conflitos de nomenclatura.
  • Produtividade: pense nos apps em blocos de DOM, em vez de uma página grande (global).

Demonstração do fancy-tabs

Ao longo deste artigo, vamos nos referir a um componente de demonstração (<fancy-tabs>) e referenciar snippets de código dele. Se o navegador for compatível com as APIs, você vai encontrar uma demonstração ao vivo logo abaixo. Caso contrário, confira a fonte completa no GitHub.

Ver código-fonte no GitHub

O que é o shadow DOM?

Contexto sobre o DOM

O HTML é a base da Web porque é fácil de usar. Ao declarar algumas tags, você pode criar uma página em segundos que tenha apresentação e estrutura. No entanto, o HTML sozinho não é tão útil. É fácil para os humanos entenderem uma linguagem baseada em texto, mas as máquinas precisam de algo mais. É aí que entra o Modelo de Objeto de Documentos, ou DOM.

Quando o navegador carrega uma página da Web, ele faz várias coisas interessantes. Uma das coisas que ele faz é transformar o HTML do autor em um documento ativo. Basicamente, para entender a estrutura da página, o navegador analisa o HTML (strings de texto estáticas) em um modelo de dados (objetos/nós). O navegador preserva a hierarquia do HTML criando uma árvore desses nós: o DOM. O legal do DOM é que ele é uma representação em tempo real da sua página. Ao contrário do HTML estático que criamos, os nós produzidos pelo navegador contêm propriedades, métodos e, o melhor de tudo, podem ser manipulados por programas. Por isso, podemos criar elementos DOM diretamente usando JavaScript:

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

produz a seguinte marcação HTML:

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

Isso é ótimo. Então, o que é o shadow DOM?

DOM… nas sombras

O Shadow DOM é um DOM normal com duas diferenças: 1) como ele é criado/usado e 2) como ele se comporta em relação ao restante da página. Normalmente, você cria nós do DOM e os anexa como filhos de outro elemento. Com o shadow DOM, você cria uma árvore do DOM com escopo que é anexada ao elemento, mas separada dos filhos reais. Essa subárvore com escopo é chamada de árvore de sombra. O elemento ao qual ele está anexado é o host de sombra. Tudo o que você adicionar nas sombras se torna local para o elemento de hospedagem, incluindo <style>. É assim que o DOM de sombra consegue o escopo de estilo CSS.

Como criar um DOM paralelo

Uma raiz de sombra é um fragmento de documento que é anexado a um elemento "host". O ato de anexar uma raiz de sombra é como o elemento ganha o shadow DOM. Para criar um shadow DOM para um elemento, chame element.attachShadow():

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

Estou usando .innerHTML para preencher a raiz de sombra, mas você também pode usar outras APIs DOM. Esta é a Web. Temos escolhas.

A especificação define uma lista de elementos que não podem hospedar uma árvore de sombra. Há vários motivos para um elemento estar na lista:

  • O navegador já hospeda o próprio shadow DOM interno para o elemento (<textarea>, <input>).
  • Não faz sentido o elemento hospedar um shadow DOM (<img>).

Por exemplo, isto não funciona:

    document.createElement('input').attachShadow({mode: 'open'});
    // Error. `<input>` cannot host shadow dom.

Como criar um shadow DOM para um elemento personalizado

O shadow DOM é particularmente útil ao criar elementos personalizados. Use o shadow DOM para compartimentar o HTML, o CSS e o JS de um elemento, produzindo um "componente da Web".

Exemplo: um elemento personalizado anexa o shadow DOM a si mesmo, encapsulando o DOM/CSS:

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
        <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
        <div id="tabs">...</div>
        <div id="panels">...</div>
    `;
    }
    ...
});

Há algumas coisas interessantes acontecendo aqui. A primeira é que o elemento personalizado cria o próprio shadow DOM quando uma instância de <fancy-tabs> é criada. Isso é feito no constructor(). Em segundo lugar, como estamos criando uma raiz de sombra, as regras de CSS dentro de <style> serão limitadas a <fancy-tabs>.

Composição e slots

A composição é um dos recursos menos compreendidos do shadow DOM, mas é provavelmente o mais importante.

No mundo do desenvolvimento Web, a composição é como criamos apps, de forma declarativa, usando HTML. Diferentes elementos básicos (<div>s, <header>s, <form>s, <input>s) se unem para formar apps. Algumas dessas tags até funcionam em conjunto. A composição é o motivo pelo qual elementos nativos como <select>, <details>, <form> e <video> são tão flexíveis. Cada uma dessas tags aceita determinados HTMLs como filhos e faz algo especial com eles. Por exemplo, <select> sabe como renderizar <option> e <optgroup> em widgets de menu suspenso e seleção múltipla. O elemento <details> renderiza <summary> como uma seta expansível. Até mesmo <video> sabe como lidar com determinados filhos: os elementos <source> não são renderizados, mas afetam o comportamento do vídeo. Que magia!

Terminologia: DOM leve x DOM paralelo

A composição do shadow DOM introduz uma série de novos conceitos básicos no desenvolvimento da Web. Antes de entrar em detalhes, vamos padronizar algumas terminologias para que todos usem os mesmos termos.

Light DOM

A marcação que um usuário do seu componente escreve. Esse DOM fica fora do shadow DOM do componente. São os filhos reais do elemento.

<better-button>
    <!-- the image and span are better-button's light DOM -->
    <img src="gear.svg" slot="icon">
    <span>Settings</span>
</better-button>

Shadow DOM

O DOM que o autor de um componente escreve. O Shadow DOM é local para o componente e define a estrutura interna, o CSS com escopo e encapsula os detalhes da implementação. Ele também pode definir como renderizar a marcação criada pelo consumidor do componente.

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

Árvore do DOM nivelada

O resultado da distribuição do light DOM do usuário no shadow DOM pelo navegador, renderizando o produto final. A árvore simplificada é o que você vê no DevTools e o que é renderizado na página.

<better-button>
    #shadow-root
    <style>...</style>
    <slot name="icon">
        <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
        <slot>
        <span>Settings</span>
        </slot>
    </span>
</better-button>

O elemento <slot>

O shadow DOM compõe diferentes árvores do DOM usando o elemento <slot>. Os slots são marcadores de posição dentro do componente que os usuários podem preencher com a própria marcação. Ao definir um ou mais slots, você convida a marcação externa para renderizar no DOM de sombra do componente. Basicamente, você está dizendo "Renderize a marcação do usuário aqui".

Os elementos podem "cruzar" o limite do DOM de sombra quando um <slot> os convida. Esses elementos são chamados de nós distribuídos. Conceitualmente, os nós distribuídos podem parecer um pouco bizarros. Os slots não movem fisicamente o DOM, mas o renderizam em outro local dentro do shadow DOM.

Um componente pode definir zero ou mais slots no shadow DOM. Os slots podem estar vazios ou fornecer conteúdo substituto. Se o usuário não fornecer conteúdo light DOM, o slot vai renderizar o conteúdo substituto.

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
    <h2>Title</h2>
    <summary>Description text</summary>
</slot>

Também é possível criar slots nomeados. Os slots nomeados são furos específicos no DOM de sombra que os usuários referenciam pelo nome.

Exemplo: os slots no shadow DOM de <fancy-tabs>:

#shadow-root
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>

Os usuários do componente declaram <fancy-tabs> desta forma:

<fancy-tabs>
    <button slot="title">Title</button>
    <button slot="title" selected>Title 2</button>
    <button slot="title">Title 3</button>
    <section>content panel 1</section>
    <section>content panel 2</section>
    <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
    <h2 slot="title">Title</h2>
    <section>content panel 1</section>
    <h2 slot="title" selected>Title 2</h2>
    <section>content panel 2</section>
    <h2 slot="title">Title 3</h2>
    <section>content panel 3</section>
</fancy-tabs>

E, se você está se perguntando, a árvore nivelada é mais ou menos assim:

<fancy-tabs>
    #shadow-root
    <div id="tabs">
        <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
        </slot>
    </div>
    <div id="panels">
        <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
        </slot>
    </div>
</fancy-tabs>

Observe que nosso componente é capaz de lidar com diferentes configurações, mas a árvore do DOM nivelada permanece a mesma. Também é possível mudar de <button> para <h2>. Esse componente foi criado para lidar com diferentes tipos de filhos, assim como o <select>.

Estilo

Há muitas opções para estilizar componentes da Web. Um componente que usa o DOM de sombra pode ser estilizado pela página principal, definir os próprios estilos ou fornecer hooks (na forma de propriedades personalizadas de CSS) para que os usuários substituam os padrões.

Estilos definidos por componentes

Sem dúvida, o recurso mais útil do shadow DOM é o CSS com escopo:

  • Os seletores de CSS da página externa não se aplicam ao componente.
  • Os estilos definidos dentro do arquivo não vazam. Eles têm como escopo o elemento host.

Os seletores de CSS usados no shadow DOM se aplicam localmente ao componente. Na prática, isso significa que podemos usar nomes de ID/classe comuns novamente, sem nos preocupar com conflitos em outras partes da página. Seletores de CSS mais simples são uma prática recomendada no DOM paralelo. Eles também são bons para o desempenho.

Exemplo: os estilos definidos em uma raiz de sombra são locais

#shadow-root
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        ...
    }
    #tabs {
        display: inline-flex;
        ...
    }
    </style>
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

As folhas de estilo também têm escopo para a árvore de sombra:

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

Você já se perguntou como o elemento <select> renderiza um widget de seleção múltipla (em vez de um menu suspenso) quando você adiciona o atributo multiple:

<select multiple>
  <option>Do</option>
  <option selected>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

O <select> pode estilizar a si mesmo de maneira diferente com base nos atributos que você declara nele. Os componentes da Web também podem se estilizar usando o seletor :host.

Exemplo: um componente que se estiliza

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

Uma pegadinha com :host é que as regras na página pai têm maior especificidade do que as regras :host definidas no elemento. Ou seja, os estilos externos vencem. Isso permite que os usuários substituam o estilo de nível superior de fora. Além disso, :host só funciona no contexto de uma raiz de sombra, então não é possível usá-lo fora do DOM de sombra.

A forma funcional de :host(<selector>) permite segmentar o host se ele corresponder a um <selector>. Essa é uma ótima maneira de o componente encapsular comportamentos que reagem à interação do usuário ou ao estado ou estilo de nós internos com base no host.

<style>
:host {
    opacity: 0.4;
    will-change: opacity;
    transition: opacity 300ms ease-in-out;
}
:host(:hover) {
    opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
}
:host(.blue) {
    color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

Estilo com base no contexto

:host-context(<selector>) corresponderá ao componente se ele ou qualquer um dos ancestrais dele corresponder a <selector>. Um uso comum para isso é a aplicação de temas com base no ambiente de um componente. Por exemplo, muitas pessoas aplicam temas usando uma classe a <html> ou <body>:

<body class="darktheme">
    <fancy-tabs>
    ...
    </fancy-tabs>
</body>

:host-context(.darktheme) estilizaria <fancy-tabs> quando ele fosse um descendente de .darktheme:

:host-context(.darktheme) {
    color: white;
    background: black;
}

:host-context() pode ser útil para temas, mas uma abordagem ainda melhor é criar ganchos de estilo usando propriedades personalizadas de CSS.

Como estilizar nós distribuídos

::slotted(<compound-selector>) corresponde a nós distribuídos em um <slot>.

Digamos que criamos um componente de crachá:

<name-badge>
    <h2>Eric Bidelman</h2>
    <span class="title">
    Digital Jedi, <span class="company">Google</span>
    </span>
</name-badge>

O shadow DOM do componente pode estilizar os elementos <h2> e .title do usuário:

<style>
::slotted(h2) {
    margin: 0;
    font-weight: 300;
    color: red;
}
::slotted(.title) {
    color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
    text-transform: uppercase;
}
*/
</style>
<slot></slot>

Como você deve se lembrar, os <slot>s não movem o DOM leve do usuário. Quando os nós são distribuídos em um <slot>, o <slot> renderiza o DOM, mas os nós permanecem fisicamente no lugar. Os estilos aplicados antes da distribuição continuam sendo aplicados depois dela. No entanto, quando o light DOM é distribuído, ele pode assumir outros estilos (definidos pelo shadow DOM).

Outro exemplo mais detalhado do <fancy-tabs>:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        border-radius: 3px;
        padding: 16px;
        height: 250px;
        overflow: auto;
    }
    #tabs {
        display: inline-flex;
        -webkit-user-select: none;
        user-select: none;
    }
    #tabsSlot::slotted(*) {
        font: 400 16px/22px 'Roboto';
        padding: 16px 8px;
        ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
        font-weight: 600;
        background: white;
        box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
        display: none;
    }
    </style>
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>
`;

Neste exemplo, há dois slots: um slot nomeado para os títulos das guias e um slot para o conteúdo do painel de guias. Quando o usuário seleciona uma guia, a seleção fica em negrito e o painel é revelado. Para isso, selecione nós distribuídos que tenham o atributo selected. O JS do elemento personalizado (não mostrado aqui) adiciona esse atributo no momento certo.

Como estilizar um componente de fora

Há algumas maneiras de definir o estilo de um componente de fora. A maneira mais fácil é usar o nome da tag como um seletor:

fancy-tabs {
    width: 500px;
    color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
    box-shadow: 0 3px 3px #ccc;
}

Os estilos externos sempre prevalecem sobre os estilos definidos no shadow DOM. Por exemplo, se o usuário escrever o seletor fancy-tabs { width: 500px; }, ele vai substituir a regra do componente: :host { width: 650px;}.

Estilizar o componente em si não é suficiente. Mas o que acontece se você quiser estilizar os elementos internos de um componente? Para isso, precisamos de propriedades personalizadas de CSS.

Como criar ganchos de estilo usando propriedades personalizadas de CSS

Os usuários podem ajustar os estilos internos se o autor do componente fornecer hooks de estilo usando propriedades personalizadas de CSS. Conceitualmente, a ideia é semelhante a <slot>. Você cria "marcadores de estilo" para os usuários substituírem.

Exemplo: <fancy-tabs> permite que os usuários substituam a cor do plano de fundo:

<!-- main page -->
<style>
    fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
    }
</style>
<fancy-tabs background>...</fancy-tabs>

No shadow DOM:

:host([background]) {
    background: var(--fancy-tabs-bg, #9E9E9E);
    border-radius: 10px;
    padding: 10px;
}

Nesse caso, o componente vai usar black como o valor de plano de fundo, já que o usuário o forneceu. Caso contrário, o padrão será #9E9E9E.

Tópicos avançados

Como criar raízes de sombra fechadas (evitar)

Há outro tipo de DOM de sombra, chamado de modo "fechado". Quando você cria uma árvore de sombra fechada, o JavaScript externo não consegue acessar o DOM interno do componente. Isso é semelhante ao funcionamento de elementos nativos, como <video>. O JavaScript não pode acessar o DOM paralelo de <video> porque o navegador o implementa usando uma raiz paralela de modo fechado.

Exemplo: como criar uma árvore de sombra fechada:

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

Outras APIs também são afetadas pelo modo fechado:

  • Element.assignedSlot / TextNode.assignedSlot retorna null
  • Event.composedPath() para eventos associados a elementos dentro do DOM shadow, retorna []

Aqui está meu resumo de por que você nunca deve criar componentes da Web com {mode: 'closed'}:

  1. Sensação artificial de segurança. Nada impede que um invasor sequestre Element.prototype.attachShadow.

  2. O modo fechado impede que o código do elemento personalizado acesse o próprio DOM de sombra. Isso é uma falha total. Em vez disso, você precisará armazenar uma referência para mais tarde se quiser usar itens como querySelector(). Isso anula completamente o propósito original do modo fechado.

        customElements.define('x-element', class extends HTMLElement {
        constructor() {
        super(); // always call super() first in the constructor.
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
        }
        connectedCallback() {
        // When creating closed shadow trees, you'll need to stash the shadow root
        // for later if you want to use it again. Kinda pointless.
        const wrapper = this._shadowRoot.querySelector('.wrapper');
        }
        ...
    });
    
  3. O modo fechado torna o componente menos flexível para os usuários finais. Ao criar componentes da Web, pode ser que você se esqueça de adicionar um recurso. Uma opção de configuração. Um caso de uso que o usuário quer. Um exemplo comum é esquecer de incluir ganchos de estilo adequados para nós internos. Com o modo fechado, não há como os usuários substituírem os padrões e ajustarem os estilos. Poder acessar os componentes internos é muito útil. Por fim, os usuários vão bifurcar seu componente, encontrar outro ou criar o próprio se ele não fizer o que eles querem :(

Como trabalhar com slots em JS

A API shadow DOM fornece utilitários para trabalhar com slots e nós distribuídos. Eles são úteis ao criar um elemento personalizado.

evento slotchange

O evento slotchange é disparado quando os nós distribuídos de um slot mudam. Por exemplo, se o usuário adicionar/remover filhos do DOM leve.

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
    console.log('light dom children changed!');
});

Para monitorar outros tipos de mudanças no DOM leve, configure um MutationObserver no construtor do elemento.

Quais elementos estão sendo renderizados em um slot?

Às vezes, é útil saber quais elementos estão associados a um slot. Chame slot.assignedNodes() para descobrir quais elementos o slot está renderizando. A opção {flatten: true} também retorna o conteúdo substituto de um slot (se nenhum nó estiver sendo distribuído).

Por exemplo, digamos que seu shadow DOM seja assim:

<slot><b>fallback content</b></slot>
UsoLigarResultado
<my-component>component text</my-component> slot.assignedNodes(); [component text]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

A qual slot um elemento é atribuído?

Também é possível responder à pergunta inversa. element.assignedSlot informa a qual dos slots de componente seu elemento está atribuído.

O modelo de evento do Shadow DOM

Quando um evento é propagado do shadow DOM, o destino dele é ajustado para manter o encapsulamento fornecido pelo shadow DOM. Ou seja, os eventos são redirecionados para parecer que vieram do componente, e não de elementos internos do shadow DOM. Alguns eventos nem sequer se propagam para fora do DOM de sombra.

Os eventos que atravessam o limite de sombra são:

  • Eventos de foco: blur, focus, focusin, focusout
  • Eventos de mouse: click, dblclick, mousedown, mouseenter, mousemove etc.
  • Eventos de roda: wheel
  • Eventos de entrada: beforeinput, input
  • Eventos de teclado: keydown, keyup
  • Eventos de composição: compositionstart, compositionupdate, compositionend
  • DragEvent: dragstart, drag, dragend, drop etc.

Dicas

Se a árvore de sombra estiver aberta, chamar event.composedPath() vai retornar uma matriz de nós pelos quais o evento passou.

Usar eventos personalizados

Os eventos DOM personalizados que são disparados em nós internos em uma árvore de sombra não saem do limite de sombra, a menos que o evento seja criado usando a flag composed: true:

// Inside <fancy-tab> custom element class definition:
selectTab() {
    const tabs = this.shadowRoot.querySelector('#tabs');
    tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

Se for composed: false (padrão), os consumidores não poderão detectar o evento fora da raiz de sombra.

<fancy-tabs></fancy-tabs>
<script>
    const tabs = document.querySelector('fancy-tabs');
    tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
    });
</script>

Processamento de foco

Se você se lembra do modelo de evento do shadow DOM, os eventos que são disparados dentro do shadow DOM são ajustados para parecer que vêm do elemento de hospedagem. Por exemplo, digamos que você clique em um <input> dentro de uma raiz de sombra:

<x-focus>
    #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

O evento focus vai parecer que veio de <x-focus>, não de <input>. Da mesma forma, document.activeElement será <x-focus>. Se a raiz de sombra foi criada com mode:'open' (consulte o modo fechado), você também poderá acessar o nó interno que recebeu o foco:

document.activeElement.shadowRoot.activeElement // only works with open mode.

Se houver vários níveis de shadow DOM em jogo (por exemplo, um elemento personalizado dentro de outro), será necessário acessar as raízes de sombra de forma recursiva para encontrar o activeElement:

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

Outra opção para o foco é a delegatesFocus: true, que expande o comportamento de foco do elemento em uma árvore de sombra:

  • Se você clicar em um nó dentro do shadow DOM e ele não for uma área focalizável, a primeira área focalizável será focalizada.
  • Quando um nó dentro do shadow DOM ganha foco, :focus se aplica ao host, além do elemento em foco.

Exemplo: como delegatesFocus: true muda o comportamento do foco

<style>
    :focus {
    outline: 2px solid red;
    }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
        <style>
        :host {
            display: flex;
            border: 1px dotted black;
            padding: 16px;
        }
        :focus {
            outline: 2px solid blue;
        }
        </style>
        <div>Clickable Shadow DOM text</div>
        <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
        console.log('Active element (inside shadow dom):',
                    this.shadowRoot.activeElement);
    });
    }
});
</script>

Result

delegatesFocus: true.

Acima está o resultado quando <x-focus> está em foco (clique do usuário, tabulação, focus() etc.), "Texto do DOM de sombra clicável" é clicado ou o <input> interno está em foco (incluindo autofocus).

Se você definir delegatesFocus: false, o resultado será o seguinte:

delegatesFocus: false e a entrada interna está em foco.
delegatesFocus: false e o <input> interno estão em foco.
delegatesFocus: false e x-focus
    recebe o foco (por exemplo, tem tabindex='0').
delegatesFocus: false e <x-focus> recebem foco (por exemplo, têm tabindex="0").
delegatesFocus: false e &quot;Texto do Shadow DOM clicável&quot; é
    clicado (ou outra área vazia no Shadow DOM do elemento é clicada).
delegatesFocus: false e "Texto do Shadow DOM clicável" são clicados (ou outra área vazia no Shadow DOM do elemento é clicada).

Dicas e sugestões

Ao longo dos anos, aprendi algumas coisas sobre como criar componentes da Web. Acho que algumas dessas dicas serão úteis para criar componentes e depurar o shadow DOM.

Usar contenção de CSS

Normalmente, o layout/estilo/pintura de um componente da Web é bastante independente. Use a contenção de CSS em :host para melhorar o desempenho:

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

Como redefinir estilos herdáveis

Os estilos herdáveis (background, color, font, line-height etc.) continuam a ser herdados no shadow DOM. Ou seja, eles atravessam o limite do shadow DOM por padrão. Se você quiser começar do zero, use all: initial; para redefinir os estilos herdáveis para o valor inicial quando eles cruzarem o limite de sombra.

<style>
    div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
    }
</style>

<div>
    <p>I'm outside the element (big/white)</p>
    <my-element>Light DOM content is also affected.</my-element>
    <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
    <style>
    :host {
        all: initial; /* 1st rule so subsequent properties are reset. */
        display: block;
        background: white;
    }
    </style>
    <p>my-element: all CSS properties are reset to their
        initial value using <code>all: initial</code>.</p>
    <slot></slot>
`;
</script>

Como encontrar todos os elementos personalizados usados por uma página

Às vezes, é útil encontrar elementos personalizados usados na página. Para fazer isso, é necessário percorrer recursivamente o DOM paralelo de todos os elementos usados na página.

const allCustomElements = [];

function isCustomElement(el) {
    const isAttr = el.getAttribute('is');
    // Check for <super-button> and <button is="super-button">.
    return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
    for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
        allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
        findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
    }
}

findAllCustomElements(document.querySelectorAll('*'));

Como criar elementos de um <template>

Em vez de preencher uma raiz de sombra usando .innerHTML, podemos usar um <template> declarativo. Os modelos são um marcador de posição ideal para declarar a estrutura de um componente da Web.

Confira o exemplo em "Elementos personalizados: como criar componentes da Web reutilizáveis".

Histórico e suporte do navegador

Se você acompanha os componentes da Web nos últimos anos, sabe que o Chrome 35+/Opera já vem com uma versão mais antiga do Shadow DOM há algum tempo. O Blink vai continuar oferecendo suporte às duas versões em paralelo por algum tempo. A especificação v0 fornecia um método diferente para criar uma raiz de sombra (element.createShadowRoot em vez de element.attachShadow da v1). Chamar o método mais antigo continua criando uma raiz de sombra com a semântica v0, então o código v0 existente não será corrompido.

Se você tiver interesse na especificação v0 antiga, confira os artigos do html5rocks: 1, 2, 3. Há também uma ótima comparação das diferenças entre o DOM de sombra v0 e v1.

Suporte ao navegador

O Shadow DOM v1 é fornecido no Chrome 53 (status), no Opera 40, no Safari 10 e no Firefox 63. O Edge começou o desenvolvimento.

Para detectar o shadow DOM, verifique se attachShadow existe:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Polyfill

Até que o suporte do navegador esteja amplamente disponível, os polyfills shadydom e shadycss oferecem o recurso v1. O Shady DOM imita o escopo do DOM do Shadow DOM e os polyfills do shadycss, as propriedades personalizadas do CSS e o escopo de estilo que a API nativa oferece.

Instale os polyfills:

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

Use os polyfills:

function loadScript(src) {
    return new Promise(function(resolve, reject) {
    const script = document.createElement('script');
    script.async = true;
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
    loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
        // Polyfills loaded.
    });
} else {
    // Native shadow dom v1 support. Go to go!
}

Consulte https://github.com/webcomponents/shadycss#usage para ver instruções sobre como ajustar/definir o escopo dos seus estilos.

Conclusão

Pela primeira vez, temos uma primitiva de API que faz o escopo adequado de CSS, de DOM e tem composição verdadeira. Combinado com outras APIs de componentes da Web, como elementos personalizados, o shadow DOM oferece uma maneira de criar componentes verdadeiramente encapsulados sem hacks ou o uso de recursos antigos, como <iframe>s.

Não me entenda mal. O DOM paralelo é certamente uma fera complexa! Mas é uma fera que vale a pena conhecer. Dedique um tempo a ele. Aprenda e faça perguntas.

Leitura adicional

Perguntas frequentes

Posso usar o Shadow DOM v1 hoje?

Com um polyfill, sim. Consulte Suporte ao navegador.

Quais recursos de segurança o shadow DOM oferece?

O Shadow DOM não é um recurso de segurança. É uma ferramenta leve para definir o escopo do CSS e ocultar árvores do DOM no componente. Se você quiser um limite de segurança verdadeiro, use um <iframe>.

Um componente da Web precisa usar o shadow DOM?

Não. Não é necessário criar componentes da Web que usem o shadow DOM. No entanto, criar elementos personalizados que usam o Shadow DOM significa que você pode aproveitar recursos como escopo de CSS, encapsulamento de DOM e composição.

Qual é a diferença entre shadow roots abertas e fechadas?

Consulte Raízes de sombra fechadas.