Focus Trapping in Data Grid Filter Popovers
Permalink to "Focus Trapping in Data Grid Filter Popovers"A column filter popover is a small piece of interactive content anchored to a header control, and it sits exactly on the boundary between two patterns. Treat it as a dialog and you trap focus in something that was never meant to block the page; treat it as inline content and you lose the state signalling and the Escape behaviour that make it usable. This page covers where the line falls, the attributes the trigger needs, and the close paths that are routinely forgotten.
It extends keyboard focus trapping and navigation, and the popover it describes is the one used by accessible filtering and faceted search.
Spec reference
Permalink to "Spec reference"aria-modal="true" on a role="dialog" tells assistive technology that content outside the dialog is not available. It is a strong claim, and it should only be made when it is true — a reader that honours it will not let the user explore the grid behind the popover.
For a non-modal popover, the trigger carries aria-expanded and aria-controls, and the popover itself needs an accessible name, usually from aria-labelledby pointing at its heading. No aria-modal, no trap.
SC 2.1.2 No Keyboard Trap (Level A) applies to both cases: a user must be able to leave using the keyboard alone. For a modal popover that means Escape and a reachable Cancel; for a non-modal one, Tab must continue into the page rather than cycling.
When to use — and when not to
Permalink to "When to use — and when not to"Trap focus when the popover genuinely blocks the page: a full-screen filter panel on mobile, or a dialog with an overlay. In those cases the trap matches what the user sees.
Do not trap a small anchored popover with three checkboxes in it. It is not modal, and a trap makes it feel broken — Tab appears to loop forever for a user who cannot see the popover boundary.
Do not skip aria-expanded in either case. Without it, the trigger is a button that does nothing detectable.
Annotated code example
Permalink to "Annotated code example"<!-- SC 4.1.2: the trigger exposes the state; the popover has a name -->
<th scope="col">
Amount
<button type="button" id="amount-filter-trigger"
aria-expanded="false" aria-controls="amount-filter-popover"
aria-label="Filter Amount column">⌄</button>
<div id="amount-filter-popover" role="group"
aria-labelledby="amount-filter-heading" hidden>
<h3 id="amount-filter-heading">Filter Amount</h3>
<label for="amount-min">Minimum</label>
<input id="amount-min" type="number">
<button type="button" data-apply>Apply</button>
<button type="button" data-cancel>Cancel</button>
</div>
</th>
// SC 2.4.3: one open path, several close paths — all restore focus
let trigger = null;
function openPopover(btn, popover) {
trigger = btn; // store BEFORE anything renders
popover.hidden = false;
btn.setAttribute('aria-expanded', 'true');
const first = popover.querySelector('input, select, button');
(first || popover).focus(); // focus must ENTER the popover
}
function closePopover(popover, { restore = true } = {}) {
popover.hidden = true;
trigger?.setAttribute('aria-expanded', 'false');
if (restore) trigger?.focus(); // every path, including click-outside
trigger = null;
}
// Escape from anywhere inside, including a text input
popover.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.stopPropagation(); // do not also close an outer dialog
closePopover(popover);
}
});
// Click outside is a close path too — and it is the one that forgets focus
document.addEventListener('pointerdown', (e) => {
if (!trigger) return;
if (!popover.contains(e.target) && e.target !== trigger) closePopover(popover);
});
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Assistive technology | On open | Deviation |
|---|---|---|
| NVDA + Firefox | Announces the popover name, then the focused control | Reads the trigger’s expanded state on return |
| JAWS + Chrome | Announces the group label and the focused control | May not announce a role="group" name unless it is focused |
| VoiceOver + Safari | Announces the focused control; the group name may be skipped | Give the first control a clear label of its own |
| TalkBack + Chrome | Announces the focused control | Popovers anchored to a header can render off-screen on small viewports |
Integration context
Permalink to "Integration context"The popover shares its close-and-restore discipline with modals — restoring focus after closing complex modals covers the case where the trigger itself has been removed while the popover was open, which happens when applying a filter re-renders the header.
The Apply path also produces a filter change, so it triggers the count announcement described in announcing filter result counts with aria-live. Restore focus first, then announce, so the two do not overlap.
Gotchas
Permalink to "Gotchas"Escape swallowed by an input. Some component libraries stop propagation of Escape inside text fields to clear them. Handle Escape on the popover itself with a capture listener if necessary.
Click-outside forgets focus. The most commonly missed close path, and the one that leaves focus on the body.
aria-expanded left true. After a close via clicking outside, the trigger still claims to be expanded.
The trigger is re-rendered on apply. The stored reference points at a detached node and focus() silently does nothing. Re-query by id before restoring.
A five-minute audit for any popover
Permalink to "A five-minute audit for any popover"Open the popover with the keyboard rather than the mouse, because the mouse path hides most of these defects. Confirm that focus moved into the popover: press Tab once and check where you land. If the first Tab takes you to a control after the popover, focus never entered it, and a screen reader user has no way to know it opened.
Press Escape from inside a text field in the popover. This is the case component libraries break most often, because many of them consume Escape in inputs to clear the value. If the popover stays open, add a capture-phase handler on the popover itself.
Close the popover by clicking outside it and then press Tab. Focus should resume from the trigger. If it resumes from the top of the document, the click-outside path is not restoring focus — the single most commonly missed close path.
Finally, inspect the trigger after each close. Its aria-expanded must be back to false every time. A trigger stuck at true tells every future screen reader user that a popup is open when nothing is.
FAQ
Permalink to "FAQ"Should a column filter popover trap focus?
Usually not. A focus trap is for modal content that blocks the rest of the page; a column filter popover normally leaves the grid usable behind it, so trapping focus overstates what is happening and forces the user to dismiss it before they can reach anything else. Use a trap only when the popover really is modal — a full filter dialog, for example, that greys out the grid.
Where should focus go when the popover opens?
To the first interactive control inside it, usually the search input. If there is no obvious first control, focus the popover container with tabindex=“-1” so the reader announces its label. What matters is that focus enters the popover: leaving it on the trigger means a screen reader user has no idea anything opened.
Does the trigger need aria-expanded?
Yes. The trigger controls a popup, so aria-expanded on the trigger is how a screen reader user learns the state without entering it. Pair it with aria-controls pointing at the popover id. Both must be kept in sync on every close path, including clicks outside, which is the path most often forgotten.
Related
Permalink to "Related"- Keyboard focus trapping & navigation — the parent guide and the modal rules
- Accessible filtering & faceted search — what the popover contains
- Restoring focus after closing complex modals — the removed-trigger case