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 in disconnectedCallback.
  • Escape — closes it. No keydown handler.
  • Top layer — the panel renders above everything regardless of stacking context, so z-index: 1000 stops 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.