A multi-select dropdown is the component everyone has written badly at least
once: open state, a document click listener, an Escape handler, a z-index that
keeps losing, and a cleanup path that leaks a listener when the component
unmounts. This version has none of those, because the platform now does all five.
Checkpoint:
ui-05.
Everything the popover attribute deletes
Two attributes replace the entire open/close machinery:
<button type="button" popovertarget="item-filter-popover">Items</button>
<div id="item-filter-popover" popover>…</div>
popovertarget on the button, popover on the panel. What that buys, with no
JavaScript at all:
- Light dismiss — clicking outside closes it. No document listener, no
composedPath()check, no cleanup indisconnectedCallback. - Escape — closes it. No keydown handler.
- Top layer — the panel renders above everything regardless of stacking
context, so
z-index: 1000stops being a number anyone has to escalate. - One-at-a-time — opening another popover closes this one.
The document listener is the one worth dwelling on. Inside a shadow root a
click’s target is retargeted to the host, so the naive “did the click land
inside me?” check silently fails and the dropdown closes when you click your own
checkbox. Fixing that means composedPath() and understanding retargeting.
Deleting it means understanding nothing, because there is no listener.
Animating an element that stops existing
A popover toggles display, which historically could not be transitioned — an
element cannot animate from display: none because it is not rendered. Two
newer CSS features fix that:
[popover] {
opacity: 0;
translate: 0 -0.5rem;
transition: opacity 180ms, translate 180ms, display 180ms allow-discrete;
}
[popover]:popover-open {
opacity: 1;
translate: 0 0;
}
@starting-style {
[popover]:popover-open {
opacity: 0;
translate: 0 -0.5rem;
}
}
allow-discrete lets display participate in the transition, flipping at the
right end rather than instantly. @starting-style supplies the values the
element animates from on first render — without it there is no previous state
to interpolate from and the entry animation simply does not play, while the exit
works fine. That asymmetry is confusing enough to be worth recognising on sight.
Pinning a panel to its trigger
The panel should sit under the button. In the top layer, normal positioning has
nothing to anchor to — which is what CSS anchor positioning solves, using the
popovertarget invoker as the implicit anchor:
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block;
inline-size: anchor-size(inline);
Below the button, aligned to its inline edge, flipping above when there is no
room below, and matching the button’s width. Three declarations replacing a
getBoundingClientRect measurement, a scroll listener, and a resize listener.
Support is not universal yet, so the component keeps a measured fallback on
@beforetoggle for browsers without it — about six lines, running only where
anchor positioning is missing. That is the shape of adopting a new platform
feature honestly: use it, and carry a small fallback rather than a whole
positioning library.
All Items is a checkbox with a job
The panel is a fieldset of checkboxes with one extra at the top:
if (value === '__all__') {
setFruitSelection(checked ? [] : ITEMS.slice());
} else {
const next = checked
? [...selectedFruits.get(), value]
: selectedFruits.get().filter(f => f !== value);
setFruitSelection(next);
}
Remember the convention from the engine course: an empty array means all
items, not no items. So “All Items” checked is the empty selection — the
absence of a filter rather than the presence of eight. The alternative, listing
all eight names, would send eight parameters into an IN clause to express “do
not filter”, which is slower and says the wrong thing.
The component also marks itself when a filter is active:
this.toggleAttribute('data-active', selectedFruits.get().length > 0);
That attribute is not read by any JavaScript. It exists purely so CSS can see it, and lesson 6 uses it to make a reset button appear with no state at all.
The gap this component leaves
One thing the platform does not do: the invoker never announces its expanded
state. There is no aria-expanded on that button, so a screen reader user is
told a button exists but not that pressing it revealed a panel.
This is a real accessibility gap in the code you are building, stated plainly
rather than glossed. The Popover API manages focus and dismissal; it does not
yet manage that attribute, and the fix is a toggle event listener setting it.
It is this lesson’s challenge, and it is worth doing before you ship anything
built on this pattern.
Build it
| File | Action | What goes in it |
|---|---|---|
src/components/multi-select-dropdown.ts |
write | The invoker and popover panel, the checkbox fieldset with All Items, the data-active toggle, anchor-positioning CSS and the @beforetoggle fallback |
index.html |
modify | A filters card with a heading, an info-tip, and a filters row holding the dropdown |
src/index.ts |
modify | Import the component |
src/style.css |
modify | .card, .filters-card, .filters-row |
Done when: the dropdown opens on click and closes on outside-click and Escape with no listeners in your code, and checking two items drops Total Records to roughly a quarter of 20,000 while Items in View reads 2.
Answer key: ui-04...ui-05.
Challenge: announce the expanded state
Add a toggle event listener on the popover that sets aria-expanded on the
trigger button from event.newState. Verify it with your operating system’s
screen reader, or by inspecting the attribute as you open and close the panel.
Then check the focus order: tab into the panel, tab past its end, and decide
whether the behaviour you get is the behaviour you want.
Next lesson: two range inputs pretending to be one control, and a reset button with no state behind it.