Why is there still no Web Component for the ARIA toolbar?
Published on
The reason I care
I’m building an application without using a JavaScript framework. Why? For fun, as well as a few more practical reasons. One thing I need for that application is a toolbar — a UI component that contains a bunch of buttons that you can easily switch through using arrows on your keyboard (full description of the ARIA toolbar pattern).
The folks in the Open UI group are working hard to bring this and many other great UI components to the web platform natively. If you didn’t know, they have been leading such long-awaited proposals such as a customizable <select> element or the Popover API. They have a proposal that would make implementing the toolbar easy, but for the time being, it’s not been accepted yet, let alone implemented anywhere. So we gotta build our own!
I already chose not to rely on a framework, so wonderful solutions such as React Aria, Radix UI, Melt UI are unavailable to me. I like maintaining code as much as the next person, so the idea of hand-coding, or even LLM-coding, these keyboard interactions does not excite me. A quick search netted me exactly 0 solutions packaged as Web Components. Sigh.
Hey, wait a second, React Aria is developed by the people at Adobe, as part of their Spectrum design system, and they have a Web Components version. Let’s go through their docs really quickly. They have something called Action Group, which seems to be exactly what I need, but it’s also packaged with a bunch of styles and it’s built for their button components, which is not what I need.
Enter controllers
A glance at the source code for this Action Group component reveals an interesting line:
import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js';Roving tab index controller! What’s a controller?
Here’s what the Spectrum docs say about it:
The
RovingTabindexControlleris a reactive controller that implements the roving tabindex pattern.https://opensource.adobe.com/spectrum-web-components/tools/roving-tab-index/
And a reactive controller is…
A reactive controller is an object that can hook into a component’s reactive update cycle. Controllers can bundle state and behavior related to a feature, making it reusable across multiple component definitions.
Okay, so a controller is a concept from Lit, a way to pack up and reuse logic across Web Components, kind of like custom hooks in React. This is amazing! The only drawback is that using these controllers requires a base class that is aware of how they work. In Lit, their base LitElement is controller-capable by default, but there’s also a slimmer version, ReactiveElement, that doesn’t contain as much Lit-related logic. Since I’m planning to build a Web Component that doesn’t use the Shadow DOM at all (people have been referring to them as HTML web components), I don’t need most of Lit.
To use it in a Web Component, we just need to construct a RovingTabindexController object and pass this as the first argument:
import { RovingTabindexController } from "@spectrum-web-components/reactive-controllers";import { ReactiveElement } from "lit";
class ToolBar extends ReactiveElement { rovingTabindexController = new RovingTabindexController(this, { /* options */ })}Refreshingly easy!
The most important option that we need to pass in is elements — a callback that will return all elements whose focus should be managed. I envision that this component is to be used like this:
<tool-bar> <button>Tool 1</button> <button>Tool 2</button> <div role="separator" aria-orientation="vertical"></div> <button>Tool 3</button></tool-bar>So basically, the elements callback should just get all buttons inside itself. Here’s how we can do that:
import { RovingTabindexController } from "@spectrum-web-components/reactive-controllers";import { ReactiveElement } from "lit";
class ToolBar extends ReactiveElement { rovingTabindexController = new RovingTabindexController(this, { elements: () => [...this.querySelectorAll('button')] })}And that’s it! Well, not entirely, because by default, ReactiveElement creates a shadow root for you, and we want to avoid that, so we also need to override the createRenderRoot method to simply return this.
And here is the final result, running in a quick Vite setup: