Structuring web components to preserve semantics and browser features
I've been working with web components, also known as custom elements, for a while. Personally, I like to use the Stencil.js library to build them as it does a nice amount of scaffolding and lets me write in JSX syntax, which I like. In short, it helps me make them faster.
I like to try to be as atomic as I can with components, breaking everything down to a its smallest possible part. This makes it much easier to debug things and to replace them if needed.
One of the challenges with using web components is that it can lose semantics and become less accessible. Here's a common example, a list with items, like a list of messages.
Semantic HTML
In straight HTML I'd like to make the list an <ol>
tag as the messages are ordered by date, and then use <li>
tags to hold the messages. This gives a nice semantic base and assistive technologies will be able to inform the user about the length of the list, navigate the list, etc.
Using web components, I'd go for <message-list>
and <message-item>
. I could still use the <ol>
and <li>
tags nested but it ends up like this, which breaks the parent-child relationship between the semantic tags and therefore loses the meaning.
<message-list>
<ol>
<message-item>
<li>...</li>
</message-item>
</ol>
</message-list>
If we drop the <ol>
and <li>
we can use aria roles to replace the semantics.
<message-list role="list">
<message-item role="listitem">...</message-item>
</message-list>
It is best practice to use semantic tags over roles. Furthermore, in this case, the aria role of "list" does not distinguish between ordered or unordered so we lose that meaning too.
My solution is simply to be less atomic and to use a single web component with this structure.
<message-list>
<ol>
<li>...</li>
</ol>
</message-list>
This preserves the <ol>
and <li>
semantics and relationships.
Browser features vs the shadow DOM
In this same example with messages, I wanted to be able to expand each message title to show the message content. For this I included <details>
and <summary>
tags inside each <li>
.
In order to make it function as an accordion, where only one message could be expanded at a time, I used the same name
attribute on each <details>
tag to control this - similar to radio button groups.
With separate nested web components it looks like this.
<message-list>
<message-item>
<details name="message">
<summary>title #1</summary>
<div class="content">...</div>
</details>
</message-item>
<message-item>
<details name="message">
<summary>title #2</summary>
<div class="content">...</div>
</details>
</message-item>
<message-item>
<details name="message">
<summary>title #3</summary>
<div class="content">...</div>
</details>
</message-item>
</message-list>
This works in the light DOM but not in the shadow DOM. If each <message-item>
uses the shadow DOM then the details elements are no longer aware of each other. It can be made to work via firing events on a common ancestor element but requires JavaScript so it's no longer really making use of the native browser feature.
As part of this functionality to expand the items and show the message content I also wanted to animate the content expanding. We are now able to animate to values like auto
but it's not entirely simple. I use this CSS to achieve the animation.
::details-content {
block-size: 0;
overflow: clip;
transition: block-size 0.5s ease,
content-visibility 0.5s ease allow-discrete;
}
@supports (interpolate-size: allow-keywords) {
:root {
interpolate-size: allow-keywords;
}
[open]::details-content {
block-size: auto;
}
}
Again, this does not work if we use the shadow DOM, only in the light DOM.
Whilst using the shadow DOM brings advantages like encapsulation, it is not always compatible with browser features which rely on relationships with other DOM elements.
Sometimes, simplifying our web components by using fewer components or the light DOM can actually leverage better use of the browser's native features and semantics.