Skip to content

feat(dialog): a popup inside the dialog owns Tab and Escape while it holds focus - #57

Open
ivanbanov wants to merge 1 commit into
mainfrom
feat/dialog-inner-popups
Open

feat(dialog): a popup inside the dialog owns Tab and Escape while it holds focus#57
ivanbanov wants to merge 1 commit into
mainfrom
feat/dialog-inner-popups

Conversation

@ivanbanov

Copy link
Copy Markdown
Member

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) and expandedPopupControlHoldsFocus(id) (focus on a control inside the window with aria-expanded="true" and aria-haspopup, the way a combobox keeps focus on its input). aria-expanded alone 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; dialogTrapOptions stands 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.
  • StoriesinnerPopup in 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 domDialogEffects and dialogTrapOptions through 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

  • dom-overlay: popup inside the window, popup portalled beside it, non-popup element outside every window, another registered layer, the body, an unknown id; expanded control with a popup, collapsed control, expanded disclosure.
  • dom-dialog: Escape stands down for a focused popup and for an expanded control; the trap stands down for a focused popup and resumes once focus is back on the window.
  • SPECs: core behavior contract (focus trap + nesting), dom-dialog (Escape + trap), dom-overlay (new section + API rows). Changesets: dom-overlay minor, dom-dialog patch.

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

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant