DEV Community

Cover image for Web components: composition techniques
Joan Llenas Masó
Joan Llenas Masó

Posted on • Edited on

Web components: composition techniques

The web Components v1 specification consists of three main technologies that can be used to create reusable custom elements:

  • Custom elements
  • HTML templates
  • Shadow DOM

Web components usually work as atomic units that don't need anything from the outside world. Sometimes, though, they require a certain degree of versatility, only reachable with the help of composition.

In this article, we will see one such scenario by creating a minimal dropdown implementation.

We will leverage what we've done in the previous articles of this series by reusing a lot of code.

Table of contents

Dropdown structure

A basic dropdown is made of three main parts:

  • A button that toggles the dropdown options.
  • A popover that holds the options.
  • A collection of selectable options.

Image description

source

Composing a dropdown

By bringing that structure into web component land, we could envision a user API such as:



<my-dropdown placeholder="Select">
  <my-dropdown-option value="option-1">
    Option 1
    <p slot="content" class="option-description">
      Option 1 description
    </p>
  </my-dropdown-option>
  (...)
</my-dropdown>


Enter fullscreen mode Exit fullscreen mode

It's similar to the <select> control but a little more interesting because the options can contain more stuff. And it will look like this:

Image description

The project

The code will be all vanilla Js using native modules.
No tooling/bundling is required, just The Web Platform™️.



.
├── package.json
└── src
    ├── components
    │   ├── my-button
    │   │   ├── my-button.css
    │   │   └── my-button.js
    │   ├── my-dropdown
    │   │   ├── my-dropdown.css
    │   │   └── my-dropdown.js
    │   └── my-dropdown-option
    │       ├── my-dropdown-option.css
    │       └── my-dropdown-option.js
    ├── index.html
    ├── main.js
    └── style.css


Enter fullscreen mode Exit fullscreen mode

The index.html file

It makes sense to start with the entry point of our example to see everything that's being loaded.



<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <script type="module" src="/main.js"></script>
    <link rel="stylesheet" href="/style.css" />
  </head>

  <body>
    <my-dropdown placeholder="Select">
      <my-dropdown-option value="light">
        Light theme
        <p slot="content" class="option-description">
          Your vision sharpens, enabling you to focus better
        </p>
      </my-dropdown-option>
      <my-dropdown-option value="dark">
        Dark theme
        <p slot="content" class="option-description">
          Reduce eye strain in low light conditions
        </p>
      </my-dropdown-option>
      <my-dropdown-option value="sepia">
        Sepia theme
        <p slot="content" class="option-description">Middle ground</p>
      </my-dropdown-option>
    </my-dropdown>
  </body>
</html>


Enter fullscreen mode Exit fullscreen mode

A couple of remarks.

We initialise all web components by loading the main.js module and add some logic to toggle the page theme between light, dark and sepia.



<script type="module" src="/main.js"></script>


Enter fullscreen mode Exit fullscreen mode

The style.css file contains CSS variables that define the aforementioned supported themes and a few custom styles that use fancy selectors that are worth exploring.



<link rel="stylesheet" href="/style.css" />


Enter fullscreen mode Exit fullscreen mode

The main.js module

As we mentioned earlier, this is a native Javascript module, that's why we can use the import syntax.

Here we're loading our web components and adding some logic to toggle the page theme.



// main.js
import "/components/my-button/my-button.js";
import "/components/my-dropdown/my-dropdown.js";
import "/components/my-dropdown-option/my-dropdown-option.js";

const main = () => {
  const myDropdown = document.querySelector("my-dropdown");
  myDropdown.addEventListener("myDropdownChange", (event) => {
    console.log(event.type, event.detail);
    document.querySelector("html").dataset.theme = event.detail;
  });
};
window.addEventListener("DOMContentLoaded", main);


Enter fullscreen mode Exit fullscreen mode

By setting <html data-theme="dark"> and with the help of CSS variables, we can manipulate the look and feel of the entire page!

The style.css file

This file contains mainly CSS variables, but some interesting selectors are worth exploring too. We'll examine them when we get to the components.



/* style.css */
:root {
  --background-color: #fff;

  --color-brand: #0b66fa;
  --color-white: #fff;
  --color-black: #000000de;

  --border-radius-sm: 8px;
  --gap-sm: 8px;
  --font-sm: 18px;
  --font-md: 20px;

  /* theme */
  --background-low-contrast: var(--color-white);
  --background-high-contrast: var(--color-black);

  --text-low-contrast: var(--color-white);
  --text-high-contrast: var(--color-black);

  --border-low-contrast: 1px solid var(--color-white);
  --border-high-contrast: 1px solid var(--color-black);
}

html[data-theme="dark"] {
  --background-color: #212a2e;

  --background-low-contrast: var(--color-black);
  --background-high-contrast: var(--color-white);

  --text-low-contrast: var(--color-black);
  --text-high-contrast: var(--color-white);

  --border-low-contrast: 1px solid var(--color-black);
  --border-high-contrast: 1px solid var(--color-white);
}

html[data-theme="sepia"] {
  --background-color: #bfa26b;

  --background-low-contrast: #bfa26b;
  --background-high-contrast: #594225;

  --text-low-contrast: #bfa26b;
  --text-high-contrast: #401902;

  --border-low-contrast: 1px solid #bfa26b;
  --border-high-contrast: 1px solid #401902;
}

body {
  background-color: var(--background-color);
  font-family: sans-serif;
}

/* 
 Styling user content placed inside of a slot
*/
my-dropdown-option .option-description {
  font-size: small;
  color: var(--text-high-contrast);
}

my-dropdown-option:hover .option-description {
  color: var(--text-low-contrast);
}

/* 
----------------------------
-------- CSS PARTS ---------
-- styling the shadow DOM --
----- from the outside -----
----------------------------
*/

my-dropdown::part(options) {
  border-radius: var(--border-radius-sm);
}

my-dropdown-option:not(:first-child)::part(container) {
  border-top: var(--border-high-contrast);
}

my-dropdown-option:first-child::part(container) {
  border-top-left-radius: var(--border-radius-sm);
  border-top-right-radius: var(--border-radius-sm);
}

my-dropdown-option:last-child::part(container) {
  border-bottom-left-radius: var(--border-radius-sm);
  border-bottom-right-radius: var(--border-radius-sm);
}


Enter fullscreen mode Exit fullscreen mode

The <my-button> web component

The my-button.js code is the same we had on previous articles of this series, but with an icon slot, the HTML template inlined, and the CSS loaded from an external file.



// components/my-button/my-button.js
const template = document.createElement("template");
template.innerHTML = /* html */ `
  <style>
    @import "/components/my-button/my-button.css";
  </style>
  <button>
    <slot></slot>
    <slot name="icon"></slot>
  </button>
`;

customElements.define(
  "my-button",
  class extends HTMLElement {
    static get observedAttributes() {
      return ["variant"];
    }

    constructor() {
      super();
      const shadowRoot = this.attachShadow({ mode: "open" });
      shadowRoot.appendChild(template.content.cloneNode(true));
    }
  }
);


Enter fullscreen mode Exit fullscreen mode

Loading CSS externally is an excellent way to improve the DX and make the implementation more self-contained.

Worth mentioning that the /* html */ comment right before the HTML template tells VScode (es6-string-html extension required) that this block has to be syntax highlighted.

Image description

Cool, huh? 😎

Regarding CSS, the only thing that differs from the my-button implementation in the previous article is the introduction of the icon slot, which we select with the ::slotted selector.



/* components/my-button/my-button.css */
:host {
  display: inline;
}

:host button {
  cursor: pointer;
  font-size: 20px;
  font-weight: 700;
  padding: 12px;
  min-width: 180px;
  border-radius: 12px;
}

:host([variant="primary"]) button {
  background-color: var(--color-brand);
  color: var(--color-white);
  border: 0;
}

:host([variant="secondary"]) button {
  border: var(--border-high-contrast);
  background-color: var(--background-low-contrast);
  color: var(--text-high-contrast);
}

slot[name="icon"]::slotted(*) {
  margin-left: var(--gap-sm);
}


Enter fullscreen mode Exit fullscreen mode

::slotted

::slotted only works from within the component. You can't use ::slotted from, let's say, the style.css file. Only styles local to the shadow DOM have access to this selector. Also, it only targets elements, not text nodes.

Here we are saying: Select any element inside the slot named icon:



slot[name="icon"]::slotted(*) { /* ... */ }


Enter fullscreen mode Exit fullscreen mode

The <my-dropdown> web component



// components/my-dropdown/my-dropdown.js
const template = document.createElement("template");
template.innerHTML = /* html */ `
  <style>
    @import "/components/my-dropdown/my-dropdown.css";
  </style>
  <my-button variant="secondary"></my-button>
  <div part="options">
    <slot></slot>
  </div>
`;

customElements.define(
  "my-dropdown",
  class extends HTMLElement {
    static get observedAttributes() {
      return ["placeholder", "open"];
    }

    get placeholder() {
      return this.getAttribute("placeholder") || "";
    }

    get open() {
      return this.hasAttribute("open") && this.getAttribute("open") !== "false";
    }

    set open(value) {
      value === true
        ? this.setAttribute("open", "")
        : this.removeAttribute("open");
    }

    constructor() {
      super();
      this.toggle = this.toggle.bind(this);
      this.optionClickHandler = this.optionClickHandler.bind(this);
      const shadowRoot = this.attachShadow({ mode: "open" });
      shadowRoot.host.setAttribute("exportparts", "options");
      shadowRoot.appendChild(template.content.cloneNode(true));
    }

    attributeChangedCallback() {
      this.render();
    }

    connectedCallback() {
      this.shadowRoot
        .querySelector("my-button")
        .addEventListener("click", this.toggle);
      this.addEventListener("myDropdownOptionClick", this.optionClickHandler);
    }

    optionClickHandler(event) {
      event.stopImmediatePropagation();
      this.selection = event.detail.label;
      this.open = false;
      this.dispatchEvent(
        new CustomEvent("myDropdownChange", {
          detail: event.detail.value,
        })
      );
      this.render();
    }

    toggle() {
      this.open = !this.open;
      this.render();
    }

    render() {
      this.shadowRoot.querySelector("my-button").innerHTML = /* html */ `
        ${this.selection || this.placeholder}
        <span slot="icon">${this.open ? "" : ""}</span>
      `;
    }
  }
);


Enter fullscreen mode Exit fullscreen mode

As expected, this file has some more logic to digest. Let's take a look at the interesting bits.

The part and exportparts attributes

In the same way that ::slotted is used to select elements local to the same template, the part attribute (and the accompanying exportparts) is used to enable selecting elements from outside of the template. Or, wording it differently, it effectively exposes a CSS API that end users can consume from the global CSS scope! 🤯

In the context of our dropdown, the CSS API is created by these two lines of code:



<div part="options">


Enter fullscreen mode Exit fullscreen mode

and



shadowRoot.host.setAttribute("exportparts", "options");


Enter fullscreen mode Exit fullscreen mode

::part()

To better understand how this works, we can return to the global CSS located in our style.css file, where we make use of the options part:



/* style.css (around line 75)  */
my-dropdown::part(options) {
  border-radius: var(--border-radius-sm);
}


Enter fullscreen mode Exit fullscreen mode

As we can see here, the host component can also use the ::part() pseudo selector.



/* components/my-dropdown/my-dropdown.css */
:host {
  display: block;
}

:host::part(options) {
  display: none;
  flex-direction: column;
  margin-top: var(--gap-sm);
  border: var(--border-high-contrast);
}

:host([open])::part(options) {
  display: flex;
}


Enter fullscreen mode Exit fullscreen mode

We used the ::part() pseudo selector to style the component instead of creating a class selector.

  • :host::part(options) {}: Select the options part.
  • :host([open])::part(options) {}: Select the options part only when the host component has the open attribute set.

The <my-dropdown-option> web component

And, last but not least, the options web component.



// components/my-dropdown-option/my-dropdown-option.js
const template = document.createElement("template");
template.innerHTML = /* html */ `
  <style>
    @import "/components/my-dropdown-option/my-dropdown-option.css";
  </style>
  <div part="container">
    <slot></slot>
    <slot name="content"></slot>
  </div>
`;

customElements.define(
  "my-dropdown-option",
  class extends HTMLElement {
    static get observedAttributes() {
      return ["value"];
    }

    get value() {
      return this.getAttribute("value");
    }

    get label() {
      const slot = this.shadowRoot.querySelectorAll("slot")[0];
      return slot.assignedNodes().at(0).textContent;
    }

    constructor() {
      super();
      this.clickHandler = this.clickHandler.bind(this);
      const shadowRoot = this.attachShadow({ mode: "open" });
      shadowRoot.host.setAttribute("exportparts", "container");
      shadowRoot.appendChild(template.content.cloneNode(true));
    }

    connectedCallback() {
      this.addEventListener("click", this.clickHandler);
    }

    clickHandler() {
      this.dispatchEvent(
        new CustomEvent("myDropdownOptionClick", {
          detail: {
            label: this.label,
            value: this.value,
          },
          bubbles: true,
          cancelable: true,
          composed: true,
        })
      );
    }
  }
);


Enter fullscreen mode Exit fullscreen mode

A couple of remarks here.

The dispatched event uses a CustomEvent instance with composed set to true, which is very important to allow the event to propagate across the shadow DOM boundary into the standard DOM.

We can also take a glimpse at what the Slot API looks like.



get label() {
  const slot = this.shadowRoot.querySelectorAll("slot")[0];
  return slot.assignedNodes().at(0).textContent;
}


Enter fullscreen mode Exit fullscreen mode

Not a fan, to be honest, but it's what it is!

Now, let's look at the my-dropdown-option component CSS:



/* components/my-dropdown-option/my-dropdown-option.css */
:host {
  display: block;
}

:host::part(container) {
  padding: 8px 16px;
  background-color: var(--background-low-contrast);
  color: var(--text-high-contrast);
  font-size: var(--font-sm);
}

:host::part(container):hover {
  background-color: var(--background-high-contrast);
  color: var(--text-low-contrast);
}


Enter fullscreen mode Exit fullscreen mode

That was nothing we haven't already covered, but two things are worth mentioning. Let's return to the global style.css file for a moment.



/* style.css (around line 79) */
my-dropdown-option:not(:first-child)::part(container) {
  border-top: var(--border-high-contrast);
}

my-dropdown-option:first-child::part(container) {
  border-top-left-radius: var(--border-radius-sm);
  border-top-right-radius: var(--border-radius-sm);
}

my-dropdown-option:last-child::part(container) {
  border-bottom-left-radius: var(--border-radius-sm);
  border-bottom-right-radius: var(--border-radius-sm);
}


Enter fullscreen mode Exit fullscreen mode

We can see how the end user used the exported parts to tweak the option's default look and feel, round the popover borders, and add separators between options.

Recap

Unless I forgot to mention some obscure API, I'd say that we've covered the whole v1 spec by implementing the dropdown component.

Project code

Github repo with the full project source code.

Related things worth exploring

A few links

Top comments (5)

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

All the oldskool .bind() voodoo magic is not required, you get lexical scope for free if you call an event handler (Web Component method) with a fat-arrow function:

this.addEventListener("click", (evt) => this.clickHandler(evt) );

instead of referencing it:

this.addEventListener("click", this.clickHandler);

Collapse
 
joanllenas profile image
Joan Llenas Masó

That's true. I will get rid of that extra noise.
I probably used it here because at some point I had removeEventListeners. You only need the bind for that particular case.
Thanks!

Collapse
 
dannyengelman profile image
Danny Engelman

For that particular case:

listener = ( name , func ) => {
   document.addEventListener( name , func );
   return {
     remove : () => removeEventListener( name , func );
   }
}
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
itsmikita profile image
Mikita Stankiewicz

What?! Don't really get it... but curious if you can pass the remove listener function when adding? Can you elaborate please?)

Thread Thread
 
dannyengelman profile image
Danny Engelman • Edited

When you add a listener it creates/returns its own remove function

this.clickerRemove = listen( "click" , evt => { } );
Enter fullscreen mode Exit fullscreen mode

then later

this.clickerRemove();
Enter fullscreen mode Exit fullscreen mode

This saves you from having to know/store the func