Web components: composition techniques
Tags: webcomponents, exportparts, slotted, composition
Published: 2022-11-30 00:31:50

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](#dropdown-structure)
- [Composing a dropdown](#composing-a-dropdown)
- [The project](#the-project)
- [The index.html file](#the-raw-indexhtml-endraw-file)
- [The main.js module](#the-raw-mainjs-endraw-module)
- [The style.css file](#the-raw-stylecss-endraw-file)
- [The `my-button` web component](#the-raw-ltmybuttongt-endraw-web-component)
  - [`::slotted`](#-raw-slotted-endraw-)
- [The `my-dropdown` web component](#the-raw-ltmydropdowngt-endraw-web-component)
  - [`part` and `exportparts`](#the-raw-part-endraw-and-raw-exportparts-endraw-attributes)
  - [`::part`](#part)
- [The `my-dropdown-option` web component](#the-raw-ltmydropdownoptiongt-endraw-web-component)
- [Recap](#recap)

# 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/46nksou377kahsm90adl.png)
> [source](https://open-ui.org/components/select#anatomy-1)


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

```html
<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>
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g6z0ixzs98d2wicx6yd5.png)

# The project
The code will be all vanilla Js using [native modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/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
```

# The `index.html` file
It makes sense to start with the entry point of our example to see everything that's being loaded.

```html
<!-- 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>
```

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.

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

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.

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

# 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.

```js
// 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);
```

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.

```css
/* 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);
}
```


# 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.

```js
// 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));
    }
  }
);
```

Loading CSS externally is an excellent way to improve the <abbr title="Developer Experience">DX</abbr> and make the implementation more self-contained.

Worth mentioning that the `/* html */` comment right before the HTML template tells VScode ([es6-string-html](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html) extension required) that this block has to be syntax highlighted.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ffo8fwi99puui9v96wrh.png)

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.


```css
/* 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);
}
```

## `::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`:

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

# The `<my-dropdown>` web component

```js
// 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>
      `;
    }
  }
);
```

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:

```html
<div part="options">
```

and

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

### ::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:

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

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

```css
/* 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;
}
```

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.

```js
// 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,
        })
      );
    }
  }
);
```

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](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement) looks like. 

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

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

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

```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);
}
```

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.

```css
/* 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);
}
```

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](https://github.com/joanllenas/web-components-article) with the full project source code.

## Related things worth exploring
- [lit.dev](https://lit.dev/) Web components library.
- [stenciljs.com](https://stenciljs.com/) Web components compiler.
- [storybook.js.org](https://storybook.js.org/) Frontend workshop for building UI components in isolation.


## A few links

- [Current web components browser support](https://caniuse.com/?search=web%20components)
- [Styling a Web component](https://css-tricks.com/styling-a-web-component/)
- [Web components](https://javascript.info/web-components)
- [Shadow DOM v1 - Self-Contained Web Components](https://web.dev/shadowdom-v1/)