Search code examples
javascripthtmlweb-component

How to append slot children to HTML body tag for absolute positioning?


I am creating a dropdown menu web component that will be used by consumers like:

<custom-menu>
    <custom-menu-anchor>
        <button>Toggle Menu</button>
    </custom-menu-anchor>

    <custom-menu-item>Fish</custom-menu-item>
    <custom-menu-item>
        <custom-icon name="chicken"/>
        <span>Chicken</span>
    </custom-menu-item>
</custom-menu>

Here, the slot items like <custom-menu-items> need to be absolutely positioned.

To do this, we need to

  1. Create an overlay
  2. Remove slot elements from web component
  3. Attach them to the overlay element.
  4. Provide correct positioning

To setup a perfect overlay, I need to create an overlay/surface element and remove the custom-menu-item children and append them all to the overlay element.

To achieve this, I attempted something like below in connectedCallback lifecycle method:

const slot = this.shadowRoot.querySelector('slot');
const surface = document.createElement('div');

const nodes = slot.assignedNodes();

surface.append(...nodes);
document.body.appendChild(surface);

Problems with this approach:

  • I noticed that removing the assignedNodes from slot messes up many things.
  • Many things do not work when I attempt to move slot's lightDOM
  • slotchange doesn't work once the elements are moved.
  • Further, web component could be used in any framework, It could be lit-html, Vue or even plain JavaScript. I noticed that this starts breaking abstractions of the consumer libraries after moving these DOM elements.

This need applies to any absolutely positioned/offset components like notification, dialog, dropdown, Snackbar and the above approach clearly struggles and is certainly not a cleaner way to do things.

How can we do this in a more effective manner avoiding all the mentioned side effects ?


Solution

  • Moving light dom nodes around is usually not a good idea - your users (and the browser) expects them to stay where they are.

    If you want to render the content somewhere else you could provide the content as an attribute.

    A simplified example for lit-html (using it to set a property via .)

    <my-el .content=${'<div>my content</div>'}></my-el>
    

    Then in your custom element, you will need to do something with that string which is not yet in the dom.

    class MyEl extends ... {
      onContentChanged() {
        document.body.querySelector('my-el-target').innerHTML = this.content;
      }
    }
    

    PS: this is highly oversimplified - in real you probably want to create/manage that my-el-target on connectedCallback - or let it be handled by a full controller. Also, the "template" probably should be a lit-template instead of a plain string...