Sebastian Zartner
Sebastian Zartner

Reputation: 20105

Is it possible to programmatically slot elements in web components?

Is it possible to automatically or programmatically slot nested web components or elements of a specific type without having to specify the slot attribute on them?

Consider some structure like this:

<parent-element>
  <child-element>Child 1</child-element>
  <child-element>Child 2</child-element>

  <p>Content</p>
</parent-element>

With the <parent-element> having a Shadow DOM like this:

<div id="child-elements">
  <slot name="child-elements">
    <child-element>Default child</child-element>
  </slot>
</div>
<div id="content">
  <slot></slot>
</div>

The expected result is:

<parent-element>
  <#shadow-root>
    <div id="child-elements">
      <slot name="child-elements">
        <child-element>Child 1</child-element>
        <child-element>Child 2</child-element>
      </slot>
    </div>
    <div id="content">
      <slot>
        <p>Content</p>
      </slot>
    </div>
</parent-element>

In words, I want to enforce that <child-element>s are only allowed within a <parent-element> similar to <td> elements only being allowed within a <tr> element. And I want them to be placed within the <slot name="child-elements"> element. Having to specify a slot attribute on each of them to place them within a specific slot of the <parent-element> seems redundant. At the same time, the rest of the content within the <parent-element> should automatically be slotted into the second <slot> element.

I've first searched for a way to define this when registering the parent element, though CustomElementRegistry.define() currently only supports extends as option.

Then I thought, maybe there's a function allowing to slot the elements manually, i.e. something like childElement.slot('child-elements'), but that doesn't seem to exist.

I've then tried to achive this programmatically in the constructor of the parent element like this:

constructor() {
  super();

  this.attachShadow({mode: 'open'});
  this.shadowRoot.appendChild(template.content.cloneNode(true));

  const childElements = this.getElementsByTagName('child-element');
  const childElementSlot = this.shadowRoot.querySelector('[name="child-elements"]');
  for (let i = 0; i < childElements.length; i++) {
    childElementSlot.appendChild(childElements[i]);
  }
}

Though this doesn't move the child elements to the <slot name="child-elements">, so all of them still get slotted in the second <slot> element.

Upvotes: 15

Views: 7088

Answers (2)

Lea Verou
Lea Verou

Reputation: 23887

As of recently, yes, you can, by using the assign() method of slot elements. Sadly, Safari doesn't support it yet, but there is a polyfill.

Upvotes: 1

Your unnamed default <slot></slot> will capture all elements not assigned to a named slot;
so a slotchange Event can capture those and force child-element into the correct slot:

customElements.define('parent-element', class extends HTMLElement {
    constructor() {
      super().attachShadow({mode:'open'})
             .append(document.getElementById(this.nodeName).content.cloneNode(true));
      this.shadowRoot.addEventListener("slotchange", (evt) => {
        if (evt.target.name == "") {// <slot></slot> captures
          [...evt.target.assignedElements()]
            .filter(el => el.nodeName == 'CHILD-ELEMENT') //process child-elements
            .map(el => el.slot = "child-elements"); // force them to their own slot
        } else console.log(`SLOT: ${evt.target.name} got:`,evt.target.assignedNodes())
      })}});
customElements.define('child-element', class extends HTMLElement {
    connectedCallback(parent = this.closest("parent-element")) {
      // or check and force slot name here
      if (this.parentNode != parent) {
        if (parent) parent.append(this); // Child 3 !!!
        else console.error(this.innerHTML, "wants a PARENT-ELEMENT!");
      }}});
child-element { color: red; display: block; } /* style lightDOM in global CSS! */
<template id=PARENT-ELEMENT>
  <style>
    :host { display: inline-block; border: 2px solid red; }
    ::slotted(child-element) { background: lightgreen }
    div { border:3px dashed rebeccapurple }
  </style>
  <div><slot name=child-elements></slot></div>
  <slot></slot>
</template>

<parent-element>
  <child-element>Child 1</child-element>
  <child-element>Child 2</child-element>
  <b>Content</b>
  <div><child-element>Child 3 !!!</child-element></div>
</parent-element>
<child-element>Child 4 !!!</child-element>

Note the logic for processing <child-element> not being a direct child of <parent-element>, you probably want to rewrite this to your own needs

Upvotes: 9

Related Questions