Toolbar panels, popover menus, and a consolidated header account menu (laz-906) - #23929
Merged
Conversation
A toolbar action can now carry a `panel` instead of an `onClick`, and the group anchors that panel to whichever control it rendered: under the button while the action is in the row, beside its row once it has collapsed into the overflow. Making that work meant rebuilding the overflow menu on `Popover` rather than `Menu`. A `Menu` closes the moment focus reaches a surface nested inside it and has no way to know that surface is its own; `Popover` registers a child's portal as part of itself, so the flyout no longer reads as leaving the menu. What `Menu` gave for free is supplied by hand: the menu roles, focus on open, arrow-key navigation, and the row holding the hover state while its panel is up. Three subtleties worth knowing, all from reading Headless UI 2.2.10: - The flyout trigger sits inside its own `Popover`. A bare `PopoverButton` within a `PopoverPanel` reads `PopoverPanelContext` and acts as a *close* button; nesting the `Popover` resets that context. - Key presses inside the flyout still reach the menu's `onKeyDown`, because React events bubble the component tree rather than the DOM. The handler bails when the event came from outside the menu element. - A `Popover` renders a hidden sentinel span beside its own element until it has resolved its root container, so a control that opens a panel briefly occupies two slots in the row. Overflow measurement therefore goes by marker attributes rather than by position among the group's children — indexing past the actions to find the overflow button landed on that span, read its width as 0, and left the group one control too wide. With the toolbar owning the trigger, `DisplayOptions` collapses to a single form: `useDisplayOptionsAction` supplies the rows, and the standalone popover shell and its shared-content split are gone. Both consumers move onto it, so the Extensions page and the Games page now use real toolbars.
Panels of settings were a flat run of rows, each drawing its own bottom rule. They are now bands: `PopoverPanelGroup` holds `PopoverPanelGroupItem` rows, and the rule sits on the group rather than the row. Stated as `:not(:last-child)` rather than a last-child reset, so nothing is declared and then undone and a trailing element can't strand a divider under the final group. Display options compose from these, which retires `DisplayOptionsItem`; the reset link becomes the panel's last group, below the final rule. Menu and panel rows are 32px with their label truncating to one line. Getting the ellipsis needs `min-width: 0` on the label: a flex item won't shrink below its content without it, so a long label widens the row instead of clipping. The panels then have to be capped or they simply grow to fit — hence `!important` on the max-widths, since Headless UI's size middleware writes the available screen space inline and offers no `--anchor-max-width` to compose with, the way it does for height. Two knock-ons from dropping `width: 100%` off menu rows in favour of letting the flex column stretch them: - A row nested inside a popover wrapper stopped filling, because a `button` sizes `width: auto` to its content even as a flex container, and only a flex *item* gets stretched. The overflow menu's flyout row is the one such case. - Rows inset themselves with a margin now, which `width: 100%` would overflow by exactly that margin. Row styling stays in the stylesheet rather than moving to utilities at the call site, matching `.nxm-dropdown-item`: what a component always is belongs in its class, and `className` is left for what a caller decides per instance.
The header carried two adjacent icon-button menus, and they overlapped: the profile menu's "Send feedback" opened the same Google Form the feedback extension already contributes to the help menu. Help is now a row of the account menu that opens the help options as a nested flyout, and the duplicate row is gone. A Dropdown could not host that flyout — a Headless UI Menu closes the moment focus reaches a surface nested inside it. The toolbar overflow had already solved this with a Popover wearing menu semantics, so that machinery moves out of the toolbar into PopoverMenu and PopoverMenuItem, and ToolbarOverflow is refactored onto it; IToolbarAction becomes IMenuAction plus the toolbar's own layout concerns, so its nine consumers are untouched. Panels gain `dismiss` alongside `close` for a control that ends the interaction rather than adjusting something — redefining `close` would have quietly made the display options' reset link dismiss the overflow menu too. Signed out there is no account menu to nest in, so the header rearranges: the premium slot holds the login call to action (it rendered nothing before, since the ad needs userInfo) and the account slot holds the help options flat. That keeps "View logs" reachable for someone who cannot sign in. The panel surface classes now say what they hold: -dropdown sizes a list of rows, matching .nxm-dropdown-items, and -controls caps a panel of controls. Also groups the Header directory into help/, notifications/, premium/ and profile/, and renames index.tsx to Header.tsx so nothing in there is an index.
The display-options example still hand-rolled its rows as three divs each carrying their own `border-b`, which is the dangling-divider problem PopoverPanelGroup was added to solve — the borders only lined up because they were placed by hand. It now composes from PopoverPanelGroup and PopoverPanelGroupItem, so the demo shows what production display options actually do, and carries nxm-popover-panel-controls as a hand-rolled panel has to. Also drops `right-auto left-0` from the panel, which has done nothing since Headless UI v2 — the panel is portalled and positioned by the anchor prop, whose inline styles win — and corrects the design note that still claimed panels are positioned manually until v2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing to undo The panel always ended in "Reset to default", even sitting at defaults where it would do nothing. It now appears only when `canReset` says a setting has been changed, and because a group states its own separator, the last settings group stops drawing a rule as soon as the reset group goes away. `canReset` is the caller's to compute — the hook is handed opaque children and a callback, so only the caller knows what its defaults are. The games panel compares against DEFAULT_PICKER_LAYOUT, now named once and used by GamePicker's onReset as well so the two can't drift; the extensions panel has only the bundled toggle, which is off by default. It reflects the settings the panel itself shows. GamePicker's onReset also puts the two sort orders back, but those have their own controls out on the page rather than in this panel.
Three things QA raised, all in the same corner of the UI. A row that opens a panel looked identical to one that runs an action, so it now carries a chevron on the right. It is decorative — `aria-haspopup` is what carries this to a screen reader, and the label stays the accessible name. Those rows also only opened on click. Pointing at one now opens it at once: a panel flickering open as the pointer sweeps down the menu is a smaller cost than a submenu that feels slow to answer. Headless UI's popover is driven by clicks alone, so hovering activates the button the same way the keyboard already does rather than reaching into the machine. Closing is still held back 150ms, with timers on both the row and its panel, because the panel is portalled: leaving the row fires even when the pointer is heading into the panel, so arriving there has to call the close off. Both that deferred close and the click that would otherwise toggle a hover-opened row shut ask the button for `aria-expanded` rather than trusting the value captured when they were scheduled. With no delay to wait through, a click can land inside that window, and a stale reading would toggle the wrong way. Resetting the display options no longer puts the panel away. It is an adjustment like any other row there, and leaving it open is what lets the picker and switches visibly move back; the link removes itself instead, having nothing left to undo. The Popover demo gains a menu with two adjacent submenu rows — a shape the app has nowhere yet — so the pointer travelling between them, and off onto an ordinary row, stays exercised somewhere.
…opens Arrowing through a popover menu moved something invisible. Its rows carry the dropdown item styling, which highlighted on `:hover` or on the active class Headless UI sets for a `Dropdown` — and suppressed the focus ring without putting anything in its place. A `PopoverMenu` roves real focus instead, so nothing answered the keyboard. The menu now tracks its active row the way Headless UI does for a `Dropdown`, and the highlight reads from that alone. Focus feeds it: arrowing moves focus, hovering a row takes focus, and opening the menu focuses the first row. Leaving `:hover` in the styling would have fought that — park the pointer on one row, arrow to another, and both light up — so it is opt-in now, for a lone row with nothing tracking it. The reset-widths row in a table's column toggle is the only one of those. Opening a panel of controls also left focus behind on the row or button that opened it, so the controls it holds were a tab away rather than to hand. Headless UI focuses a panel on request, which also closes it when focus leaves; that turns the pointer returning across the row into a dismissal, since hovering a row takes focus, so a row no longer reclaims focus while its own panel holds it.
insomnious
approved these changes
Aug 12, 2026
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.
Toolbar
IToolbarAction takes either an onClick or a panel. A panel action opens a floating surface anchored to whichever control the group rendered — the button while it fits the row, its row once it has collapsed into the overflow kebab, so the panel never needs to know where it was opened from.
Toolbar opening from button

Toolbar opening from overflow menu

PopoverMenu
New shared primitive for a menu filling a popover panel: menu roles, focus on open, roving arrow keys, and rows that can open a surface of their own. Actions arrive in groups separated by a rule; empty groups are dropped. Built on Popover rather than Menu, because a Headless UI Menu closes the moment focus reaches a nested surface. The toolbar overflow and both header menus render it.
A row with a panel carries a chevron and opens on hover immediately;closing is held back 150ms so crossing the gap into the panel doesn't dismiss it. → opens, ← and Escape back out.
Surfaces: .nxm-popover-panel-dropdown sizes a list of menu rows, .nxm-popover-panel-controls a panel of controls.
Header
One menu behind the avatar — View profile on web / Refresh user info / Help ▸ / Logout — where Help flies out to Help centre, View logs, Send Feedback, Report Bug and About.
Signed out, the premium slot shows Log in and the avatar becomes a
⋯button opening those help options flat, so logs stay reachable without an account.Header/ is grouped into help/, notifications/, premium/, profile/.
Help menu now inside profile menu

Display options
useDisplayOptionsAction returns a toolbar action, so display options ride the overflow with every other action. Panels are composed from PopoverPanelGroup rows. The reset link shows only when a setting differs from its default, and leaves the panel open so the rows visibly move back.
Not covered