Skip to content

feat(ButtonGroup): a held pointer is tracked across the buttons, and the one lifted on runs - #8657

Draft
vjeux wants to merge 1889 commits into
meta:mainfrom
vjeux:uikit/c01-buttongroup-tracking
Draft

vjeux wants to merge 1889 commits into
meta:mainfrom
vjeux:uikit/c01-buttongroup-tracking

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Stack: on #8083, #8665 and #8797. Review this PR alone: vjeux/astryx@uikit/c01-bg-review-base...uikit/c01-buttongroup-tracking

User need

Part of the UIKit-parity work. On iOS you can press one button in a row and slide to another; 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

What led to it: #8622, now merged, makes ButtonGroup a member of family:action-groups.

  • The shared tracker (useActionGroupTracking) follows a held finger, pen or mouse; the lift runs only the button under it, and a lift outside runs nothing.
  • Under a finger, each button's target grows toward 44 px without the buttons growing, never into a neighbour's or a disabled button's.
  • After a slide or a tap, under a finger too, focus goes where a click on the lifted button would.
  • A member that opens a menu or a listbox keeps its own opening; one that opens a dialog, a grid or a tree joins the slide.
  • A mouse press that starts on a keyboard-focused button lights only the button under the pointer.
  • A click keeps the page's text selection (fixed in feat(core): gestures attach for every pointer (spec:AST-094 FR43) #8665's gesture host; the test is here).
  • A member the group doesn't track, such as a menu's trigger, counts as a neighbour, like a disabled button, so no grown target reaches into it.
  • In scrolling content only a finger waits before lighting; a Ctrl-, Cmd- or Shift-click is left to the browser, so a link member opens a new tab.

Current specification

family:action-groups FR1–FR5; spec:AST-094 FR11, FR26–FR28.

Public delta and caller burden

No API change.

Owner question: should ButtonGroup offer a prop that turns tracking off, as the way back spec:AST-017 FR3 asks for? None exists today; the changeset says so.

Design and alternatives

  • Per-button handlers never see a press that began on a neighbour; fixed margins overlap.
  • Chosen: the shared tracker with utils/hitArea.ts's nearest-target rule, as in AlertDialog and ToggleButtonGroup, so the groups can't drift apart.

Testing

To check by hand, in the ButtonGroup "Slide across" story:

  1. Press Cut, slide to Paste, lift: the log reads "Pressed: Paste"; lifting outside adds nothing.
  2. On a phone or the iOS simulator, tap just below a button: it runs.
  3. Tab to Cut, then press Paste with the mouse: only Paste looks pressed.

What was run: unit and real-browser tests in Chromium and WebKit; the slide, 44 px target and mouse look fail without this change, and the scrolling, modified-click, focus, disabled-button, dialog-member, focused-press, kept-selection and untracked-neighbour cases without their fixes. Not from this PR: the touch-bundle root-entry check (#8671), and two node tests that fail the same way on main locally (story-tree.test.ts, release/cache.test.mjs's lock cases). These are local runs: GitHub Actions doesn't run fork pull requests right now.

Evidence

Size (gzip): ButtonGroup JS 8.4 → 29.7 KB (+21.3 KB), CSS +161 B; Button JS +195 B, CSS +75 B.

Writing rubric results

component:ButtonGroup: the compatibility lines name the DOM change (the action-group item marker on each tracked button, the hit layer's inset properties under a coarse pointer). R1–R6 pass: present tense, no research, references, rejected alternatives or open questions.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 30, 2026 1:52am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 30, 2026, 3:08 AM UTC

@github-actions github-actions Bot added needs:spec-owner-review Current knowledge records await owner approval needs:design-review Affects visuals — Design should review labels Oct 27, 2026
vjeux added 2 commits October 26, 2026 23:31
… the scroll brake for a finger only (spec:AST-094 FR11, DEC-21)

A mouse or a pen press paints at once, inside a scroller too; a mouse
press on a scroller a flick set moving still presses, as the brake is a
finger's. The brake case fails until the tracker brakes only a touch.
… (spec:AST-094 FR43)

A drag from the edge band begins a screen-edge pan for a finger only,
unless the caller allows another pointer. The mouse and pen case fails
until the recognizer defaults to touches.
vjeux and others added 12 commits October 26, 2026 23:38
… FR19, FR24, DEC-21)

Recognizers and press consumers attach for a mouse, a pen and a finger
alike. Where the primary pointer is fine, each element with an enabled
recognizer or consumer keeps one passive pointerdown listener and the page
has none: a press on it (a mouse's primary button, a pen, or a finger)
installs the host's page listeners until that press's contacts end. The
action group's own fine-pointer path folds into this one, so a group is
served like every attachment.

- touch-action is written for every pointer, a kept press's manipulation
  too (FR18); the element's blocking touchmove stays a coarse pointer's,
  so under a fine one the element keeps its one listener (FR24).
- A press consumer takes a mouse press as a press (DEC-21): no
  feedback-only mode; PressTracker brakes only a finger's press (FR11).
- The screen-edge pan allows touches only unless its caller allows another
  pointer (FR43).
- perEventPointer is accepted and does nothing; the fine-pointer warning
  goes.

Tests that encoded the coarse-only design now state FR43's; a finger's
pointerdown comes before its touchstart in them, as in a browser.
…onto main) under the button group's tracking
…th real mouse drags (spec:AST-094 FR43, FR24)

Under a desktop's mouse the host still writes touch-action, leaves the
page no listener while idle and warns for nothing; a real mouse drag pans
both the bare page's pan and the fixture's horizontal pan, and the page's
touch listeners are gone again after the release.
…(spec:AST-094 FR18, DEC-13)

A screen-edge pan attached to a stack container, as wide as the screen,
with a touch 4 px from its edge: inside the 40.2 px band the pan decides at
UIKit's 15 px whatever the element's width, and then tracks the travel past
it. From several edges too. Both fail while a wider element decides at
FR19's 10 px.
…'s width (spec:AST-094 FR18, DEC-13)

FR18: inside the 40.2 px band a screen-edge pan decides at UIKit's 15 px
screen-edge hysteresis; outside it the author's scrolling is untouched and
FR19's 10 px governs every other claim. The pan decided at 10 px on any
element wider than the band, so NavigationStack's edge pan, attached to the
stack container, began 5 px early. The edge zone still decides only the
touch-action; a touch outside every band still fails at once. The two
cases that pinned the 10 px decision go.
… 15 px whatever the element's width) into the experimental touch exports
…er's move keeps that finger until it lifts) into the experimental touch exports
…rged) so this pull request's compare shows only its own change
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…'s current heads) into the toggle group's tracking
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added 18 commits October 29, 2026 10:44
meta#8665 now carries meta#8360's new head (75392b2), which carries the
gesture foundation with the harness meta#8045 and meta#8046 landed on main, so
this pull request no longer carries their old copies. The merge is
clean.
…esture host's subscriptions

The gesture host subscribes no click: the one cancelling click subscriber
is the click guard's, added at a window's first arm and kept. The release
note, the stream's header and the gestures README listed click with the
host's touchend and contextmenu; they now name the guard's own.
meta#8360's input-stream notes no longer list click among the gesture host's
subscriptions. The merge is clean.
… keyboard-focused button lights one button

Two defects fail here (family:action-groups):

- ButtonGroupTracking.test.tsx: a member whose aria-haspopup is dialog,
  grid or tree carries no group marker, so a slide lifted on it runs
  nothing; only menus keep their own opening. Guards: aria-haspopup of
  true or listbox stays untracked, as menu does.
- ButtonGroupTracking.interaction.browser.spec.ts, Chromium and WebKit
  (FR2): a mouse press that starts on a keyboard-focused Cut and is held
  over Paste leaves Cut with the pressed overlay beside Paste's paint,
  as its :active:focus-visible arm still matches. Guard: holding Space on
  the focused button still paints its pressed look. The hook's unit case
  asks for the mark the fix uses: the action a pointer press began on is
  marked while the press runs, and loses the mark at the lift and at a
  cancel.
…keyboard-focused button lights one button

- Button leaves only a menu trigger untracked (aria-haspopup of true,
  menu or listbox): a menu keeps its own opening (spec:AST-094 FR29),
  while a button that opens a dialog, a grid or a tree is an action the
  group tracks like any other (family:action-groups).
- The hook marks the action a pointer press began on with
  data-astryx-pointer-press while the press runs, and clears it at the
  lift, at a cancel and when tracking stops. A tracked button's
  :active:focus-visible overlay arms step aside for the mark, so a mouse
  press that starts on a keyboard-focused button no longer leaves it
  looking pressed beside the one under the pointer (FR2); a held Space
  still paints the button's own press.
… lift and the browser's menu, keeps a kept press's finger, and goes when the page hides

From the review of this pull request, three cases fail on its head:
- an armed press installed the page listeners without the claiming
  ones, so a claimed finger's touchend stayed passive and the browser's
  menu opened during a claimed long press (spec:AST-094 FR21);
- a kept press got its blocking touchmove during an armed press, but its
  moves were cancelled only where the primary pointer is coarse, so a
  held stepper on a touchscreen laptop lost its finger (FR11, DEC-21);
- a page hidden mid-press cancelled the press but kept the page's
  listeners until the next lift anywhere (FR24).
…lift and the browser's menu, keeps a kept press's finger, and goes when the page hides

- A finger's or a pen's armed press now brings the claiming listeners
  (the cancelable touchend and the contextmenu canceller) with the page
  listeners, as wherever the host installs, so a claimed lift clicks
  nothing and a claimed hold opens no browser menu (spec:AST-094 FR21). A
  mouse's press needs neither and adds neither.
- A kept press keeps its finger under any pointer: its target blocks and
  its moves are cancelled once its press is delivered, not only where the
  primary pointer is coarse (FR11, DEC-21).
- A page hidden mid-press releases the armed press as a blur does, so the
  page's listeners go with the cancelled press (FR24).
…'s selection

An action group's press is a long press with no minimum duration, so it
claims at the press's pointerdown, before any movement. The drag
selection hold then cleared the page's selection, so a plain click on a
group's button left its Copy or Cut with nothing to act on. The case
fails on this head: the selection is gone after the pointerdown.
…age's selection

The drag selection hold cleared the page's selection when any mouse or
pen contact was claimed, but an action group's press claims at its
pointerdown, before it has moved or selected anything, so a plain click
on a group's button left its Copy or Cut with nothing to act on. A claim
before any movement now keeps the page's selection: the hold still
refuses a new one, and puts the page's back over a selection that
appears while it runs. A claim after movement clears what its drag
selected, as before (spec:AST-094 FR41).

The case before the new one now waits for its hold to end a task after
the lift, so no hold outlives that test.
…t every pointer does

- GestureHost's header, the gestures README and coarsePointer.ts no
  longer describe the coarse-only host: a finger or a pen takes the
  touch-down lock under any primary pointer, a kept press's finger is
  kept during a fine pointer's press too, and recognizers attach for
  every pointer while the page listeners last a press (spec:AST-094 FR19,
  FR24, FR41, FR43).
- The fine-pointer selection case asserted the replaced rule and passed
  only because it read iOS WebKit's inline lock without iOS WebKit: it
  now asserts the lock a fine pointer's finger and pen take, and that a
  mouse takes none.
- FR41's sentence and its verification row say that a press claimed at
  its own pointerdown, before it moves, keeps the page's selection, while
  a drag that moved before its claim loses what it selected.
…ion, in the present tense

The sentence said a drag that moved before its claim loses the selection
it started, but the hold clears the page's selection then, whatever made
it; and an unclaimed mouse drag kept "today's" behavior, a phrase that
ages. It now says a claim after movement clears the page's selection, a
press claimed at its own pointerdown keeps it, and an unclaimed mouse
drag keeps its released behavior.
…lection

Fails in Chromium and WebKit (spec:AST-094 FR41): with text selected on
the page, a mouse click on Cut runs Cut but clears the selection, as
the group's press claims at its pointerdown and the gesture host's drag
hold cleared the page's selection there; a Copy or Cut action in a group
then has nothing to act on. meta#8665's fix keeps a selection that was there
before a press claimed at its own pointerdown.
…s the page's selection) into the ButtonGroup review base
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
…pointer press

The press scale's :active:focus-visible arm on a button its group tracks
now skips the button a pointer press began on (data-astryx-pointer-press,
from meta#8657's hook), as the tracked overlay's arms do: a mouse press that
starts on a keyboard-focused toggle no longer leaves it at 0.98 beside
the toggle under the pointer (family:action-groups FR2). A held Space
still scales it.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
…tion) into ToggleButtonGroup press tracking
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:38

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs two engineering-owner decisions before it can be approved: whether ButtonGroup's slide-and-lift ships with no way back, although spec:AST-017 FR3 asks a changed functional default's changeset to name the option that restores it, and whether to accept the new spec:AST-094 FR41 text on claimed mouse and pen drags; an engineering owner records each by replying or by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Independently of those decisions, two changes are still needed:

  • Rebase onto the landed dependencies so the diff carries only the 11 ButtonGroup files instead of 32,005 lines from seven open pull requests.
  • A selection that a group button's own click makes is reverted a task after a mouse or pen click (utils/selectionLock.ts), so a "Select all" button in a group leaves nothing selected; please end or narrow the hold before the lift runs the action, and add a test for it.

[Automated review]

Comment on lines +153 to +166
const clear = () => {
const selection = view?.getSelection();
if (
selection == null ||
selection.rangeCount === 0 ||
selection.isCollapsed ||
holdsRanges(selection, kept)
) {
return;
}
selection.removeAllRanges();
for (const range of kept) {
selection.addRange(range.cloneRange());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With keepsSelection, this treats every other non-collapsed selection as the drag's and puts the kept ranges back, on each selectionchange while focus is outside a text field and once more at the deferred release (:197-210). An action group's press claims at its pointerdown and the lifted button's click() runs inside that window, so a group button whose action selects text (say "Select all" over a read-only view) ends with nothing selected after a mouse or pen click; in Chromium a 54-character selection made by a Paste handler survives on main and is gone 400 ms later on this head. spec:AST-094 FR41 keeps a mouse's released behavior. Could a press claimed at its own pointerdown end the document hold (or stop restoring) before the lift runs the action, with a test for an action that selects text?

'@astryxdesign/core': patch
---

[feat] ButtonGroup behaves like a menu under a press: press a button, slide across the others while the highlight follows, and the button you lift on runs; lifting outside the group runs nothing. A mouse, a pen and a finger all track the same way, and with a finger each button's target reaches 44 px without the buttons growing. A menu trigger in the group keeps its own opening, and the keyboard is unchanged. Before this, each button tracked only its own press: sliding off it ran nothing, and a small button's touch target was its own box. No option restores that behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-017 FR3 asks the changeset for a changed functional default to name "the prop or option that restores the old default"; this line says no option restores it. That is an engineering owner's decision (a CodeBunny question asks it): either record the exception, or add a prop that then needs its own spec authority.

Comment on lines +471 to +475
(`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims
takes no new text selection for that drag only. A claim made after the
drag has moved clears the page's selection; a press claimed at its own
pointerdown, before it moves, keeps it. An unclaimed mouse drag keeps its
released behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This text is a requirement main does not state (FR41 there only keeps a mouse's released behavior), so it needs an engineering owner's approval. It also leaves open whether "takes no new text selection" covers a selection the lifted action's own click makes, which the hold in utils/selectionLock.ts reverts today; saying so either way would settle the code.

vjeux added 2 commits October 29, 2026 17:30
…ger runs nothing

Fails: the finger resolution weighs only the tracked buttons and the
disabled ones, so Cut's target, grown toward 44 px, reaches 2 px into a
32 px menu trigger 4 px away; a finger slid from Cut and lifted 1 px
inside the trigger keeps Cut lit and runs it, against spec:AST-094 FR42
(a grown target never overlaps a neighbour's own).
…s neighbours

A member the group does not track (a menu's trigger in a ButtonGroup, a
toggle without a value in a ToggleButtonGroup) keeps its own target: the
finger resolution now measures the group's interactive members it does
not track beside its disabled actions, so no tracked action's grown
target reaches into one (spec:AST-094 FR42), and a finger lifted inside
one runs nothing.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…ers as neighbours) into ToggleButtonGroup press tracking

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs two engineering-owner decisions before it can be approved: whether ButtonGroup's slide-and-lift ships with no way back, although spec:AST-017 FR3 asks the changeset to name the prop or option that restores the old default, and whether the new spec:AST-094 FR41 text carried from #8665 is accepted as written. An engineering owner records each decision by replying on this pull request or by commenting /approve-spec <full head SHA>, and the author may do so as an engineering owner. Whatever is decided, two changes remain: rebase onto the landed dependencies so the diff carries only the ButtonGroup change, and keep a selection the lifted button's own click makes after a mouse or pen click (utils/selectionLock.ts, reached through gestures/GestureHost.ts). The latest fix, which keeps a finger's grown target out of an untracked menu trigger, matches spec:AST-094 FR42 and is covered by its new unit case.

[Automated review]

This branch was successfully deployed

1 active deployment
Preview — 9b5a15b8 Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants