One of my favorite uses of Web Components is as a container for HTML
elements that helps manage some aspect of state. In this case, we can
wrap a couple of <button>
elements to create a set
of toggle buttons, like you might use for a theme picker on your
website. Let me know what you think over on the fediverse:
@darth_mall@notacult.social.
Markup
<fieldset>
<legend>Choose one</legend>
<toggle-group>
<button value="one">One</button>
<button value="two">Two</button>
<button value="three">Three</button>
</toggle-group>
<fieldset>
The markup for our control is pretty simple. At it’s core, we just
wrap some <button>
elements in our
<toggle-group>
custom element. We’ll register this
with some JavaScript later on to implement the toggle behavior. Then
we give our control an accessible name using a
<fieldset>
with a <legend>
. All
credit for this ingenious use of <fieldset>
goes to
Ben Myers.
We use the value
attribute on the buttons much like we
would use them with <input type=radio>
, since, in
effect, a single-selection toggle group like this is just a gussied up
radio group.1
Default selection
With the above markup, none of our buttons will be selected by
default. Selected buttons will be identified using the
aria-pressed=true
attribute. So, if you’d like to set a
default selection, you simply have to include
aria-pressed=true
on whichever button should be selected.
Multi-select
When we come to the implementation of our
<toggle-group>
Web Component, we’ll also include a
boolean attribute for a multi-select mode. To use it, we simply
include the multiple
attribute in our markup like this,
<toggle-group multiple>
and that will turn on
multi-selection.
Styles
button[aria-pressed="true"] {
background-color: dodgerblue;
border-color: dodgerblue;
color: black;
}
/* Make all buttons in the group the same width */
toggle-group {
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
}
/* Eliminate rounded corners on inner edges of buttons. */
toggle-group button:not(:first-of-type) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
toggle-group button:not(:last-of-type) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
/* Prevent clicks on elements nested inside buttons */
toggle-group button * {
pointer-events: none;
}
Our styles are also pretty minimal. First, we define a style that
communicates the toggled state for buttons. If you want, you can apply
this to toggle-group button[aria-pressed=true]
instead of
all buttons, but I like to just define the toggle state for all
buttons on a site.
Next, we handle the layout of the buttons in our toggle group. Grid makes this really easy and ensures that we have no gaps between buttons and that all buttons have the same size.
The next two rules remove rounded corners on the internal edges of any of the buttons. If you don’t have rounded corners on your buttons, this won’t really matter, and the corners of your toggle group will be square. If you do round the corners of your buttons, then the corners of your toggle group will match whatever border radius you have set for your buttons, and we only straighten out the corners we need to so that the control looks like a single, segmented button.
Finally, we disable pointer events on elements contained inside of the buttons. This allows you to put an icon inside the buttons along with the text, without allowing it to be the target of a click event. This way, when we implement the click handler, we don’t have to worry about the event target being the SVG inside of the button instead of the button itself.
JavaScript
class ToggleGroup extends HTMLElement {
// Lifecycle methods
connectedCallback() {}
disconnectedCallback() {}
// Properties
get multiple() {}
set multiple(value) {}
get value() {}
set value(val) {}
// Internal methods
#handleClick(event) {}
#setState(state) {}
#togglePressed(button) {}
}
customElements.define("toggle-group", ToggleGroup);
ToggleGroup
class that will implement the control’s
behavior, without any method implementation details.
Life cycle methods
Since we’re not using shadow DOM for this component, there’s no real
need to implement a constructor
. Instead, we can do all
of our initialization in the connectedCallback
method.
connectedCallback() {
this.addEventListener("click", this.#handleClick);
}
disconnectedCallback() {
this.removeEventListener("click", this.#handleClick);
}
All we do in our life cycle methods is connect and disconnect our click handler. We could listen for clicks on each individual button, but this has several disadvantages:
-
If we’ve defined the custom element before we encounter the opening
tag,
connectedCallback
will fire as soon as<toggle-group>
is parsed; so if we go looking for<button>
elements inside our Web Component, we won’t find any, they’ll be added later and we’ll miss them - If any buttons are added or removed to our Web Component using JavaScript, we’ll miss them
- Having multiple event listeners is more costly than having just one
We could handle the first two problems using
MutationObserver
, but that’s more complicated than just using
event delegation.
Properties
get multiple() {
return this.hasAttribute("multiple");
}
set multiple(value) {
if (value === true) {
this.setAttribute("multiple", "");
} else {
this.removeAttribute("multiple");
}
}
multiple
property.
The multiple
property on our Web Component mimics the
multiple
attribute defined for
<select>
elements. It is just a reflection of the
attribute of the same name in JavaScript, i.e. it is written to
and read from the attribute on the element. This makes it convenient
for us to test whether or not this is a multi-select group when
handling clicks, and also makes it convenient to set the selection
mode from JavaScript—toggleGroup.multiple = true
is much
nicer than
toggleGroup.setAttribute("multiple", "")
.
get value() {
const value_list = [];
for (let btn of this.querySelectorAll("[aria-pressed=true]")) {
value_list.push(btn.value);
}
if (value_list.length === 0) return;
return this.multiple ? value_list : value_list[0];
}
set value(val) {
if (!Array.isArray(val)) {
// Ensure we are always working with an array, for simplicity.
this.#setState([val]);
} else if (val.length > 1 && !this.multiple) {
// If this is a single-select toggle group and there's more than one
// value in the array, we ignore all but the first value.
this.#setState([val[0]]);
} else {
// Assigned value is fine as-is.
this.#setState(val);
}
}
value
property.
The value
property is modeled after the
value
property on <input>
elements.
The getter simply iterates over all of the children of the component
that are toggled on and adds their value to an array of values. If the
toggle group has multi-select enabled, it returns the array, otherwise
it returns the first item in the array. If nothing is selected, it
returns undefined
.
The setter will accept either a single value or an array of values,
with a guard to ignore all but the first array element if the toggle
group is in single-select mode. It just ensures that we’re passing a
valid argument to the #setState
method so that the
API for anyone using the Web Component via JavaScript has
a nicer time, rather than requiring them to always pass an array.
Click handler
#handleClick(event) {
const target = event.target;
// Bail out, in case someone puts a non-button element inside this component
if (target.localName !== "button") return;
// Prevent form submission when the toggle group is inside a form element
// for progressive enhancement
event.preventDefault();
// If the toggle group is a multi-select, toggle the event target and
// we're all done, otherwise set the state of the control
if (this.multiple) {
this.#toggleState(target);
} else {
this.#setState([target.value]);
}
this.dispatchEvent(new CustomEvent(
"togglechange",
{ detail: { value: this.value } },
));
}
The first thing the handler does is check to see if the thing that was clicked was actually a button. In the event that someone put something other than a button inside the toggle group, we want to just ignore clicks and let the browser do whatever else it wants to do. If it is a button, we want to prevent the default browser behavior, because this toggle group could be inside a form and we don’t want the form submitting (more on this when we get to progressive enhancement).
With those guards in place, the next thing to do is check to see if
this toggle group is a multi-select group or not. If it is a
multi-select group, then all we have to do is toggle the state of
whatever button was clicked. If it’s a single-select group, we just
pass the value of the event target to #setState
.
Set state method
#setState(state) {
for (let btn of this.querySelectorAll("button")) {
const pressed = state.includes(btn.value) ? "true" : "false";
btn.setAttribute("aria-pressed", pressed);
}
}
The set state method is a utility to set the
aria-pressed
attributes on all the buttons to match the
desired state. The argument, state
, is an array of
strings corresponding to the values on each button that should be
turned on; all other buttons will be turned off. This method is
private because it doesn’t include any checks on the arguments—it
assumes that state
is an array. This allows us to skip
unnecessary checks when we call internally from our click handler and
we know the correct format of the argument to pass. We can then push
all of the checks to be more permissive in what we accept from authors
using this Web Component into the value
setter for a
nicer API.
Toggle pressed method
#togglePressed(button) {
const currentValue = button.getAttribute("aria-pressed");
const toggledValue = currentValue === "true" ? "false" : "true";
button.setAttribute("aria-pressed", toggledValue);
}
The toggle state method is a utility to make it easier to flip the
aria-pressed
attribute on any given button. It gets the
current value of aria-pressed
on an element, uses that to
determine what the next value should be, and then sets the next value
on the element.
Progressive Enhancement
If you have the ability to respond to form submissions, you can use a form to progressively enhance your toggle group.
<form>
<fieldset>
<legend>Choose one</legend>
<toggle-group>
<button name="choose-one" value="one">One</button>
<button name="choose-one" value="two">Two</button>
<button name="choose-one" value="three">Three</button>
</toggle-group>
<fieldset>
</form>
By default, the buttons of a form will submit the form, and without a
method
or action
property, the form will
submit to the current URL as a GET
request.
By adding a name
to our buttons, we ensure that clicking
a button will request the current URL with
?choose-one=VALUE
as the query string, where
VALUE
is the value
assigned to the button
that was clicked. You can use this to process state on the server and
set cookies or do whatever you need to do, so that your toggle group
works even if JavaScript doesn’t.
This is why we called event.preventDefault
in our click
handler. If JavaScript is working, we can prevent the form submission
and respond to an event fired by the Web Component.
Without forms
If you can’t handle form submissions, your best bet may be to hide the control if JavaScript has failed. This can be easily done with CSS.
toggle-group:not(:defined) {
display: none;
}
This will hide the control, but if you’re using
<fieldset>
to label the control, you’ll have a
label that labels nothing. This can be fixed with :has
,
if your browser targets support that.
fieldset:has(toggle-group:not(:defined)) {
display: none;
}
Footnotes
-
It would therefore be a good idea to ask yourself whether you really want a toggle group, or if you’d be better off just using a group of radio buttons. ↩︎