12

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.

Sebastian Zartner
  • 18,808
  • 10
  • 90
  • 132
  • 1
    I think this has potential to be a good question but in its current form it's a little confusing what you're trying to achieve and what you've managed to achieve so far. Please can you edit / rewrite your question for clarity - I love WebComponents and I'd like to help you with this. – Rounin Dec 23 '20 at 11:14
  • @Rounin, think ``slotchange`` event for your answer... – Danny '365CSI' Engelman Dec 23 '20 at 11:40
  • 1
    @Rounin I've extended my answer to clarify what's the expected outcome and what I've tried so far. – Sebastian Zartner Dec 23 '20 at 11:52
  • @Danny'365CSI'Engelman As far as I can see, the `slotchange` event is only fired on the `` element once an element got added or removed from it, but I don't see yet how that helps to let me put them into the correct slot. – Sebastian Zartner Dec 23 '20 at 11:55
  • I presume you mean ``childEements[i]`` instead of ``tabs[i]``??? In which case you are no longer using ``SLOT`` technology, because you now **move** *lightDOM* content to *shadowDOM* – Danny '365CSI' Engelman Dec 23 '20 at 12:33
  • Oops! Of course I meant `childElements[i]`. I've generalized the code for SO. Well, as I wrote, I want to slot them though I didn't find a proper way so far besides moving them from Light DOM to Shadow DOM. – Sebastian Zartner Dec 23 '20 at 12:59

2 Answers2

9

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

Danny '365CSI' Engelman
  • 16,526
  • 2
  • 32
  • 49
  • That works perfectly. I was just wondering why you have to react to the `slotchange` event and can't proactively move them to ``, i.e. when they get created or upgraded or before they are slotted. – Sebastian Zartner Dec 23 '20 at 13:28
  • Yes, you can do without the slotchange Event and and put the logic on the child-element: if slot is **not** correct,set it – Danny '365CSI' Engelman Dec 23 '20 at 13:30
  • Also [`connectedCallback()` doesn't take parameters](https://html.spec.whatwg.org/multipage/custom-elements.html#upgrades), so `parent = this.closest("parent-element")` should be defined within the function body. – Sebastian Zartner Dec 23 '20 at 13:30
  • No, you can declare variables there.. this is valid JavaScript, we are not limited by TypeScript syntax.. If components should be small, that is a create place to prevent LET statements.. saves 4 bytes. See the svg() method in my [IconMeister](https://github.com/iconmeister/iconmeister.github.io/blob/master/elements.iconmeister.js) all "variables" are "parameters" – Danny '365CSI' Engelman Dec 23 '20 at 13:34
  • 2
    It _is_ valid JavaScript and it saves a few bytes on network transfer, yes, but what I meant is that the DOM specification says `connectedCallback()` is called without parameters. Placing it within the parameters list might let people reading your answer think that they can somehow change `parent`. So doing it may be good when you want to minimize your code but for clarity here on SO it's better to use a `const` statement here. – Sebastian Zartner Dec 23 '20 at 13:50
  • assigning `el.slot` doesn't seem to work if the node is Text... is there a way to re-assign the slot of text node? – Michael May 26 '22 at 21:31
  • @Michael, ask as question, I can't paste code in comments – Danny '365CSI' Engelman May 27 '22 at 09:05
1

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.

Lea Verou
  • 23,618
  • 9
  • 46
  • 48
  • I didn't try it out yet but it looks like that solves the issue. The downside of this approach, though, seems to be that you can't mix automatic slot assignment with manual assignment (yet). In my example, it would be best if I could define that all ``s are slotted manually but the rest automatically in the unnamed ``. – Sebastian Zartner Jul 18 '22 at 19:11