Skip to content

Restore focus to the trigger when ColumnFilterMenu closes - #312

Merged
woksin merged 2 commits into
mainfrom
fix/column-filter-menu-restores-focus
Sep 22, 2026
Merged

woksin merged 2 commits into
mainfrom
fix/column-filter-menu-restores-focus

Conversation

@woksin

@woksin woksin commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Summary

ColumnFilterMenu composes React Aria's DialogTrigger + Popover + Dialog directly, with its own separate trigger button, rather than an integrated single-owner control like ComboBox/Dropdown. React Aria's built-in focus restoration for that shape only notices focus fell to document.body on a requestAnimationFrame-deferred check, so any observer sampling focus right after a dismissal (a keyboard user, a screen reader, or an automated check) sees focus land on the document instead of the trigger. This happened identically whether the popover was dismissed with Escape or by clicking outside; Tab does not dismiss the popover at all, since React Aria already traps focus inside it while open.

The restore is deliberately conditional. React Aria's own restore is guarded — at teardown it asks whether the active element is still inside the dying scope or has already fallen to document.body, and leaves focus alone otherwise, so it only ever recovers focus that was dropped, never focus the user moved. Only the timing of that check is wrong for this shape, not the check itself. ColumnFilterMenu therefore keeps React Aria's question and drops its delay: the same predicate is evaluated synchronously in the effect cleanup that runs in the commit closing the menu, with nothing deferred left to lose a race with.

Read at that moment, "dropped" cannot simply mean document.body: the popover is still exiting and still in the document, so focus is usually still on the popover's own value input — a drop in progress, not a destination. A drop is therefore nothing focused, the body, a node already detached from the document, or anything inside this menu's own popover. Focus anywhere else is somewhere the user deliberately went, such as another column's filter trigger, and is left untouched.

Fixed

Behavior and compatibility

No rendered DOM and no CSS changed. Serialized markup for the closed, open and reclosed states is identical to pre-PR main apart from React Aria's own data-focused / data-focus-visible attributes following the restored focus, which is the behavior this PR exists to produce.

Before: closing the filter popover moved focus to `document.body`
instead of back to the trigger that opened it. The popover relies on
React Aria's own FocusScope to notice focus fell to `document.body`
and restore it, but that check is deferred to a `requestAnimationFrame`
callback and only fires after focus has already visibly left the
trigger's neighborhood. Both Escape and clicking outside the popover
go through the same close path, so both lost focus the same way; Tab
does not dismiss the popover at all, since React Aria already traps
focus inside it while open.

After: ColumnFilterMenu owns a ref to its own trigger button and, in
a `useEffect` cleanup that fires exactly on the open-to-closed
transition, focuses it directly. React Aria tears down the popover's
focus-containment listeners in a layout effect during the same commit,
before this passive effect runs, so the trigger is already free to
receive focus again by the time it is called - no reliance on the
timing-sensitive "did focus fall to body" heuristic, and no
interference with React Aria's own restore logic (it no-ops once
focus already isn't on `document.body`). This mirrors the ref+effect
focus-management idiom DialogImplementation.tsx already uses for
open-focus, applied here to the close transition instead, rather than
adding a new hand-rolled mechanism.

No rendered DOM or CSS changed; only a ref attachment and an effect
were added.

(#241)
The previous commit restored focus unconditionally: the effect cleanup
that fires on the open-to-closed transition called `triggerRef.current
.focus()` on every close, whatever had happened. That is wrong whenever
the user closed the menu by going somewhere on purpose. With two
filterable columns, opening column A's menu and then pressing column B's
filter trigger dismisses A - and A's cleanup then yanked focus off B and
back onto A's trigger, undoing the move the user had just made. Measured
in jsdom before this change: after the press, focus is on B's trigger
while the menu is still open, and after the click that closes A it is
back on A's trigger.

React Aria's FocusScope never had that bug, because it asks a question
before it restores: at teardown it checks whether the active element is
still inside the dying scope or has already fallen to `document.body`,
and it leaves focus alone otherwise - it recovers focus that was dropped,
never focus that was moved. What is wrong with it here is only the
timing. It defers the actual restore to a `requestAnimationFrame` that
re-tests `activeElement === body`, so the restore lands a frame after
focus has visibly left the trigger's neighborhood, and silently does
nothing at all if something else claims focus in between.

So keep React Aria's question and drop its delay. The cleanup now
evaluates the same predicate synchronously, in the commit that closed the
menu, and focuses the trigger only if focus was dropped. There is nothing
deferred left to lose a race with: the decision is made from state that
is already final, since the user's focus move and the close both happened
before this commit was scheduled.

The subtle part is what "dropped" means at the moment the cleanup runs.
The popover is still exiting and still in the document, so focus is
usually still sitting on the popover's own value input - a drop in
progress, not a destination. Reading React Aria's plain body check at
this moment would therefore never fire; that is precisely why React Aria
has to wait for a frame. A drop is consequently: nothing focused, the
body, a node already detached from the document, or anything inside this
menu's own popover. Anything else is a place the user went, and is left
alone. Matching the popover needs a handle on it, so the popover's ref is
captured stickily - React detaches that ref during the same commit that
closes the menu, before this passive cleanup runs, so the last element
seen is the only handle still available.

Covered by a new spec that opens column A's menu, presses column B's
trigger with the pointer sequence plus the focus a real `mousedown`
performs as its default action (jsdom does not run that default action),
and asserts focus settles on B's trigger and not A's. Reverting the guard
fails exactly those two assertions on every run while the existing
dismissal specs stay green; keeping only React Aria's literal body check,
without the in-popover clause, instead fails both existing restore specs.
The shared `a_filterable_table` helper grew an `additionalColumns` option
and a `triggers` list to render a second filterable column.

No rendered DOM and no CSS changed: serialized markup for the closed,
open and reclosed states is byte-identical to the previous commit, and
identical to pre-PR `main` except for React Aria's own `data-focused`
and `data-focus-visible` attributes tracking the restored focus, which is
the behavior this PR exists to produce.

(#241)
@woksin woksin added the patch label Sep 22, 2026
@woksin
woksin merged commit 166433d into main Sep 22, 2026
49 of 50 checks passed
@woksin
woksin deleted the fix/column-filter-menu-restores-focus branch September 22, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant