Skip to content

feat(react-headless-components-preview): move headless overlays onto useTopLayer - #36910

Draft
Victor Genaev (mainframev) wants to merge 2 commits into
feat/top-layer-enginefrom
feat/top-layer-overlays
Draft

Victor Genaev (mainframev) wants to merge 2 commits into
feat/top-layer-enginefrom
feat/top-layer-overlays

Conversation

@mainframev

@mainframev Victor Genaev (mainframev) commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Previous Behavior

Popover, Dialog, Tooltip, MenuPopover, the Combobox/Dropdown/TagPicker listbox and Toaster each drive the top layer on their own, and disagree on details: when they run, whether browser closes reach React state, focus restore, and exit animations.

New Behavior

All of them use useTopLayer from the previous PR. Each keeps its own focus model.

  • Popover (TeachingPopover, InfoLabel): usePopover still drives contentRef, the surface still unmounts after the browser reports the close (now also after exit animations), and focus still returns to the trigger when it was inside. The tests from fix: restore headless TeachingPopover close focus #36843 pass unchanged.
  • Dialog (Drawer): modal dialogs keep blocking native Escape (preventEscapeClose) so onOpenChange can veto a close; non-modal dialogs still report popovertarget toggles as surfaceToggle.
  • Tooltip: hint with hintFallback: 'manual', so in browsers without popover="hint" showing a tooltip never closes an open popover.
  • Menu, listbox, Toaster: keep their own focus handling (autofocus item and trigger restore, active descendant, toast restore).

Visible changes, also in the change file:

  • Modal Dialog, trapFocus Popover and Drawer keep Tab inside.
  • A non-modal Dialog moves focus to [autofocus] or its first focusable element on open, and restores focus on close only when it was inside.
  • Escape inside a popover or dialog nested in a DialogSurface closes only the inner one.
  • A Tooltip that has been shown and hidden is inert and aria-hidden while hidden; aria-labelledby / aria-describedby still resolve.

The spec gets a "Changes from master" table with every difference.

Bugs fixed

Master had a separate top-layer implementation in each overlay, and they disagreed on edge cases. Moving them onto the shared hooks fixes:

  • Modal Dialog out of sync after a native close. <form method="dialog"> or dialog.close() from outside React closed the dialog but never reached onOpenChange, so open stayed true. It is now reported as surfaceToggle.
  • Native modal opens ignored. showModal() from outside React or a commandfor invoker could open a closed modal Dialog that stays mounted (unmountOnClose={false}) without React knowing. Where dialogs fire toggle (Chromium 132+), the open is now reported as surfaceToggle, and undone when the consumer keeps open false. Non-modal Dialog already synced these.
  • Popover unmounted mid-animation. The surface unmounted as soon as the browser reported the close. It now waits for exit animations, like DialogSurface.
  • Exiting overlays still interactive. A Popover or Dialog that is animating out is now inert and aria-hidden="true" until the exit ends, then the consumer's own values come back.
  • Escape closed the outer Dialog too. Escape inside a popover or dialog nested in a DialogSurface closed both. Only the inner one closes now; Escape from content portaled out of the surface still closes the dialog.

Related Issue(s)

Part 2 of 2 of the headless top-layer stack.

@github-actions

Copy link
Copy Markdown

Pull request demo site: URL

@mainframev
Victor Genaev (mainframev) removed this pull request from stack #36911 October 16, 2026 16:00
@mainframev Victor Genaev (mainframev) changed the title feat(react-headless-components-preview): move the remaining overlays onto useTopLayer feat(react-headless-components-preview): move headless overlays onto useTopLayer Oct 16, 2026
@mainframev
Victor Genaev (mainframev) changed the base branch from feat/top-layer-subpath to feat/top-layer-engine October 16, 2026 16:00
@mainframev
Victor Genaev (mainframev) added this pull request to stack #36912 October 16, 2026 16:00
Comment on lines +45 to +47
preventEscapeClose: true,
onOpen: isNonModal ? event => requestOpenChange({ type: 'surfaceToggle', open: true, event }) : undefined,
onClose: isNonModal ? ({ event }) => requestOpenChange({ type: 'surfaceToggle', open: false, event }) : undefined,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A non-modal dialog opened or closed by a popovertarget button is still reported as surfaceToggle, same as master.

Modal and alert dialogs keep blocking the native Escape close (preventEscapeClose), because onOpenChange can veto a close with preventDefault. Everything else using the hook lets the browser close on Escape.

@mainframev
Victor Genaev (mainframev) force-pushed the feat/top-layer-overlays branch 2 times, most recently from 2f596df to 7acb450 Compare October 19, 2026 13:49
@mainframev
Victor Genaev (mainframev) force-pushed the feat/top-layer-overlays branch 2 times, most recently from c347169 to 2bc59a7 Compare October 19, 2026 16:38
@mainframev
Victor Genaev (mainframev) force-pushed the feat/top-layer-overlays branch 2 times, most recently from d9c4077 to cd60bec Compare October 19, 2026 21:27
@mainframev
Victor Genaev (mainframev) force-pushed the feat/top-layer-overlays branch 3 times, most recently from 345c66c to 44e6139 Compare October 21, 2026 09:33
@mainframev
Victor Genaev (mainframev) force-pushed the feat/top-layer-overlays branch 2 times, most recently from 5050196 to 9dd2d71 Compare October 21, 2026 10:17
@mainframev
Victor Genaev (mainframev) force-pushed the feat/top-layer-overlays branch 2 times, most recently from fefb7a1 to 2b587fd Compare October 21, 2026 16:06
…use it in Popover and Dialog

Adds lifecycle callbacks (onBeforeShow, onShow, onBeforeHide, onHide) to the
open-state hooks and a shared useLayerFocusReturn hook built on them. Popover
returns focus to its trigger and non-modal Dialog to the element focused
before opening, replacing the per-component focus code.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant