Repository navigation
feat(ButtonGroup): a finger's slide runs the button it lifts on - #8657
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
This needs an engineering owner's decision before it can be approved: ButtonGroup's slide-and-lift tracking relies on the family:action-groups record and the spec:AST-094 DEC-17 amendments carried from #8622, which are not on main yet, and the current AST-094 FR11 keeps a touch control's activation on the browser's click. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or by landing #8622 first; the decision should also settle whether a mouse tracks, since the AST-094 menus section still says a mouse keeps today's behavior, and whether popup buttons inside a group are tracked. Independently of that decision, these need changes:
- Against
mainthis diff is about 32,400 changed lines, of which the ButtonGroup change is 811; please rebase once #8083, #8635 and #8622 land. - A click-opened popup button inside a ButtonGroup, such as DropdownMenu's bottom-sheet trigger, loses its pointer click because the group's press accepts every target (
ButtonGroup.tsx:227); limitshouldTrackto tracked buttons and the hit layer, and add a test. ButtonGroupContextValueis exported, so its new requiredtracksPressis a public type change; keep it internal or document it.
[Automated review]
| highlightOnsetMs: 0, | ||
| highlightExpansion: 0, | ||
| resolveTarget: resolveGroupHitTarget, | ||
| shouldTrack: () => isGroupLaidOut(groupRef.current), |
There was a problem hiding this comment.
This accepts a press on any target in the group, including a button the group leaves untracked (one with aria-haspopup). Once the group's long press begins (at once outside a scrolling container, after 150 ms inside one), the arena claims the press and suppresses its click (gestures/GestureArena.ts:597-603, :865-872), and the tracker finds no marked button, so a click-opened popup button in the group, such as DropdownMenu's bottom-sheet trigger, loses its pointer click. Accepting only presses on a tracked button or on the group's own hit layer keeps the 44 px target working:
shouldTrack: target =>
isGroupLaidOut(groupRef.current) &&
(target === groupRef.current ||
(target instanceof Element && target.closest(TRACKED_BUTTON) != null)),A case where a click on an aria-haspopup button in a group still runs its handler would cover it.
| * (`family:action-groups`): each button it tracks carries the group item | ||
| * marker (`utils/pressFeedback.ts` PRESS_GROUP_ITEM_ATTRIBUTE). | ||
| */ | ||
| tracksPress: boolean; |
There was a problem hiding this comment.
ButtonGroupContextValue is exported from @astryxdesign/core/ButtonGroup, so this required field is a public type change (a caller-typed value of it stops compiling), while the changeset says there is no API change. Button is its only reader; could it stay off the exported type?
|
Posted by Cindy's agent on her behalf. Thanks for the action-group work. Two review points:
The same points apply to the ToggleButtonGroup PR (#8660). If this stays idle, we'll make the change after about 30 minutes, keeping your authorship. |
|
Both points are done in this PR and in #8660: the groups call |
There was a problem hiding this comment.
This needs an engineering owner's decision before it can be approved: ButtonGroup's slide-and-lift tracking relies on the family:action-groups record and the spec:AST-094 DEC-17 amendments carried from #8622, which are not on main yet, and the current AST-094 FR11 keeps a touch control's activation on the browser's click. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or by landing #8622 first; the decision should also settle whether a mouse tracks, since the AST-094 menus section still says a mouse keeps today's behavior, and whether every popup button in a group stays untracked. Independently of that decision, these need changes:
- Against
mainthis diff is about 32,500 changed lines, of which the ButtonGroup change is 863; please rebase once #8083 and #8622 land. - A Ctrl-, Cmd- or Shift-click on a link member (
<Button href>) in a group navigates the current page and its handler sees no modifier, because the lift runs a syntheticclick(); leave modified presses to the browser and add a test. - The head now conflicts with
mainin the AST-094 record, where #8515 added DEC-19 at the place this branch adds DEC-17. - In development, a desktop page with a ButtonGroup logs a warning that touch work stays inactive under a fine pointer although the group tracks a mouse, and the changeset should name the old behavior and the way back.
[Automated review]
| const isGroupTracked = | ||
| (groupTracksPress && !opensPopup) || PRESS_GROUP_ITEM_ATTRIBUTE in props; |
There was a problem hiding this comment.
This marks link members (<Button href>) too. The group press begins for any left-button mouse press whatever its modifiers, and the lift runs the link through a synthetic click() (hooks/actionGroupTracker.ts, clickAndReport) while the browser's own click is swallowed, so a Ctrl-, Cmd- or Shift-click on a link member navigates the current page and onClick sees no modifier. In Chromium a Ctrl-click on a #docs link member sets this page's hash on this branch and leaves it alone on main. Leaving a press that carries a modifier key to the browser (as the host already does for a non-primary button) would keep new-tab and new-window clicks; a case for a modified click on a link member would cover it.
| if (options.perEventPointer === true) { | ||
| this.perEventRecognizers.add(recognizer); | ||
| } else if (!this.isCoarse) { | ||
| this.warnFinePointer(); |
There was a problem hiding this comment.
An action group attaches here with actionGroup: true but without perEventPointer, so in development the first enabled ButtonGroup on a desktop page logs "It stays inactive until the pointer is coarse" although the group tracks a mouse under a fine pointer; ButtonGroupTracking.test.tsx's own mouse case prints it. Could the warning skip an action-group attachment?
|
|
||
| Rejected: the 150 ms delay on every menu row. | ||
|
|
||
| ### DEC-17 — Every group of sibling actions tracks a held pointer the same way |
…s watcher test fixed)
… the meta#8637 helper on fZ)
…taken only on tracked buttons) into the toggle group's tracking The toggle group takes the same two changes in this merge, since the module it used is gone: it calls utils/hitArea.ts directly (resolveHitTarget for a finger's press, watchHitArea with writeHitInsets for its hit layer), and its tracking takes a press only on a toggle or on the group itself, so any other member keeps its own click.
There was a problem hiding this comment.
Changes requested: under the touch presentation, ButtonGroup's slide-and-lift, its 44 px finger targets and the unchanged mouse press match the action-groups family, the record's compatibility lines are approved at this head, and the duplicated pointer list from the last review is gone. One fix remains:
- When the group's root is wider than its buttons (a flex child in a column, as in the
SlideAcrossstory),takesPressinhooks/useGroupPressTracking.tstakes every touch press on the root and the hit layer covers the root's whole width plus its top and bottom band, so a tap beside the buttons runs nothing and its click no longer reaches the page or a clickable container; please take a press on the root only when a member's grown target holds it, keep the layer within the members' extent, and add a test for a root wider than its members.
[Automated review]
| if (box.width <= 0 || box.height <= 0) { | ||
| return false; | ||
| } | ||
| return target === group || isTrackedByGroup(target as Element); |
There was a problem hiding this comment.
This takes every press whose target is the root, not only one on the hit layer next to a member. When the root is wider than its buttons (a flex child in a column, as in the SlideAcross story: root 358 px, buttons 180 px), a tap on the empty part of the root or in the layer's band beside it is claimed, resolveNearestAction finds no member, nothing runs, and the browser's click is cancelled. In Chromium with real touch, a click listener on the story's wrapper heard both taps on the base and neither here. family:action-groups FR5 makes the 44 px target each member's hit expansion. Suggest accepting a root press only when resolveNearestAction over the tracked members holds the point, and letting groupGrowth shrink the layer on a side where the root reaches past its edge member.
There was a problem hiding this comment.
Done in a667721: the group takes a press on its own box only where a button's target holds it, and the layer ends at the outer buttons' targets. A new test covers a root wider than its buttons.
There was a problem hiding this comment.
Changes requested. The touch-only slide and lift, the 44 px finger targets and the unchanged desktop press hold up in the tests and in a real-touch Chromium run, and taps beside a stretched group now reach the page. Two things block landing:
- Under the touch presentation, a tap on a disabled button that has a tooltip no longer opens its reason, because
takesPress(hooks/useGroupPressTracking.ts:93-95) takes every press on a marked member and Button marks disabled members too, so the click the tooltip opens on is cancelled (component:TooltipFR5); let that press keep its click and add a test that the reason opens. - At 2,078 authored lines the pull request is over the 2,000-line limit while carrying a change that stands on its own, so please move the gesture host's kept-lift selection (
gestures/GestureHost.ts,utils/selectionLock.tsand its tests) into its own pull request that lands first.
Two small cleanups, not blocking: drop "The ref's cleanup stops the watch." at hooks/useGroupPressTracking.ts:150, and call trackedTargets directly instead of the growingMembers wrapper.
[Automated review]
| return false; | ||
| } | ||
| if (target !== group) { | ||
| return isTrackedByGroup(target as Element); |
There was a problem hiding this comment.
A press on a disabled member is taken here too: Button marks disabled members (Button/Button.tsx:663-666), so a tap on a disabled button that has a tooltip is claimed, the tracker runs nothing, and the browser's click that opens the tooltip's reason is cancelled (component:Tooltip FR5). In Chromium with real touch the base opens "Nothing to copy" and this head shows nothing. Letting a press on a disabled member keep its click (decline it here, or write no mark on a disabled Button) fixes it; a case that the lift is not cancelled would cover it.
There was a problem hiding this comment.
Done in d57466c: a press on a disabled member keeps its click, so its tooltip opens its reason. A new test covers it.
| // A finger's target grows toward 44 px through the hit layer, not the | ||
| // members. The watch shares the page's one `(pointer: coarse)` listener | ||
| // and grows a target only under a coarse primary pointer, so the layer | ||
| // exists only then. The ref's cleanup stops the watch. |
There was a problem hiding this comment.
Non-blocking: "The ref's cleanup stops the watch." narrates the cleanup below; the comment reads complete without it.
There was a problem hiding this comment.
Changes requested. Under the touch presentation, ButtonGroup's slide and lift, its 44 px finger targets and the unchanged desktop press match the action-groups contract in the tests and in a real-touch Chromium run, and a tap on a disabled member with a tooltip opens its reason again. One thing blocks landing:
- At 2,260 authored lines the pull request is over the 2,000-line limit without the stack exemption, because #8660 carries a different
Stack:line and its head does not contain this one, and the diff still includes #9848's kept-selection commit while this pull request's own part is 2,093 lines; please either make the stack declaration hold (the identicalStack: #9848 → #8657 → #8660line in #8660, with #8660 rebased onto this head) or land #9848 first and move one more separable part, such as thegroupGrowthandcheckAtPresshit-area extension with its tests, into its own pull request.
[Automated review]
|
/approve-spec d57466c |
There was a problem hiding this comment.
This pull request is reviewed as part of its stack; see the review on #8660.
|
/approve-spec 5deb446 |
There was a problem hiding this comment.
Changes requested. The touch-only slide, the 44 px finger targets and the unchanged mouse press follow the action-groups family record, and the ButtonGroup record edit is owner-approved at this head. Two fixes remain:
- At 2,100 authored lines the change is over the 2,000-line limit, and the stack exemption does not apply because #8660 still declares
Stack: #9848 → #8657 → #8660and is built on this pull request's previous head, so either update #8660's Stack line and rebase it onto this head, or move the 44 px hit layer into its own pull request. - Miss:
trackedTargetsinhooks/useGroupPressTracking.tsrepeats the shared tracker'smeasureloop inhooks/actionGroupTracker.ts, so the group's root-press and hit-layer decisions can drift from what the tracker tracks; export one measuring function from the tracker, use it in both places and delete the copy.
Non-blocking: the Chinese description in ButtonGroup.doc.mjs says the slide works on touchscreens, but it applies only under the touch presentation (a coarse primary pointer).
[Automated review]
| } | ||
|
|
||
| /** The tracked, enabled members with a box, each box read once. */ | ||
| function trackedTargets(group: HTMLElement): ActionGroupTarget[] { |
There was a problem hiding this comment.
Miss: this repeats the shared tracker's measure loop (hooks/actionGroupTracker.ts, the same query, filter and rect read over the same members), so takesPress and the hit layer's growth can drift from what the tracker tracks. Export one measuring function from actionGroupTracker.ts, call it here and in the tracker, and delete this copy.
There was a problem hiding this comment.
Done in 99190b1: actionGroupTracker.ts exports measureActions, which the tracker's measure and the group's trackedTargets both call; the copy is gone.
| name: 'ButtonGroup', | ||
| displayName: 'Button Group', | ||
| description: '将多个按钮组合在一起,带有连接样式:共享边框、正确的圆角处理(仅外边缘),以及水平或垂直方向。', | ||
| description: '将多个按钮组合在一起,带有连接样式:共享边框、正确的圆角处理(仅外边缘),以及水平或垂直方向。在触摸屏上,按下时按钮组像菜单一样跟踪按压:按下一个按钮并滑过其他按钮,高亮随之移动。松开时所在的按钮执行,在组外松开则什么都不执行。鼠标按下与原生按钮相同。使用手指时,每个按钮的触控目标向 44 px 扩展,最多到与相邻按钮间距的一半,按钮本身不变大。组内的菜单触发器保留自己的打开方式。', |
There was a problem hiding this comment.
Non-blocking: 在触摸屏上 says touchscreens, but the slide applies only under the touch presentation (a coarse primary pointer), so a touchscreen laptop gets none. Match the English "Under the touch presentation".
There was a problem hiding this comment.
Done in 99190b1: it now reads 在触摸呈现下(主要指针为粗指针时), here and in ToggleButtonGroup's docs.
|
/approve-spec 99190b1 |
There was a problem hiding this comment.
Changes requested. Under the touch presentation, ButtonGroup's slide-and-lift, its 44 px finger targets and the unchanged mouse press follow the action-groups family record, an engineering owner approved the ButtonGroup record edit at this head, and the earlier shared-measuring and Chinese-wording points are fixed.
- The change is 2,169 authored lines, over the 2,000-line limit, and the stack exemption does not apply because #8660's Stack line is not identical to this one and #8660 is built on this pull request's previous head; rebase #8660 onto this head with the identical
Stack: #8657 → #8660line, or move the 44 px hit layer into its own pull request.
CI skipped every job on this draft, so its unit, browser and lint lanes have not run at this head.
[Automated review]
There was a problem hiding this comment.
This pull request is reviewed as part of its stack; see the review on #8660.
|
/approve-spec a5704de |
There was a problem hiding this comment.
Changes requested. Under the touch presentation, ButtonGroup's slide-and-lift, its 44 px finger targets and the unchanged mouse press follow the action-groups family record, and the ButtonGroup record edit is owner-approved at this head. One item blocks:
- At 2,175 authored lines this pull request is over the 2,000-line limit, and the
Stack: #8657 → #8660declaration no longer holds because #8660 is built on this pull request's previous commit; rebase #8660 onto this head, or move the 44 px hit layer (groupGrowth,checkAtPress,groupHitArea.stylex.tsand their tests) into its own pull request.
[Automated review]
There was a problem hiding this comment.
This pull request is reviewed as part of its stack; see the review on #8660.
There was a problem hiding this comment.
Changes requested at this head when the review began: ButtonGroup's touch slide-and-lift and its 44 px finger targets follow the action-groups family rules, and the unit tests prove them; one scope item remained.
- The pull request is 2,213 authored lines, over the 2,000-line limit, and the stack exemption did not apply because #8660 was built on this pull request's previous commit; rebase #8660 onto this head, or move the shared hit-area and action-group tracker extensions into their own pull request.
#8660 has since been rebased onto this head, which restores the stack: this pull request is now judged in the stack review on #8660, and it lands only once that review approves it. CI has not run on this head because the pull request is a draft.
[Automated review]
|
/approve-spec 34e0781 |
There was a problem hiding this comment.
This pull request is reviewed as part of its stack; see the review on #8660.
|
/approve-spec f738b8d |
There was a problem hiding this comment.
This pull request is reviewed as part of its stack; see the review on #8660.
|
/approve-spec 2387032 |
There was a problem hiding this comment.
This pull request is reviewed as part of its stack; see the review on #8660.
There was a problem hiding this comment.
Changes requested. Under the touch presentation, ButtonGroup's slide-and-lift, its 44 px finger targets and the unchanged mouse press follow the action-groups family record, and the ButtonGroup record edit is owner-approved at this head; one item blocks:
- Regression: the group's own hit-area callback ref (
packages/core/src/hooks/useGroupPressTracking.ts:140-173) andpackages/core/src/utils/groupHitArea.stylex.tsduplicate main'suseTouchHitAreaandtouchHitAreaStyles.layer, which landed after this code was written (architecture:react-component-runtimeINV16), and the copy already drifts: under a coarse pointer a group given an inline callbackrefdrops its hit layer,position: relativeandisolation: isolatefor a frame on every parent re-render, so please giveuseTouchHitAreathegrowthandcheckAtPressoptions and call it once, or say in the description why it cannot serve the group.
The size is not a blocker while the #8657 → #8660 stack holds, and CI's build and test jobs have not run on this draft head yet.
[Automated review]
| // and grows a target only under a coarse primary pointer, so the layer | ||
| // exists only then. | ||
| const [isGrowing, setIsGrowing] = useState(false); | ||
| const hitAreaRef = useCallback( |
There was a problem hiding this comment.
Miss: this callback ref and utils/groupHitArea.stylex.ts repeat the shared useTouchHitArea (utils/touchHitArea.ts) and touchHitAreaStyles.layer (architecture:react-component-runtime INV16). They have already drifted: the shared hook keeps one watch when a re-render hands the element a new merged ref, so its band never drops. Here a caller's inline ref makes the cleanup run setIsGrowing(false), so the root loses the band, position: relative and isolation: isolate until the next measure frame, and the page is re-read. Please extend useTouchHitArea with the growth and checkAtPress options watchHitArea now takes and call it once here, or say why it cannot serve the group.
There was a problem hiding this comment.
Done in a0f9257: useTouchHitArea takes growth and checkAtPress options, the group calls it once, and groupHitArea.stylex.ts is gone. A new case covers the inline-ref re-render.
There was a problem hiding this comment.
Approval waits on one engineering-owner decision. The touch-only slide, the 44 px finger targets and the unchanged mouse press otherwise hold up, and the earlier duplicate hit-area finding is fixed: the group now calls the shared touch hit area once.
What needs to happen next:
- An engineering owner approves or rejects the reworded
component:ButtonGroupcompatibility lines at this head. They are the only spec authority for the group root becomingposition: relativeunder the touch presentation, which changes where an absolutely positioned child of the group places for touch users. The earlier approval covered the previous wording. To approve, an engineering owner (the author is one) comments/approve-spec <full head SHA>on this pull request.
No blocking code finding remains. One non-blocking comment suggestion is inline, on a line an earlier review missed. #8660 does not contain this head yet, so this pull request was reviewed on its own.
[Automated review]
| ): GroupPressTracking { | ||
| useActionGroupTracking(ref, { | ||
| itemSelector: MEMBER_ELEMENTS, | ||
| // A disabled member is the tracker's neighbour, measured once per press. |
There was a problem hiding this comment.
Miss (non-blocking, comment style): this line sits above an isItem that also drops a hidden member by reading its box, and each tracked member's box is read again by measureActions, so "measured once per press" does not describe the code below. Suggested: // A disabled member is the tracker's neighbour. A hidden member has no box, so it is no item.
Under the touch presentation, ButtonGroup's buttons are an action group for a finger and a pen. The button under the pointer highlights at once, the highlight follows it, and the lift runs the button under it; a lift outside the group runs nothing. A finger picks the nearest button whose 44 px target holds it, through a hit layer on the group, so small buttons don't grow. A mouse press acts as a native button's, as before. A menu trigger keeps its own press and opening, and the keyboard is unchanged. After a slide, focus does what a click on the lifted button does.
|
/approve-spec 46240e9 |
There was a problem hiding this comment.
Approved pending CI: CI has not run on this head yet.
Under the touch presentation, ButtonGroup's slide-and-lift, its 44 px finger targets and the unchanged mouse press hold up, and the hit layer now sits below the buttons, so each button keeps the pixels on its own edges. An engineering owner approved the component:ButtonGroup record edit for this head, which settles the touch-only position: relative root.
Next steps:
- Mark the pull request ready for review; because the branch lives in a fork, a maintainer then adds the
ci:runlabel so CI runs. - Rebase #8660 onto this head: it is still built on the previous commit, so this pull request was reviewed on its own.
No blocking finding remains.
[Automated review]
|
/approve-spec 46240e9 |
There was a problem hiding this comment.
Approved. CI needs a rerun before merging: one WebKit interaction shard failed on a motion animator test this pull request does not touch.
Under the touch presentation, ButtonGroup's slide-and-lift, its 44 px finger targets and the unchanged mouse press hold up. An engineering owner's spec approval covers the component:ButtonGroup record edit at this head, which settles the touch-only position: relative root. The latest commit only merges main with no conflict, and the touched tests and Core's typecheck pass on the merged tree.
Next steps:
- Rerun the failed WebKit interaction shard. It failed in
packages/core/src/motion/__tests__/animator.interaction.browser.spec.ts(the busy-spring proof), and every ButtonGroup browser case passed in WebKit and Chromium. - #8660 is still built on this pull request's previous commit, so the
Stack:line does not hold yet. Rebase #8660 onto this head.
No blocking finding remains.
[Automated review]
Stack: #8657 → #8660
#9848 merged as 7b87c5c, so this pull request carries only its own commit. On main bfc1dcc, main's DropdownMenu change gives
DropdownMenuPopoverapressTouchModelprop, and this commit'sPressGroupContextwrap sits around it. Main's hit-area pass measures in layout px, so a group's growth reads its members through the same layout map (toLayout), and a group in an opening layer keeps its insets. Main's #10022 renames the focus-return import beside this commit'sPressGroupContextimport, and main's #8085 addsownsDescendantTouchesto the hit-area watch beside this commit'sgrowthandcheckAtPress. The rebase keeps both sides: the press check readscheckAtPressper watch, and main's descendant rule per element. The group's hit layer is main's shared touch hit area (#8085):useTouchHitAreatakes agrowthand acheckAtPressoption, which the group passes, and the root appliestouchHitAreaStyles.layer(architecture:react-component-runtimeINV16).Size: about 2,220 authored lines, of which about 1,600 are this change's own tests (unit and real-touch browser cases for the slide, the 44 px targets, the wide group, a disabled member, the read counts and the shared hit area's options). The code under them has no other user, so the tests can't land without it.
User need
Part of the UIKit-parity work. On an iPhone you can press one button in a row and slide to another, and the one you lift on runs. In a ButtonGroup a slide runs nothing, and its 28–32 px buttons are under the 44 px a finger needs.
Problem and solution fit
family:action-groupslists ButtonGroup as a member. Its FR1 (decided by the spec owners on 2026-11-03, DEC-1) scopes the slide to the touch presentation: a finger and a pen slide, and a mouse press acts as the platform's native button does.spec:AST-094FR13: a coarse primary pointer), the shared tracking (useActionGroupTracking) follows a held finger or pen. The lift runs only the button under it, and a lift outside runs nothing.groupGrowthinutils/hitArea.ts), so an end button's target reaches past the group. A grown target never reaches into a neighbour's or a disabled button's. Where the group's box is wider than its buttons (a flex child that stretches), the layer ends at the outer buttons' targets, and the group takes a press on its own box only where a button's target holds it. So a tap beside the buttons keeps its click for the page. A press on a disabled button keeps its click too, so a tooltip on it still opens its reason. A press reads no box outside the group: the tracker reads the group's own members when a press begins, and the hit layer's watch does not re-measure the page then (checkAtPress: false). A hidden member (an empty box) is no tracker item and grows nothing.aria-haspopup. A member that opens a dialog, a grid or a tree joins the slide. A Button in a member's layer, such as a DropdownMenu's, is not tracked: the group's press context is layer-scoped, ascomponent:ButtonGroupFR10 asks.GROUP_PRESS_POINTERS: a finger and a pen), and Button writes them into the press-group mark, so the press controller leaves only those presses to the tracker (fix(AlertDialog): a mouse press on the actions acts natively again #9771's rule). AlertDialog uses the same list through the same context: its actions take the mark from Button, so its own copy of the list and its explicit mark are gone.useGroupPressTracking. Its hit layer is the shared touch hit area:useTouchHitArea(utils/touchHitArea.ts) gains thegrowthandcheckAtPressoptions for a group, and the root appliestouchHitAreaStyles.layerBelowChildren: the shared band, drawn as the root's::before, so its buttons paint over it. So the group keeps one watch, and its layer, when a re-render hands the root a new merged ref, such as a caller's inlineref. The touch presentation is read through main'suseTouchPresentation, one subscription per group; fix(CheckboxListItem): a row answers a finger the same wherever it lands #8212 makes that one shared watch per query.Current specification
family:action-groupsFR1–FR5 and DEC-1;component:ButtonGroupFR10;spec:AST-094FR11, FR13, FR26–FR28, FR43.Public delta and caller burden
No API or prop-default change. Released default preserved:
yesfor a mouse; under the touch presentation a press gains the slide. Under the touch presentation, an enabled group's root is alsoposition: relative, as the containing block of its hit layer. The layer is the root's::before(touchHitAreaStyles.layerBelowChildren), which cuts out the root's own box. The buttons paint after it, so each button keeps every pixel of its own box, edges included. So the layer needs no z-index, and the root noisolation. With a fine primary pointer the root keeps its stacking: noposition, no layer.Record change at this head, for the re-stamp:
component:ButtonGroup's Compatibility class now reads "…the gesture host'sdata-astryx-touch-actionmarker on the group root and the hit layer's inset properties, and an enabled group's root is alsoposition: relative, so absolutely positioned descendants resolve against it there". It read "…the hit layer's inset properties; while the hit layer grows a member's touch target, an enabled group's root is alsoposition: relativeandisolation: isolate, so absolutely positioned and z-indexed descendants resolve against it there". The changeset says, in the spec owners' words: "An enabled ButtonGroup root is position: relative under a coarse pointer, so absolutely positioned children anchor to the group."Census: absolutely positioned children inside a group. Under a coarse pointer an enabled group's root is
position: relative, so an absolutely positioned child anchors to the group there. The repository's every ButtonGroup and ToggleButtonGroup use was read: 124 groups in 29 files (Core's components, tests and docs, Lab, the Storybook stories, the docs site, the sandbox, and the CLI's templates and docs). Their children are Button (162), ToggleButton (75), IconButton (10), DropdownMenu (8), a nested ButtonGroup (2), Tooltip, HoverCard, Drawer, OverflowList, a plainbuttonand a test helper (1 each), with icons inside the members.position: relativeon main too, so they don't change.position, no layer. A browser case checks each pointer.The ButtonGroup record is owner-gated: it needs an engineering owner's
/approve-spec <head sha>.Design and alternatives
utils/hitArea.ts's nearest-target rule, as AlertDialog uses, so the groups can't drift apart.::before, where a single control's band is its::after. Drawn after the buttons, the clip path's edge gave the exact pixel on each button's top edge, and the group's start column, to the group, in Chromium and WebKit. Drawn before them, each button owns every pixel of its box.Testing
To check by hand, in the ButtonGroup "Slide across" story:
What was run:
presentedHitTesting6/6.touchHitAreaand the ButtonGroup tracking cases 47/47. This commit alone: typecheck;test:ui21,141/21,141. At the stack's tip:test:ui21,158/21,158;test:node16,638 passed, 2 expected failures, 71 skipped, and 1 failed: main's own import-hint case below, which passes alone at the tip, twice.CI=true pnpm linton the changed files, strict ESLint, Prettier,check:knowledge, changeset coverage andreview:prepass.At the previous head, c53 (this commit a0f9257, the stack's tip 414d6d6). This head changes the hit layer's pseudo-element and its tests, the changeset sentence and one comment; the runs above cover them. On main bfc1dcc, fresh
pnpm install --frozen-lockfile(exit 0):test:ui21,140/21,140.useTouchHitAreawithout options, two of this commit's cases fail (NOT-FOR-LANDING commit 3b865ec). "Keeps its hit layer, and reads the page no more, when a parent's re-render hands it a new inline ref" fails on the root's classes, which lose the hit layer after the re-render. "Passes an owner's growth, with the element, and its press check to the watch" fails on a missing press check. Both pass here. The group measures its members with the shared tracker'smeasureActions, which has its own unit case (document order, both filters, one read per box).gestures.interaction(66 passed in Chromium, 58 in WebKit, the rest that file's engine gates) andpresentedHitTesting6/6. Main's list touch-target cases (ListTouchHitArea), which use the watch's new descendant rule, pass 16/16 in Chromium and in WebKit. Button's a11y contract passes 20/20 in Chromium, and InfoTip's touch contract 4/4.test:ui21,157/21,157;test:node16,633 passed, 2 expected failures, 76 skipped, and 1 failed: main's own import-hint case "builds its fixture where a scan of the repo's docs running at the same time never finds it", which times out at 30 s because it imports every doc (its fix is test(cli): the docs-scan case reads the scan's walk, not every doc #10060; alone it passes at this head and at main), the bundle test included.CI=true pnpm linton the changed files, strict ESLint and Prettier on the changed TypeScript files,check:knowledge,review:preand changeset coverage pass.Evidence
Desktop, real Chrome, before (main) and after (this head), a mouse press on the first button released on the third:
Measured on both builds (the "Horizontal" story, a fine primary pointer): only Copy is
:activewhile held, no button carries the press paint or the group's marker, and nothing runs at the release.Writing rubric results
component:ButtonGroup: the compatibility lines name the DOM change under the touch presentation (the action-group item marker on each tracked button, the gesture host's touch-action marker on the group root, and the hit layer's inset properties). "Released default preserved" isyesfor a mouse, with the touch presentation's slide named. R1–R6 pass: present tense, with no research, references, rejected alternatives or open questions.Scope