feat(dialog): a popup inside the dialog owns Tab and Escape while it holds focus - #57
Open
ivanbanov wants to merge 1 commit into
Open
feat(dialog): a popup inside the dialog owns Tab and Escape while it holds focus#57ivanbanov wants to merge 1 commit into
ivanbanov wants to merge 1 commit into
Conversation
…holds focus A select menu, popover, menu, or combobox list inside a modal dialog never joins the layer stack, so the dialog kept treating itself as topmost: its capture-phase Escape closed the dialog under the popup, and its trap cancelled Tab inside the popup and pulled focus back into the window. - dom-overlay: `foreignPopupHoldsFocus(id)` and `expandedPopupControlHoldsFocus(id)` read the real keyboard owner from ARIA — the popup roles (aria-haspopup's values) and an expanded control with aria-haspopup — telling a registered layer from a popup that never registered. No cooperation from the popup is needed. - dom-dialog: the Escape listener stands down for either; the trap stands down for a foreign popup only (Tab is how an expanded control's popup is left). - SPECs: core behavior contract, dom-dialog, dom-overlay. Stories: a hand-rolled listbox inside the dialog, mirrored in React and Solid. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A popup opened inside a modal dialog — a select menu, a popover, a menu, a combobox list, from any library — owns the keyboard while it holds focus. The dialog's Escape and focus trap stand down until focus is back in the window, so one Escape closes the popup and the next reaches the dialog, and Tab inside the popup is the popup's.
Where
@dunky.dev/dom-overlay— two queries read the real keyboard owner from ARIA, since such a popup never joins the layer stack:foreignPopupHoldsFocus(id)(focus in an element with a popup role —aria-haspopup's values: listbox, menu, tree, grid, dialog — that is neither the layer's window nor a registered layer, or outside every registered window at all, since the page is inert under a modal layer) andexpandedPopupControlHoldsFocus(id)(focus on a control inside the window witharia-expanded="true"andaria-haspopup, the way a combobox keeps focus on its input).aria-expandedalone is a disclosure, which has no popup to close, so it does not count. Registered layers never count as foreign — a layer beneath stays inert and the topmost trap re-enters from it.@dunky.dev/dom-dialog— the capture-phase Escape listener stands down for either query;dialogTrapOptionsstands down for a foreign popup only. An expanded control keeps the trap: Tab is how its popup is left, and focus is still in the window.innerPopupin React and Solid: a hand-rolled listbox inside the dialog, since the repo has no select of its own yet.The substrates change nothing: they pass
domDialogEffectsanddialogTrapOptionsthrough as before.Why
The stack is the right answer for layers that register, and the design already anticipates this repo's own menus doing so. It cannot know about a popup from another library, or one a product team wrote, and there will always be those. Reading ARIA covers every well-formed popup with no cooperation required; registering stays a refinement for our own overlays, not a prerequisite for the dialog to be usable with someone else's.
Before: the capture-phase listener ran before the popup's own Escape handler and closed the dialog under it; the trap called
preventDefault()on every Tab and pulled focus back into the window while the popup was open.Coverage
Full suite green: 17 files, 294 tests. Typecheck (base + solid), oxlint, oxfmt clean on the changed files. knip reports only the pre-existing native-package items.
🤖 Generated with Claude Code