Shadow DOM v1: componentes da Web independentes

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

Resumo

O shadow DOM elimina 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 usado pela página. Bugs sutis aparecem, a especificidade do CSS se torna um grande problema (!important tudo!), os seletores de estilo ficam fora de controle e o desempenho pode sofrer. A lista é longa.

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 criar um pacote de CSS com markup, ocultar detalhes de implementação e criar componentes autárquicos no JavaScript vanilla.

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 HTML faziam parte da lista, mas agora são consideradas descontinuadas.

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

O shadow DOM foi criado 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 no DOM sombra tem escopo para ele. As regras de estilo não vazam e os estilos da página não se sobrepõem.
  • Composição: projete uma API declarativa baseada em marcação para seu componente.
  • Simplifica o CSS: o DOM com escopo permite usar seletores CSS simples, nomes de ID/classe mais genéricos e não se preocupar com conflitos de nomenclatura.
  • Produtividade: pense em apps em partes do DOM, em vez de uma página grande (global).

fancy-tabs demonstração

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

Confira o código-fonte no GitHub

O que é shadow DOM?

Informações sobre o DOM

O HTML é a tecnologia que alimenta a Web porque é fácil de trabalhar com ele. Ao declarar algumas tags, você pode criar uma página em segundos com apresentação e estrutura. No entanto, o HTML por si só não é tão útil. É fácil para as pessoas entenderem uma linguagem baseada em texto, mas as máquinas precisam de algo mais. Entre no 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 em tempo real. Basicamente, para entender a estrutura da página, o navegador analisa HTML (strings estáticas de texto) 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 que 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);

gera a seguinte marcação HTML:

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

Tudo bem. Então, o que é o shadow DOM?

DOM… nas sombras

O Shadow DOM é apenas 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 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. Esse subárvore com escopo é chamada de árvore sombra. O elemento a que 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 shadow DOM alcança o escopo do estilo CSS.

Como criar um shadow DOM

Uma raiz de sombra é um fragmento de documento que é anexado a um elemento "host". O ato de anexar uma raiz shadow é 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. Essa é a Web. Temos escolha.

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 que o elemento hospede um shadow DOM (<img>).

Por exemplo, esta abordagem 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 é especialmente útil ao criar elementos personalizados. Use o shadow DOM para compartimentar o HTML, o CSS e o JS de um elemento, criando 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 seu 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 CSS dentro do <style> serão aplicadas a <fancy-tabs>.

Composição e slots

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

No mundo do desenvolvimento da Web, a composição é como construímos apps, de forma declarativa, fora do HTML. Elementos básicos diferentes (<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 certos HTML como filhos e faz algo especial com eles. Por exemplo, <select> sabe renderizar <option> e <optgroup> em widgets de lista suspensa e multiseleção. O elemento <details> renderiza <summary> como uma seta expansível. Até mesmo o <video> sabe como lidar com alguns filhos: os elementos <source> não são renderizados, mas afetam o comportamento do vídeo. Que magia!

Terminologia: light DOM x shadow DOM

A composição do shadow DOM apresenta vários novos fundamentos no desenvolvimento da Web. Antes de entrar em detalhes, vamos padronizar alguns termos para que possamos falar a mesma linguagem.

DOM leve

A marcação que um usuário do seu componente grava. Esse DOM fica fora do DOM shadow do componente. Ele é o filho real 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 um autor de 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 DOM simplificada

O resultado do navegador distribuindo o light DOM do usuário no shadow DOM, 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 no componente que os usuários podem preencher com sua própria marcação. Ao definir um ou mais slots, você convida a marcação externa a renderizar no DOM de sombra do componente. Basicamente, você está dizendo "Renderize o elemento do usuário aqui".

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

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

<!-- 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 buracos específicos no DOM sombra que os usuários referenciam por 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> assim:

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

A árvore achatada fica 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>

Nosso componente é capaz de processar diferentes configurações, mas a árvore DOM simplificada permanece a mesma. Também podemos mudar de <button> para <h2>. Esse componente foi criado para processar diferentes tipos de filhos, assim como <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 ganchos (na forma de propriedades personalizadas de CSS) para que os usuários substituam os padrões.

Estilos definidos pelo componente

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

  • Os seletores de CSS da página externa não são aplicados no componente.
  • Os estilos definidos não são exibidos. Elas são aplicadas ao elemento original.

Os seletores de CSS usados no shadow DOM são aplicados localmente ao componente. Na prática, isso significa que podemos usar nomes de ID/classe comuns novamente, sem nos preocuparmos com conflitos em outros lugares da página. Seletores de CSS mais simples são uma prática recomendada no Shadow DOM. Elas também são boas para a performance.

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 na á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 aplicar estilos de forma diferente com base nos atributos que você declara nele. Os componentes da Web também podem estilizar a si mesmos usando o seletor :host.

Exemplo: um componente com estilo

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

Um problema com :host é que as regras na página mãe 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á-la 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 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>) corresponde ao componente se ele ou qualquer um dos ancestrais corresponder a <selector>. Um uso comum para isso é a aplicação de temas com base no contexto de um componente. Por exemplo, muitas pessoas aplicam uma classe a <html> ou <body>:

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

:host-context(.darktheme) estilizaria <fancy-tabs> quando 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 do CSS.

Como definir estilos em nós distribuídos

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

Digamos que criamos um componente de selo de nome:

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

O DOM de sombra do componente pode estilizar o <h2> e o .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>

Se você se lembra, <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 no lugar. Os estilos aplicados antes da distribuição continuam a ser aplicados depois da distribuição. No entanto, quando o light DOM é distribuído, ele pode assumir outros estilos (definidos pelo shadow DOM).

Outro exemplo mais detalhado de <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 nomeado para os títulos de guia e outro para o conteúdo do painel de guias. Quando o usuário seleciona uma guia, destacamos a seleção e revelamos o painel dela. Isso é feito selecionando nós distribuídos que têm o atributo selected. O JS do elemento personalizado (não mostrado aqui) adiciona esse atributo no momento certo.

Aplicar estilo a um componente de fora

Há algumas maneiras de estilizar 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 vencem 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;}.

Estilos no componente só vão ajudar até certo ponto. Mas o que acontece se você quiser estilizar os elementos internos de um componente? Para isso, precisamos de propriedades personalizadas do CSS.

Criar ganchos de estilo usando propriedades personalizadas do CSS

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

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

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

Dentro do 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.

Temas avançados

Criação de raízes de sombra fechadas (evite)

Há outro tipo de shadow DOM chamado "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 shadow de <video> porque o navegador o implementa usando uma raiz shadow no 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 da sombra, retorna []

Confira um resumo de por que você nunca deve criar componentes da Web com {mode: 'closed'}:

  1. Sensação artificial de segurança. Não há nada que impeça um invasor de sequestrar o Element.prototype.attachShadow.

  2. O modo fechado impede que o código do elemento personalizado acesse o próprio DOM sombra. Isso é um fracasso total. Em vez disso, você terá que armazenar uma referência para mais tarde se quiser usar elementos como querySelector(). Isso vai contra o objetivo 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, você pode esquecer 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. No modo fechado, não há como os usuários substituir os padrões e ajustar os estilos. Ter acesso aos componentes internos é muito útil. No final, 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 DOM sombra oferece 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 é acionado 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 encontrar quais elementos o slot está renderizando. A opção {flatten: true} também vai retornar o conteúdo padrão de um slot (se nenhum nó estiver sendo distribuído).

Por exemplo, digamos que o DOM sombra tenha esta aparência:

<slot><b>fallback content</b></slot>
UsoLigarResultado
<my-component>texto do componente</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 que slot um elemento é atribuído?

Também é possível responder à pergunta inversa. element.assignedSlot informa a quais dos slots de componentes o elemento está atribuído.

Modelo de evento do DOM paralelo

Quando um evento é transmitido do shadow DOM, o alvo dele é ajustado para manter o encapsulamento fornecido pelo shadow DOM. Ou seja, os eventos são redirecionados para parecerem que vêm do componente, em vez de elementos internos no DOM sombra. Alguns eventos nem mesmo se propagam fora do DOM de sombra.

Os eventos que cruzam a fronteira da sombra são:

  • Eventos de foco: blur, focus, focusin, focusout
  • Eventos do mouse: click, dblclick, mousedown, mouseenter, mousemove etc.
  • Eventos da 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, a chamada de event.composedPath() vai retornar uma matriz de nós por onde o evento passou.

Usar eventos personalizados

Eventos DOM personalizados que são acionados em nós internos em uma árvore shadow não saem do limite da shadow, 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 da 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 DOM shadow, os eventos disparados dentro do DOM shadow 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 for criada com mode:'open' (consulte modo fechado), você também poderá acessar o nó interno que ganhou 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 recursivamente as raízes shadow 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 de foco é a delegatesFocus: true, que expande o comportamento de foco dos elementos em uma árvore de sombra:

  • Se você clicar em um nó dentro do DOM de sombra 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 é aplicado ao host, além do elemento em foco.

Exemplo: como delegatesFocus: true muda o comportamento de 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: comportamento verdadeiro.

Acima está o resultado quando <x-focus> está focado (clique do usuário, focus(), etc.). O "texto do shadow DOM clicável" é clicado ou o <input> interno é focado (incluindo autofocus).

Se você definisse delegatesFocus: false, a seguinte mensagem seria exibida:

delegatesFocus: false e a entrada interna está em foco.
delegatesFocus: false e o <input> interno está focado.
delegatesFocus: false e x-focus
    ganham o foco (por exemplo, tabindex=&#39;0&#39;).
delegatesFocus: false e <x-focus> ganham foco (por exemplo, têm tabindex="0").
delegatesFocus: false e o &quot;texto do DOM de shadow clicável&quot; é
    clicado (ou outra área vazia dentro do DOM de shadow do elemento é clicada).
delegatesFocus: false e "Texto do shadow DOM clicável" são clicados (ou outra área vazia dentro do shadow DOM do elemento é clicada).

Dicas e sugestões

Ao longo dos anos, aprendi algumas coisas sobre a criação de componentes da Web. Acredito 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 do 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 herdados (background, color, font, line-height etc.) continuam a ser herdados no shadow DOM. Ou seja, eles perfuram a fronteira do shadow DOM por padrão. Se você quiser começar do zero, use all: initial; para redefinir os estilos herdados para o valor inicial quando eles cruzarem o limite da 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 sombra 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('*'));

Criar elementos usando 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.

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

Histórico e suporte a navegadores

Se você acompanha os componentes da Web há alguns anos, sabe que o Chrome 35+ e o Opera estão enviando uma versão mais antiga do DOM há algum tempo. O Blink vai continuar oferecendo suporte às duas versões em paralelo por algum tempo. A especificação v0 forneceu um método diferente para criar uma raiz sombra (element.createShadowRoot em vez de element.attachShadow da v1). Chamar o método mais antigo continua criando uma raiz sombra com a semântica da v0, para que o código v0 atual não seja interrompido.

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

Suporte ao navegador

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

Para detectar o DOM paralelo, verifique a existência de attachShadow:

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 shadycss propriedades CSS personalizadas e o escopo de estilo fornecido pela API nativa.

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 instruções sobre como aplicar shim/escopo aos seus estilos.

Conclusão

Pela primeira vez, temos uma API primitiva que faz o escopo adequado do CSS e do DOM e tem composição real. Combinado com outras APIs de componentes da Web, como elementos personalizados, o Shadow DOM oferece uma maneira de criar componentes encapsulados de verdade sem hacks ou usando bagagem mais antiga, como <iframe>s.

Não me interpretem mal. O shadow DOM é certamente uma fera complexa. Mas vale a pena aprender. Passe algum tempo com ele. Aprenda e faça perguntas.

Leitura adicional

Perguntas frequentes

Posso usar o Shadow DOM v1 hoje?

Com um polyfill, sim. Consulte Suporte a navegadores.

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 DOM no componente. Se você quiser um limite de segurança real, 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 raízes de sombra abertas e fechadas?

Consulte Raízes de sombra fechadas.