Shadow DOM v1: Componentes web independientes

Shadow DOM permite que los desarrolladores web creen DOM y CSS compartimentados para componentes web

Resumen

Shadow DOM elimina la fragilidad en la compilación de aplicaciones web. La fragilidad es parte de la naturaleza global de HTML, CSS y JS. A lo largo de los años, inventamos una exorbitante cantidad de tools para eludir los problemas. Por ejemplo, cuando usas un ID o una clase de HTML nuevos, no se sabe si entrará en conflicto con un nombre existente que usa la página. Después de que surjan errores sutiles, la especificidad de CSS se convierte en un gran problema (!important todo), los selectores de estilo se descontrolan y el rendimiento puede verse afectado. La lista continúa.

Shadow DOM corrige CSS y DOM. Presenta estilos con alcance en la plataforma web. Sin herramientas ni convenciones de nombres, puedes empaquetar CSS con lenguaje de marcado, ocultar los detalles de implementación y crear componentes independientes en JavaScript normal.

Introducción

Shadow DOM es uno de los tres estándares de componentes web: plantillas HTML, Shadow DOM y elementos personalizados. Las importaciones de HTML formaban parte de la lista, pero ahora se consideran obsoletas.

No es necesario crear componentes web que usen shadow DOM. Sin embargo, cuando lo hagas, podrás aprovechar sus beneficios (alcance de CSS, encapsulamiento DOM y composición) y compilar elementos personalizados reutilizables, que son resilientes, altamente configurables y extremadamente reutilizables. Si los elementos personalizados son la manera de crear un nuevo HTML (con una API de JS), shadow DOM es la forma de proporcionar su HTML y CSS. Las dos APIs se combinan para crear un componente con HTML, CSS y JavaScript independientes.

Shadow DOM está diseñado como una herramienta para crear apps basadas en componentes. Por lo tanto, brinda soluciones para los problemas comunes del desarrollo web:

  • DOM aislado: El DOM de un componente es independiente (p.ej., document.querySelector() no mostrará nodos en el shadow DOM del componente).
  • CSS con alcance: El CSS definido dentro del shadow DOM está dentro del alcance. Las reglas de estilo no se filtran y los diseños de página no se filtran.
  • Composición: Diseña una API declarativa basada en lenguaje de marcado para tu componente.
  • CSS simplificado: DOM con alcance significa que puedes usar selectores CSS simples, nombres de clase o ID más genéricos, sin preocuparte por conflictos de nomenclatura.
  • Productividad: Piensa en apps como fragmentos del DOM en lugar de una página grande (global).

Demostración de fancy-tabs

En este artículo, haré referencia a un componente de demostración (<fancy-tabs>) y a sus fragmentos de código. Si tu navegador es compatible con las APIs, deberías ver una demostración en vivo a continuación. De lo contrario, consulta la fuente completa en GitHub.

Ver el código fuente en GitHub

¿Qué es shadow DOM?

Antecedentes de DOM

El HTML alimenta toda la Web porque es fácil de usar. Declarando algunas etiquetas, puedes crear una página en segundos que tenga presentación y estructura. Sin embargo, el HTML por sí solo no es tan útil. Es fácil para las personas entender un lenguaje de texto, pero las máquinas necesitan algo más. Introduce el modelo de objeto del documento, o DOM.

Cuando el navegador carga una página web, realiza una gran cantidad de acciones interesantes. Una de las cosas que hace es transformar el HTML del autor en un documento activo. Básicamente, para comprender la estructura de la página, el navegador analiza HTML (cadenas de texto estáticas) en un modelo de datos (objetos/nodos). Para preservar la jerarquía de HTML, el navegador crea un árbol de estos nodos: el DOM. Lo bueno del DOM es que es una representación activa de tu página. A diferencia del código HTML estático que creamos, los nodos producidos por el navegador contienen propiedades y métodos. Lo mejor de todo es que los programas pueden manipularlos. Por eso, podemos crear elementos del DOM directamente con JavaScript:

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

produce el siguiente lenguaje de marcado HTML:

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

Todo eso está muy bien. Entonces, ¿qué diablos es shadow DOM?

DOM... en las sombras

Un Shadow DOM es solo un DOM normal, pero con dos diferencias: 1) cómo se crea/usa y 2) cómo se comporta en relación con el resto de la página. Por lo general, creas nodos del DOM y los agregas como secundarios de otro elemento. Con shadow DOM, puedes crear un árbol del DOM con alcance que se adjunta al elemento, pero separado de sus elementos secundarios reales. Este subárbol con alcance se denomina shadow tree (árbol en las sombras). El elemento al que se adjunta es su shadow host (host en las sombras). Todo lo que agregues en las sombras se volverá local para el elemento de hosting, incluido <style>. Así es como shadow DOM logra el alcance de estilo de CSS.

Cómo crear un shadow DOM

Una shadow root es un fragmento de documento que se adjunta a un elemento "host". Al adjuntar una shadow root, el elemento obtiene su shadow DOM. Si deseas crear un shadow DOM para un elemento, llama a 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

Estoy usando .innerHTML para completar el shadow root, pero también puedes usar otras APIs de DOM. Esta es la Web. Tenemos opciones.

La especificación define una lista de elementos que no pueden alojar un shadow tree. Existen varios motivos por los que un elemento puede estar en la lista:

  • El navegador ya aloja su propio shadow DOM interno para el elemento (<textarea>, <input>).
  • No tiene sentido que el elemento aloje un shadow DOM (<img>).

Por ejemplo, esto no funciona:

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

Cómo crear un shadow DOM para un elemento personalizado

Shadow DOM es particularmente útil para crear elementos personalizados. Usa shadow DOM para compartimentar el HTML, CSS y JS de un elemento, lo que produce un "componente web".

Ejemplo: Un elemento personalizado adjunta shadow DOM a sí mismo y encapsula su 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>
    `;
    }
    ...
});

Hay un par de cosas interesantes sucediendo aquí. La primera es que el elemento personalizado crea su propio shadow DOM cuando se crea una instancia de <fancy-tabs>. Esto se hace en constructor(). En segundo lugar, debido a que creamos una shadow root, las reglas de CSS dentro de <style> tendrán el alcance <fancy-tabs>.

Composición y ranuras

La composición es una de las funciones menos comprendidas de shadow DOM, pero podría decirse que es la más importante.

En nuestro mundo de desarrollo web, la composición es la forma en que construimos apps, de manera declarativa, a partir de HTML. Se unen diferentes componentes básicos (<div>, <header>, <form> y <input>) para formar apps. Algunas de estas etiquetas incluso funcionan en conjunto. La composición es la razón por la que los elementos nativos como <select>, <details>, <form> y <video> son tan flexibles. Cada una de esas etiquetas acepta ciertos HTML como secundarios y hace algo especial con ellos. Por ejemplo, <select> sabe cómo renderizar <option> y <optgroup> en widgets de menú desplegable y de selección múltiple. El elemento <details> renderiza <summary> como una flecha expandible. Incluso <video> sabe cómo lidiar con ciertos elementos secundarios: los elementos <source> no se renderizan, pero sí afectan el comportamiento del video. ¡Qué magia!

Terminología: Light DOM frente a shadow DOM

La composición de Shadow DOM introduce varios aspectos básicos nuevos en el desarrollo web. Antes de abrumarnos, estandaricemos algo de terminología para que hablemos en la misma jerga.

DOM claro

El lenguaje de marcado que escribe un usuario de tu componente. Este DOM reside fuera del shadow DOM del componente. Son los elementos secundarios reales del 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

Es el DOM que escribe el autor del componente. Shadow DOM es local del componente y define su estructura interna, CSS con alcance, y encapsula los detalles de implementación. También puede definir cómo renderizar el lenguaje de marcado que creó el consumidor de tu componente.

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

Árbol del DOM plano

Es el resultado de que el navegador distribuya el Light DOM del usuario en tu shadow DOM y renderice el producto final. El árbol plano es lo que en última instancia ves en las Herramientas para desarrolladores y lo que se renderiza en la 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>

El elemento <slot>

El Shadow DOM compone distintos árboles del DOM juntos mediante el elemento <slot>. Los espacios son marcadores de posición dentro de tu componente que los usuarios pueden llenar con su propio lenguaje de marcado. Cuando defines uno o más espacios, permites que el lenguaje de marcado externo se renderice en el shadow DOM de tu componente. En esencia, dices “Renderiza el lenguaje de marcado del usuario aquí”.

Los elementos pueden "cruzar" el límite del shadow DOM cuando un <slot> los invita. Estos elementos se denominan nodos distribuidos. Conceptualmente, los nodos distribuidos pueden parecer un poco extraños. Los espacios no mueven físicamente el DOM, sino que lo renderizan en otra ubicación dentro del shadow DOM.

Un componente puede definir cero o más ranuras en su shadow DOM. Las ranuras pueden estar vacías o proporcionar contenido de resguardo. Si el usuario no proporciona contenido de light DOM, el espacio renderiza su contenido de resguardo.

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

También puedes crear ranuras con nombre. Los espacios con nombre son agujeros específicos en tu shadow DOM a los que los usuarios hacen referencia por su nombre.

Ejemplo: Los espacios en el 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>

Los usuarios del componente declaran <fancy-tabs> de la siguiente manera:

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

Y si te lo preguntas, el árbol plano luce así:

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

Ten en cuenta que nuestro componente puede controlar diferentes configuraciones, pero el árbol del DOM acoplado sigue siendo el mismo. También podemos cambiar de <button> a <h2>. Este componente se creó para controlar diferentes tipos de elementos secundarios, al igual que <select>.

Diseño

Hay muchas opciones de estilo para los componentes web. Un componente que usa shadow DOM puede recibir un estilo de la página principal, definir sus propios estilos o proporcionar enlaces (en forma de propiedades personalizadas de CSS) para que los usuarios anulen los valores predeterminados.

Estilos definidos por los componentes

Sin lugar a dudas, la función más útil de shadow DOM es el CSS específico:

  • Los selectores CSS de la página exterior no se aplican dentro del componente.
  • Los estilos definidos en el interior no se desvanecen. Tienen alcance en el elemento host.

Los selectores CSS que se usan dentro de un shadow DOM se aplican a tu componente de forma local. En la práctica, esto significa que podemos volver a usar nombres de ID o de clase comunes, sin preocuparnos por los conflictos en otra parte de la página. Los selectores de CSS más simples son una práctica recomendada dentro del Shadow DOM. También ayudan a mejorar el rendimiento.

Ejemplo: Los estilos definidos en una shadow root son locales.

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

Las hojas de estilo también tienen un alcance en el shadow tree:

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

¿Alguna vez te preguntaste cómo el elemento <select> renderiza un widget de selección múltiple (en lugar de un menú desplegable) cuando agregas el atributo multiple:

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

<select> puede diseñarse a sí misma de manera diferente según los atributos que declares. Los componentes web también pueden diseñarse a sí mismos con el selector :host.

Ejemplo: un componente que define su propio estilo

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

Un problema con :host es que las reglas de la página principal tienen mayor especificidad que las reglas de :host definidas en el elemento. Es decir, los estilos exteriores tienen prioridad. Esto permite a los usuarios anular tu diseño de nivel superior desde el exterior. Además, :host solo funciona en el contexto de una shadow root, por lo que no puedes usarlo fuera de shadow DOM.

El formato funcional de :host(<selector>) te permite orientar el host si coincide con un <selector>. Esta es una excelente manera de que tu componente encapsule los comportamientos que reaccionan a la interacción del usuario o a los estados o los diseños de nodos internos basados en el 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 basado en el contexto

:host-context(<selector>) coincide con el componente si este o cualquiera de sus principales coinciden con <selector>. Un uso común de esto es la creación de temas según el entorno de un componente. Por ejemplo, muchas personas aplican temas aplicando una clase a <html> o <body>:

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

:host-context(.darktheme) definirá el diseño de <fancy-tabs> cuando sea descendiente de .darktheme:

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

:host-context() puede ser útil para aplicar temas, pero un enfoque aún mejor es crear hooks de estilo con las propiedades personalizadas de CSS.

Cómo aplicar diseño a nodos distribuidos

::slotted(<compound-selector>) coincide con los nodos que se distribuyen en un <slot>.

Supongamos que creamos un componente de insignia:

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

El shadow DOM del componente puede definir el estilo del <h2> y la .title del usuario:

<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 ya lo vimos, los objetos <slot> no mueven el Light DOM del usuario. Cuando los nodos se distribuyen en un <slot>, el <slot> renderiza su DOM, pero los nodos permanecen en su lugar. Los estilos que se aplicaron antes de la distribución se siguen aplicando después de esta. Sin embargo, cuando se distribuye el Light DOM, puede adoptar diseños adicionales (los definidos por el shadow DOM).

Otro ejemplo más detallado 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>
`;

En este ejemplo, hay dos espacios: uno con nombre para los títulos de las pestañas y otro para el contenido del panel de pestañas. Cuando el usuario selecciona una pestaña, ponemos en negrita su selección y revelamos su panel. Para ello, se seleccionan nodos distribuidos que tengan el atributo selected. El JS del elemento personalizado (que no se muestra aquí) agrega ese atributo en el momento correcto.

Cómo aplicar diseño a un componente desde el exterior

Hay varias maneras de diseñar un componente desde el exterior. La forma más fácil es usar el nombre de la etiqueta como selector:

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

Los estilos externos son más importantes que los estilos definidos en el shadow DOM. Por ejemplo, si el usuario escribe el selector fancy-tabs { width: 500px; }, tendrá prioridad sobre la regla del componente: :host { width: 650px;}.

No será suficiente con definir el estilo del componente. Pero, ¿qué sucede si deseas dar estilo a las partes internas de un componente? Para eso, necesitamos propiedades personalizadas de CSS.

Cómo crear hooks de estilo con propiedades personalizadas de CSS

Los usuarios pueden modificar los diseños internos si el autor del componente proporciona hooks de estilo con las propiedades personalizadas de CSS. Conceptualmente, la idea es similar a <slot>. Se crean "marcadores de posición de estilo" que los usuarios pueden anular.

Ejemplo: <fancy-tabs> permite que los usuarios anulen el color de fondo:

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

Dentro de su shadow DOM:

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

En este caso, el componente usará black como valor en segundo plano, ya que el usuario lo proporcionó. De lo contrario, el valor predeterminado sería #9E9E9E.

Temas avanzados

Cómo crear shadow root cerradas (debe evitarse)

Existe otra variante de shadow DOM llamada modo "cerrado". Cuando creas un shadow tree cerrado, el JavaScript externo no podrá acceder al DOM interno de tu componente. Esto es similar al funcionamiento de los elementos nativos, como <video>. JavaScript no puede acceder al shadow DOM de <video> porque el navegador lo implementa con una shadow root de modo cerrado.

Ejemplo (crea un shadow tree cerrado):

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

Otras APIs también se ven afectadas por el modo cerrado:

  • Element.assignedSlot / TextNode.assignedSlot muestra null
  • Event.composedPath() para eventos asociados con elementos dentro del shadow DOM, muestra []

Este es un resumen de por qué nunca deberías crear componentes web con {mode: 'closed'}:

  1. Sensación artificial de seguridad No hay nada que impida a un atacante usurpar Element.prototype.attachShadow.

  2. El modo cerrado evita que tu código de elemento personalizado acceda a su propio shadow DOM. Un error absoluto. En cambio, deberás almacenar una referencia para más adelante si quieres usar elementos como querySelector(). ¡Esto frustra por completo el propósito original del modo cerrado!

        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. El modo cerrado hace que el componente sea menos flexible para los usuarios finales. A medida que compiles componentes web, llegará un momento en el que te olvidarás de agregar una función. Una opción de configuración Un caso de uso que el usuario quiera. Un ejemplo común es olvidarse de incluir hooks de estilo adecuados para nodos internos. Con el modo cerrado, los usuarios no pueden anular los valores predeterminados y ajustar los estilos. Es muy útil poder acceder a la parte interna del componente. En última instancia, los usuarios bifurcarán tu componente, encontrarán otro o crearán uno propio si no hace lo que ellos quieren :(

Cómo trabajar con ranuras en JS

La API de shadow DOM proporciona utilidades para trabajar con ranuras y nodos distribuidos. Estos son útiles a la hora de crear un elemento personalizado.

evento de cambio de ranura

El evento slotchange se activa cuando cambian los nodos distribuidos de un espacio. Por ejemplo, si el usuario agrega elementos secundarios del Light DOM o los quita de este.

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

Para supervisar otros tipos de cambios en un Light DOM, puedes configurar un MutationObserver en el constructor de tu elemento.

¿Qué elementos se renderizan en un espacio?

A veces, es útil saber qué elementos están asociados con un espacio. Llama a slot.assignedNodes() para averiguar qué elementos renderiza el espacio. La opción {flatten: true} también mostrará el contenido de resguardo de una ranura (si no se distribuyen nodos).

A modo de ejemplo, supongamos que tu shadow DOM tiene el siguiente aspecto:

<slot><b>fallback content</b></slot>
UsoCallResultado
<my-component>texto del 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 qué ranura se asigna un elemento?

También es posible responder la pregunta inversa. element.assignedSlot te indica a qué ranuras de componentes está asignado tu elemento.

El modelo de eventos de Shadow DOM

Cuando un evento surge del shadow DOM, se ajusta su objetivo para mantener la encapsulación que proporciona este último. Es decir, se vuelve a segmentar los eventos para que parezca que provienen del componente y no de elementos internos de tu shadow DOM. Algunos eventos ni siquiera se propagan fuera del shadow DOM.

Los eventos que cruzan el límite de la sombra son los siguientes:

  • Eventos de enfoque: blur, focus, focusin, focusout
  • Eventos del mouse: click, dblclick, mousedown, mouseenter, mousemove, etcétera
  • Eventos de la rueda: wheel
  • Eventos de entrada: beforeinput, input
  • Eventos de teclado: keydown, keyup
  • Eventos de composición: compositionstart, compositionupdate, compositionend
  • DragEvent: dragstart, drag, dragend, drop, etcétera

Sugerencias

Si el shadow tree está abierto y llamas a event.composedPath(), se mostrará un array de nodos que recorrió el evento.

Utiliza eventos personalizados

Los eventos personalizados del DOM que se activan en nodos internos de un shadow tree no salen del límite de la sombra, a menos que el evento se cree con la marca 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}));
}

Si es composed: false (predeterminado), los consumidores no podrán escuchar el evento fuera de tu shadow root.

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

Cómo manejar el foco

Como recuerdas del modelo de eventos de shadow DOM, los eventos que se activan dentro del shadow DOM se ajustan para que parezca que provienen del elemento de hosting. Por ejemplo, supongamos que haces clic en un <input> dentro de una shadow root:

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

Parece que el evento focus provino de <x-focus>, no de <input>. Del mismo modo, document.activeElement será <x-focus>. Si la shadow root se creó con mode:'open' (consulta modo cerrado), también podrás acceder al nodo interno que obtuvo el enfoque:

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

Si hay varios niveles de shadow DOM en juego (por ejemplo, un elemento personalizado dentro de otro), debes desglosar de manera recursiva las shadow roots para encontrar el activeElement:

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

Otra opción de enfoque es delegatesFocus: true, que expande el comportamiento del enfoque de los elementos dentro de un shadow tree:

  • Si haces clic en un nodo dentro de shadow DOM y el nodo no es un área enfocable, se enfocará la primera área enfocable.
  • Cuando un nodo dentro de shadow DOM obtiene el foco, :focus se aplica al host además del elemento enfocado.

Ejemplo: Cómo cambia delegatesFocus: true el comportamiento del enfoque

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

Resultado

delegatesFocus: Comportamiento real.

Arriba se muestra el resultado cuando se enfoca <x-focus> (clic del usuario, pestaña, focus(), etcétera). Se hace clic en "Texto del Shadow DOM en el que se puede hacer clic", o bien el objeto <input> interno se enfoca (incluido autofocus).

Si configuraras delegatesFocus: false, esto es lo que verías en su lugar:

delegatesFocus: Falso y la entrada interna está enfocada.
delegatesFocus: false y el <input> interno están enfocados.
delegatesFocus: El valor falso y el enfoque X aumentan el enfoque (p.ej., tiene tabindex=&#39;0&#39;).
delegatesFocus: false y <x-focus> ganan enfoque (p.ej., tienen tabindex="0").
DeegatesFocus: Se hace clic en false y en el texto del Shadow DOM en el que se puede hacer clic (o se hace clic en otra área vacía dentro del shadow DOM del elemento).
Se hace clic en delegatesFocus: false y en "Texto del Shadow DOM en el que se puede hacer clic" (o en otra área vacía dentro del shadow DOM del elemento).

Sugerencias

A lo largo de los años, he aprendido algunas cosas sobre la creación de componentes web. Creo que algunas de estas sugerencias te resultarán útiles para crear componentes y depurar shadow DOM.

Usar la contención de CSS

Por lo general, el diseño, el estilo o la pintura de un componente web son bastante independientes. Usa la contención de CSS en :host para obtener un rendimiento ganador:

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

Cómo restablecer los diseños heredables

Los diseños heredables (background, color, font, line-height, etc.) continúan heredando en shadow DOM. Es decir, perforan el límite del shadow DOM de forma predeterminada. Si quieres comenzar con una pizarra nueva, usa all: initial; para restablecer los diseños heredables a su valor inicial cuando crucen el límite de la 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>

Cómo encontrar todos los elementos personalizados que usa una página

A veces, es útil encontrar los elementos personalizados que se usan en la página. Para hacerlo, debes recorrer de manera recursiva el shadow DOM de todos los elementos que se usan en la 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('*'));

Crea elementos a partir de una <template>

En lugar de propagar una shadow root con .innerHTML, podemos usar un <template> declarativo. Las plantillas son marcadores de posición ideales para declarar la estructura de un componente web.

Consulta el ejemplo en “Elementos personalizados: compila componentes web reutilizables”.

Historial y navegador compatibles

Si has estado al tanto de los componentes web durante los últimos años, sabrás que Chrome 35 y versiones posteriores, Opera, han estado enviando una versión anterior de shadow DOM durante un tiempo. Blink seguirá admitiendo ambas versiones en paralelo durante un tiempo. Las especificaciones de v0 proporcionaron un método diferente para crear una shadow root (element.createShadowRoot en lugar del element.attachShadow de v1). Llamar al método más antiguo sigue creando una shadow root con semántica de v0, por lo que el código v0 existente no se romperá.

Si te interesa la especificación de la versión 0 anterior, consulta los siguientes artículos de html5rocks: 1, 2 y 3. También encontrarás una excelente comparación de las diferencias entre shadow DOM v0 y v1.

Navegadores compatibles

Shadow DOM v1 se distribuye en Chrome 53 (estado), Opera 40, Safari 10 y Firefox 63. Edge comenzó el desarrollo.

Para detectar shadow DOM por medio de funciones, comprueba la existencia de attachShadow:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Polyfill

Hasta que haya compatibilidad general con los navegadores, los polyfills shadydom y shadycss te brindarán la función v1. Shady DOM imita el alcance del DOM de Shadow DOM y las propiedades personalizadas de CSS de polyfills shadycss y el alcance de estilo que proporciona la API nativa.

Instala los polyfills:

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

Usa los 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!
}

Consulta https://github.com/webcomponents/shadycss#usage para obtener instrucciones sobre cómo corregir la corrección de compatibilidad/alcance de tus estilos.

Conclusión

Por primera vez, tenemos una primitiva de API que limita el alcance de CSS y del DOM de forma correcta, y tiene una composición verdadera. En combinación con otras APIs de componentes web, como elementos personalizados, shadow DOM ofrece una forma de crear componentes verdaderamente encapsulados sin modificaciones ni elementos antiguos, como los <iframe>.

No me malinterpretes. Sin duda, el Shadow DOM es complejo. Pero vale la pena aprender sobre estas bestias. Dedícale tiempo. Aprende y haz preguntas.

Lecturas adicionales

Preguntas frecuentes

¿Puedo usar Shadow DOM v1 en la actualidad?

Sí, con un polyfill. Consulta Compatibilidad con los navegadores.

¿Qué funciones de seguridad proporciona shadow DOM?

Shadow DOM no es una función de seguridad. Es una herramienta liviana para determinar el alcance de CSS y ocultar árboles del DOM en componentes. Si quieres un límite de seguridad verdadero, usa un <iframe>.

¿Un componente web debe usar shadow DOM?

De ninguna manera. No es necesario crear componentes web que usen shadow DOM. Sin embargo, crear elementos personalizados que usen Shadow DOM te permite aprovechar funciones como el alcance de CSS, el encapsulamiento del DOM y la composición.

¿Cuál es la diferencia entre las shadow root abiertas y cerradas?

Consulta Shadow roots cerrados.