TIL post on WebComponents
Some checks failed
Build and copy to prod / build-and-copy (push) Has been cancelled
Some checks failed
Build and copy to prod / build-and-copy (push) Has been cancelled
This commit is contained in:
parent
8298c98039
commit
a5e9becd96
@ -0,0 +1,139 @@
|
||||
---json
|
||||
{
|
||||
"title": "TIL: Using Lit decorators for better WebComponent APIs",
|
||||
"date": "2024-10-11T09:38:12.810Z",
|
||||
"tags": [
|
||||
"javascript",
|
||||
"html"
|
||||
],
|
||||
"excerpt": "I've been experimenting with WebComponents for a distributed component library at work. At first I had issues with making the components do what I wanted them to do, while still being usable. Lit's @queryAssignedElements decorator has made that significantly easier"
|
||||
}
|
||||
---
|
||||
|
||||
I've been experimenting with WebComponents for a distributed component library at work. At first I had issues with making the components do what I wanted them to do, while still being usable. To simplify things, we're using [Lit](https://lit.dev) which provides, among other things, the [@queryAssignedElements decorator](https://lit.dev/docs/api/decorators/#queryAssignedElements), and has made that significantly easier.
|
||||
|
||||
> [!warning]
|
||||
>
|
||||
> A lot of the examples in this post are going to include aria role attributes. The examples are incomplete implementations, please do _not_ copy them verbatim as they'll be completely inaccessible.
|
||||
|
||||
## The problem
|
||||
|
||||
Let's say I want to create a custom `select` component. If I were using React, I might define my API as something like this:
|
||||
|
||||
```typescript
|
||||
type Option = {
|
||||
value: string | number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface CustomSelectProps {
|
||||
id: string;
|
||||
label: string;
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
export const CustomSelect = ({ id, label, options }: CustomSelectProps) => (
|
||||
<div>
|
||||
<label htmlFor=${id}>{label}</label>
|
||||
<ul
|
||||
role="listbox"
|
||||
id={id}
|
||||
...
|
||||
>
|
||||
${options.map(option => (
|
||||
<li
|
||||
role="option"
|
||||
data-value={option.value}
|
||||
...
|
||||
>
|
||||
{option.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
This is expected in React, because you don't typically have much control over the contents of your component's child nodes. But in a WebComponent, we use `<slot>` elements for child nodes, which means a slightly different mental model. In particular, I think that using a WebComponent should _feel_ like you're using regular HTML tags - because that's essentially what they are.
|
||||
|
||||
So I'd much rather have an API that looked something like:
|
||||
|
||||
```html
|
||||
<custom-select id="my-id">
|
||||
<span slot="label">Select label</span>
|
||||
<option value="option-1-value">Option 1</option>
|
||||
<option value="option-2-value">Option 2</option>
|
||||
...
|
||||
</custom-select>
|
||||
```
|
||||
|
||||
The main issue here is that doing things this way means it's not particularly easy to do things like styling the options - which is the main reason for making a custom select to begin with.
|
||||
|
||||
## Using queryAssignedElements to hijack the values
|
||||
|
||||
To make that API work as we'd expect it to, we can use a named `slot` for an option, and then use `queryAssignedElements` to select them and do some DOM manipulation:
|
||||
|
||||
```typescript
|
||||
import { html, css, LitElement } from 'lit';
|
||||
import { customElement, queryAssignedElements } from 'lit/decorators.js';
|
||||
|
||||
@customElement('custom-select')
|
||||
export class CustomSelect extends LitElement {
|
||||
@queryAssignedElements({ slot: 'option', selector: 'option' })
|
||||
options!: Array<HTMLOptionElement>;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<label for="${this.id}" id="${this.id}-label"><slot name="label"></slot></label>
|
||||
<ul
|
||||
role="listbox"
|
||||
id="${this.id}"
|
||||
>
|
||||
${this.options.map(option => html`
|
||||
<li
|
||||
role="option"
|
||||
data-value="${option.value}"
|
||||
>
|
||||
${option.innerText}
|
||||
</li>
|
||||
`)}
|
||||
</ul>
|
||||
<slot name="option" @slotchange=${() => this.requestUpdate()}></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This iterates over every `option` that's passed to the `option` slot, and renders it as a child of the listbox. This means that we can easily define the correct ARIA roles, attributes, and event listeners on those children while keeping the need to deal with both shadow & light DOM nodes to a minimum. And by using a `selector`, we can ignore any extra elements that are passed to the slot by mistake.
|
||||
|
||||
The `@slotchange` event handler turned out to be necessary to force the rendered list of options to _actually_ contain values, otherwise it was always an empty array as it's initialised before the slot contents are rendered.
|
||||
|
||||
Finally, we can hide the user-supplied options with a little bit of CSS:
|
||||
|
||||
```typescript
|
||||
@customElement('custom-select')
|
||||
export class CustomSelect extends LitElement {
|
||||
|
||||
static styled = css`
|
||||
slot[name="option"]::slotted(*) {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Now the API for the WebComponent is a lot more natural-feeling:
|
||||
|
||||
```html
|
||||
<custom-select id="my-select">
|
||||
<span slot="label">Select an option</span>
|
||||
<option slot="option" value="1">Option 1</option>
|
||||
<option slot="option" value="2">Option 2</option>
|
||||
<option slot="option" value="3">Option 3</option>
|
||||
<option value="4">This option won't appear</option>
|
||||
<div slot="option">Nor will this</div>
|
||||
</custom-select>
|
||||
```
|
||||
|
||||
And that's all there is to it. I'm still very much in the early days of learning about WebComponents & Lit, so if you've read this and have any suggestions for better ways to handle this then I'd love to hear them. You can [toot me](https://social.lol/@lewis), or find me on any of the places on my [links page](/links/).
|
Loading…
Reference in New Issue
Block a user