Skip to content

Toolbar panels, popover menus, and a consolidated header account menu (laz-906) - #23929

Merged
r1chm8 merged 8 commits into
masterfrom
rm/toolbar
Aug 12, 2026
Merged

Toolbar panels, popover menus, and a consolidated header account menu (laz-906)#23929
r1chm8 merged 8 commits into
masterfrom
rm/toolbar

Conversation

@r1chm8

@r1chm8 r1chm8 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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
image

Toolbar opening from overflow menu
image

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
image

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

  • GamePicker resets sortUnmanaged to "popular" where the reducer default is "alphabetical".

@r1chm8
r1chm8 requested review from a team as code owners August 11, 2026 13:38
r1chm8 and others added 8 commits August 11, 2026 15:57
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.
@r1chm8
r1chm8 merged commit d4f7942 into master Aug 12, 2026
12 checks passed
@r1chm8
r1chm8 deleted the rm/toolbar branch August 12, 2026 08:10
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.

2 participants