Search code examples
htmlweb-componentshadow-domcustom-elementhtml-templates

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.


Solution

  • 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