fix(ButtonGroup): keep arrow keys inside a member's open menu, plus the ButtonGroup audit - #5355
Merged
cixzhang merged 2 commits intoAug 24, 2026
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsButtonGroup (@astryxdesign/core) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
cixzhang
force-pushed
the
night-watch-component-audit/ButtonGroup/2026-08-23
branch
from
August 23, 2026 11:35
28a437c to
68afbcc
Compare
A member can own a layer, and useLayer renders it inline inside the group rather than portaling it, so keys pressed inside an open DropdownMenu bubble to the group's list-focus handler. The focused menu item is not a group item, so the group read 'no current index' and focused its first or last button, leaving the menu open with focus outside it. Scoped through useListFocus's own boundarySelector rather than a bespoke guard. The boundary is the group's own root or any layer, whichever is nearer to the event target, so a member's open menu owns its keys and a group rendered inside a Popover, ContextMenu, HoverCard or Toast still owns its own. A guard that bailed on any [popover] ancestor gets the second case wrong: it kills arrow navigation for every group inside a layer. Also reflects elevation through themeProps, so a theme can reach the group's elevation levels the way it already reaches size and orientation. Adds the keyboard tests the component never had (arrows, wrap, Home/End, vertical, RTL, disabled skip, a member's open menu, a group inside a popover), the disabled/elevation/long-label stories the audits need to see those states, an RTL audit target, and labelling guidance in the docs.
cixzhang
force-pushed
the
night-watch-component-audit/ButtonGroup/2026-08-23
branch
from
August 23, 2026 11:37
68afbcc to
e7d12eb
Compare
github-actions
Bot
deleted the
night-watch-component-audit/ButtonGroup/2026-08-23
branch
August 24, 2026 04:19
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.
ButtonGroup, full audit
First audit of this component. Graded end to end against Component Audit Rubric v1.7.1, in Chromium against a static Storybook build.
Corrections since the first push
A review of this PR found that its headline fix was a regression, plus three lesser defects. All four are corrected here.
[popover]ancestor. SinceuseLayerwraps every layer's children in[popover], a ButtonGroup rendered inside a Popover, ContextMenu, HoverCard or Toast matched an ancestor popover and swallowed all of its own arrow keys. The group stopped working entirely in those contexts. It is now scoped throughuseListFocus's ownboundarySelector, and both cases hold at once. Measured in Chromium, three ways, below..not.toHaveFocus()calls, which pass when focus went nowhere at all, and in jsdom that is exactly what happened. It asserts positively now, and a second test covers the case the old guard broke.*VariantMapand MediaTheme verified.ButtonGroup/index.tsdeclares no variant map,elevationis a closed union over a closed style record, and MediaTheme was never rendered.Before: 72.0 / C, 4 open BLOCKs. Ledger row recorded before any change: f5fac81.
After: 82.8 / B, 0 open BLOCKs. Re-derived, not patched:
(4x16 + 4x14 + 4x14 + 4x12 + 5x6 + 4x4 + 4.5x8 + 4x8 + 4.5x8 + 4x5 + 4x5) / 5 = 82.8. Held until this lands, so itscommitcan name a realmaincommit.elevation. Short of 5 on the variant map and MediaTheme.§3, §4, §5a, §7, §9 and §10 are unchanged.
Fixed
A4 (BLOCK): arrow keys crossed a layer boundary in both directions
useLayerrenders a DropdownMenu's menu inline inside the group rather than portaling it, which is the same factIS_LAST_ITEMrelies on. So an arrow key pressed inside the open menu bubbles to the group'suseListFocushandler,getCurrentIndex()returns -1 for the focused menu item, and the group focuses one of its own buttons. The menu stays open with focus behind it.The first fix here was a hand-rolled
closest('[popover]')bail-out in a wrapping keydown handler.closest()walks up, and every layer carries[popover], so it also matched when the group itself was inside a layer. That killed arrow navigation for every ButtonGroup inside a Popover, ContextMenu, HoverCard or Toast.The mechanism was already there, and
useListFocusis where it belongs.boundarySelectorscopes both halves of the problem in the hook that every other list consumer shares: an item counts as the group's own only when its nearest boundary ancestor is the group's container, and a key event is ignored when its nearest boundary ancestor is not. The boundary is'[role="group"], [popover]': the group's own root, or any layer, whichever is nearer to the event target. Naming the group's own root is the whole difference, because a group inside a Popover reaches its ownrole="group"before it reaches the enclosing[popover].packages/core/src/ButtonGroup/ButtonGroup.tsx:173-183. The bespoke wrapper is gone; the group handshandleKeyDownstraight toonKeyDownagain.Measured in Chromium, on one page carrying both scenarios, built three times from the same source tree with only
ButtonGroup.tsxswapped:b594d5c5fc4button "Allow once", focus leaves the open menuclosest('[popover]'))menuitem "Always allow"boundarySelector)menuitem "Always allow"Only the boundary passes both. The menu is still open in every B measurement, so B is about where focus went, not whether the layer survived.
T6 (BLOCK):
elevationselected between StyleX style objects but was missing fromthemeProps.The group rendered
data-sizeanddata-orientationonly, so a theme had no hook for the elevation levels. Verified by injecting the rules a theme would emit:.astryx-button-group[data-elevation="high"]matched nothing before and matches now.ButtonGroup.tsx:197,ButtonGroup.doc.mjs:18.V6 (BLOCK): no keyboard test existed.
28 tests, none of them keyboard, on a component that wires arrow navigation, wrapping, Home and End, and RTL flipping. Eight added: arrows and wrap, Home and End, vertical orientation, RTL, disabled-member skip, a member's open menu, and a group inside a popover. The last two are the boundary, and each is red against one of the two broken builds above: the open-menu test fails on the parent commit, the in-popover test fails on the first fix, both pass here. Verified by swapping the source in place.
V10 (BLOCK): no story rendered a disabled member.
So
disabled-cursor-auditanddisabled-hover-auditboth reported a pass over zero elements for this component (Checked 0 disabled element(s) across 0 of 12 stories), and A20 and A21 were unmeasurable. With theDisabledstory they check 5 elements: no disabled member paints a hover state, and every one answers the pointer withdefaultat all five hit-test points, which is v1.7.1's expected value.X10, X12, X16, I15, I17 (FIX).
isDisabledwas the one documented prop no story exercised. AddedDisabled,ElevationandLongLabelsstories; added a curatedtargets.jsonentry, sortl:audit --filter ButtonGroupnow reportsCUR RTL-ready ButtonGroup {"D2":"pass"}instead of nothing; added labelling guidance tousage.bestPractices.Why §2 is 4 and not 5
T6 is genuinely closed, and everything else in the 5/5 anchor holds: every visual value is a token,
themePropssits on the painting element carrying every style-driving prop,targets[]moved in the same diff, both Vitest guards pass unmodified, and no new DOM node appeared. Two things in that anchor do not hold.*VariantMapin the component's public subpath barrel with the prop type derived from it.ButtonGroup/index.tsdeclares none, andelevationisElevation, a closed union of four strings, indexed into a closedelevationStylesrecord. A theme cannot add a level.MediaThemeverified with screenshots. The contact sheet has the first three.Neither is a new BLOCK. T19 blocks a closed map on a themeable axis, and
elevationresolves to wholeshadowVarstiers, which makes it a closed system axis in the sense P18 carves out forsize, not a theme-extensible one. So the honest read is 4: the 5 anchor minus two of its clauses.Needs review
The roving-tabindex question, and which pattern this component claims (A2).
The group renders
role="group", keeps every member in the tab order (three tab stops, measured), and also wires arrow-key navigation. That combination matches no APG pattern:Toolbarin this repo pairs arrow navigation withrole="toolbar"andhasRovingTabIndex: true. Which model ButtonGroup should take is the open design question in #4238, so it is not settled here.#4238 rests on a false premise, and its text is not corrected from this PR.
It states that ButtonGroup "already does BOTH the connected visual AND roving via
useListFocus". It does not.ButtonGroup.tsx:173callsuseListFocuswithouthasRovingTabIndex, so the hook only moves focus and never touchestabindex; there is no single tab stop, and therefore no two roving layers to fight. Anyone picking that issue up would be working from a premise that does not hold. Left for @cixzhang to decide, because rewriting a design issue's framing is not an audit PR's call, but it should not sit there unflagged.The same T6 shape sits in four sibling components, and stays there.
Card,Button,BannerandChatComposerall applyelevationStyles[elevation]withelevationabsent from theirthemePropscall, all from #4251. Not touched here on purpose. A four-component sweep is a different change with a different blast radius and a different reviewer, and folding it into an audit of one component would make both harder to review and harder to revert. Each will come up in its own audit, or in one sweep diff that owns the whole shape.A member inside a group has no pressed state (B4, §5b).
Measured with the pointer down and
:activematching:transformstaysnonebecauseButtondropsstyles.pressableinside a group, and the background is identical to hover. A standalone Button getsscale(0.98). Whether a connected member should depress, and how, is a design call rather than a correction.Long labels overflow their container (R1, I18).
The group is
inline-flexand members do not wrap or truncate, so a group whose labels are wider than its container overflows it: 376px of members in a 280px container, and at a 320px viewport the document scrolls sideways (scrollWidth392 againstclientWidth320). The newLongLabelsstory shows it. Wrapping, truncating or allowing members to shrink are three different answers, so this needs a decision.ref={mergeRefs(ref, listRef)}is built during render (ButtonGroup.tsx:194).Left alone deliberately: #5267 migrates this exact line to
useMergedRefsand #5269 lints the pattern.A coarse pointer gets no larger hit area. Icon members stay 32x32 under emulated
any-pointer: coarse. Over the 24px floor, so not a finding against the group, and the sizing belongs to Button and IconButton.Full contact sheet, 44 captures across 12 stories in light and dark, RTL, the y2k theme and forced colors: assets/pr-5355.
Checks
pnpm buildandpnpm lint:strictclean. The ButtonGroup suite (36 tests) plus every otheruseListFocusconsumer, since the fix moved onto the shared hook:AvatarGroup,Breadcrumbs,ContextMenu,DropdownMenu,NavMenu,Outline,Pagination,SegmentedControl,SideNav,TabList,TabMenu,Toolbar,TopNav, the wholehooksdirectory, andPopover,ToastandHoverCardfor the layer side. 1198 tests over 45 files, all green.check:sync,check:changesets,check:use-client,a11y:audit --components ButtonGroup(0 violations, baseline unchanged),rtl:audit --filter ButtonGroup, and both disabled sweeps also pass locally.Night Watch — Component Auditor