Skip to content

feat(core): one tracker for a held finger across any group of targets - #8346

Merged
cixzhang merged 6 commits into
meta:mainfrom
vjeux:uikit/c19-one-tracker
Oct 27, 2026
Merged

cixzhang merged 6 commits into
meta:mainfrom
vjeux:uikit/c19-one-tracker

Conversation

@vjeux

@vjeux vjeux commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Depends on nothing (based on main).

Maintainer impact

A held finger that slides across targets and lifts on one is tracked in several places. Menus (useMenuPress), alerts and action sheets (useActionGroupTracking, #8083), the segmented control (#8127) and the page control's scrub (#8174) each resolve the target under the finger, paint or report it, and activate it. Review asked for one implementation ("a shared tracker"). This adds that tracker as a standalone building block, internal to core. #8110 moves the alert's tracking onto it, and the others stack on it without pulling in menus or alerts.

Intended invariant

  • Rects are read once when a press begins (and on remeasure), never per move.
  • The default target is the hit test grown by highlightExpansion.
  • Disabled targets are skipped; an inert press paints and runs nothing.
  • The action runs through its own click() inside the lift's event, keeping the user activation and every click handler.
  • The highlight paints with the press attribute and fades for PRESS_FADE_MS at the lift.

Change

hooks/actionGroupTracker.ts adds createActionGroupTracker(getOptions), which returns begin, move, end, cancel, offsetBy, remeasure, dispose, highlighted, beganOn and isTracking. Options:

  • roots, itemSelector, highlightExpansion.
  • resolveTarget(x, y, targets): the owner picks from the measured rects (a segmented control: the nearest segment along x, and past the ends the end one, so a lift anywhere commits).
  • onMove(x, y): every finger position, before it is resolved.
  • paintsHighlight: false, plus onHighlightChange(next, previous): for an owner that shows the highlight another way (focus, a dimmed title).
  • onActivate (replaces the click), and onActivated(item, cancelled): whether a handler's preventDefault() vetoed the click.
  • swallowsNativeClick: the browser's own click for an unclaimed touch is taken once after the lift. Its listeners exist only until that click, the next press or a key. An activation with detail === 0 (a key, assistive technology) always passes (spec:AST-094 FR21).

Evidence

  • Before: each component carries its own resolution, paint and activation for a held finger.
  • After: 22 jsdom tests drive the tracker directly as a recognizer would. They cover a press that begins while another is tracked, each option, the cancelled click, the one swallowed browser click, an activation with detail === 0 passing it, and the swallower's listener lifetime.

Public delta

None: the tracker is internal to core, with no export from a package entry point (no new public surface without its own spec decision). No product behavior, visual direction or policy changes.

Testing

  • pnpm vitest run packages/core/src/hooks/actionGroupTracker.test.ts: 22 pass; the detail === 0 case fails without its change.
  • Core typecheck, eslint, prettier, check:changesets, changeset coverage, sync:exports (unchanged), the CLI's experimental-isolation goldens (unchanged).

@vercel

vercel Bot commented Oct 25, 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 26, 2026 11:56pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 27, 2026, 12:19 AM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c19-one-tracker branch from 4931e65 to c576918 Compare October 25, 2026 02:43
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c19-one-tracker branch from c576918 to 45a954c Compare October 25, 2026 03:10
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
`createActionGroupTracker` (hooks/actionGroupTracker.ts), exported from
`@astryxdesign/core/hooks` as experimental with `ActionGroupTracker`,
`ActionGroupTrackerOptions` and `ActionGroupTarget`: UIKit's tracking of a
held finger across a group of targets, as a plain object any gesture
recognizer can drive, so an alert's or an action sheet's actions, a menu's
rows, a segmented control's segments and a page control's dots share one
implementation instead of each resolving, painting and activating the
target under the finger itself.

- `begin` reads the targets' rects once; `move` resolves the finger against
  them (the hit test grown by `highlightExpansion`, or the owner's
  `resolveTarget(x, y, targets)`: the nearest segment along an axis, the end
  one past the ends) and never reads layout; `offsetBy` and `remeasure`
  follow content that moves under a still finger.
- The highlighted target is painted with the press attribute (or not:
  `paintsHighlight: false`) and reported with the one it replaces
  (`onHighlightChange(next, previous)`); `beganOn` is the target the press
  began on; `onMove(x, y)` sees every position first.
- `end` fades the paint and runs the target through its own `click()`
  inside the lift's event (or `onActivate`); `onActivated(item, cancelled)`
  learns whether a handler's `preventDefault()` vetoed it, and
  `swallowsNativeClick` takes the browser's own click for an unclaimed
  touch once, with listeners that exist only until that click, the next
  press or a key.
- An inert press is taken but tracks nothing; disabled targets are skipped;
  `dispose` finishes pending fades.
@vjeux vjeux changed the title refactor(core): one tracker for a held finger across any group of targets feat(core): one tracker for a held finger across any group of targets Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c19-one-tracker branch from 45a954c to 37ee506 Compare October 25, 2026 03:24
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
…/c23-page-control-scrub

# Conflicts:
#	packages/core/src/hooks/index.ts
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
…acker

The scrub was PageControl's own tracking of a held finger across its dots.
It now runs on core's one tracker for a held finger across a group of
targets (createActionGroupTracker, meta#8346), the piece an alert's actions, a
menu's rows and a segmented control's segments share:

- the tracker begins once the finger has travelled scrubStartDistance (until
  then the press is a tap) and reads the dots once; the page under the
  finger is resolveTarget: scrubTarget on the control's geometry, read once
  at that begin, so nothing reads layout while the finger moves;
- past the content's edge of a windowed control it resolves no dot, and the
  jog steps the page as before;
- onHighlightChange asks for the dot's page (paintsHighlight: false: the
  current dot shows it), and the lift commits nothing more (onActivate).

The scrub browser test now also counts layout reads: none after the scrub
begins.
No new public surface without its own spec decision (spec:AST-002 FR18):
createActionGroupTracker and its types are no longer exported from
@astryxdesign/core/hooks. Core's consumers import the module directly.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
The two dependencies in one commit, already merged here, so this pull request's own change reads as one compare against it.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…8346 heads), so a compare from it shows only this PR's change
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8110's dependencies, meta#8084 and meta#8346, in one commit, so its compare link shows only its own change.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8084 now sits on F2's line and meta#8076 62ecda1; this pull request's dependencies arrive as one commit, so its compare link shows only its own change.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…8084)

meta#8110's dependencies in one commit, so its compare link shows only its own change: meta#8084, meta#8346, and F2's line to meta#8360, which carries the gesture host's and press tracker's per-group UIKit rows.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8084 now carries meta#8083's test that counts only the fade's timer and registers only the alert's own two springs, so F2's line to meta#8360 can come in with its click guard: it brings the gesture host's and press tracker's per-group UIKit rows.
… it is core's one track-across-items loop

An export-lock test proves neither the hooks entry point nor the package
exports reach the tracker. The header records that it is core's one
track-across-items loop, that useMenuPress's copy moves into it next, and
that nothing drives it until its adopters land.

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 0b92ae3

@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 is ready to merge as an internal building block: no package path reaches the tracker (a new test enforces that), no component uses it yet, and its click swallower never blocks a detail === 0 activation, as spec:AST-094 FR21 asks. Before #8110 and #8127 start depending on its defaults, four of them deserve a fix or a documented precondition:

  • When highlightExpansion exceeds the gap between neighbours, the hit test returns whichever target comes first in the DOM, so a finger just inside the second target can fire the first.
  • Targets marked data-astryx-pressable also get data-astryx-press from the document press controller, which wipes the tracker's highlight after a finger leaves its starting target and returns.
  • onActivated reports cancelled: false for a vetoed click on a target inside a shadow root, because the check compares event.target, which outside the shadow tree is the host.
  • With swallowsNativeClick, a lift that fires nothing (an inert press, a slide off every target, a target disabled mid-press) arms no swallower, so the browser's own click for that touch still goes through.

[Automated review]

const view = element.ownerDocument.defaultView;
let clicked: Event | null = null;
const grab = (event: Event) => {
if (clicked == null && event.target === element) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For a target inside a shadow root, this window listener sees the host as event.target, so clicked stays null and onActivated gets cancelled: false even when a handler called preventDefault(), while the same target in the light DOM reports true. Menus already render inside shadow roots and the header says their loop is moving here, so identifying the element through event.composedPath() would keep the report right for them.

const lifted = highlighted;
const wasTracking = isTracking;
reset();
if (!wasTracking || lifted == null) {

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 swallowsNativeClick, this early return (and the disabled one just below) arms no swallower, so after an inert press, a slide off every target or a target disabled mid-press, the browser's own click for an unclaimed touch can still reach the target the touch began on, against the header's "nothing when there is none". Arming the swallower on every lift of a tracked touch, or documenting that the owner must block that click, would close the gap.

@cixzhang cixzhang closed this Oct 27, 2026
@cixzhang cixzhang reopened this Oct 27, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…a#8094 and meta#8346 heads): iOS 26.2 simulator, Android 15 Chromium 158, Chromium storyboard
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
meta#8346 0b92ae3 join it), so a compare from it shows only this PR's change
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
@cixzhang
cixzhang merged commit 167a3de into meta:main Oct 27, 2026
90 of 119 checks passed
@vjeux
vjeux deleted the uikit/c19-one-tracker branch October 27, 2026 03:27
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
The tracker paint's dependencies in one commit, so its compare link shows only its own change: main, which now holds the action-group tracker (meta#8346, merged), and meta#8076, which carries the one press writer (paintPress).
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
Main now holds the action-group tracker (meta#8346, merged as one commit). Its two files here keep this pull request's version, which is the merged one plus this pull request's change.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
…bute

From the review of meta#8076: the README said paintPress in
utils/pressFeedback.ts is the one writer of data-astryx-press, but the
action-group tracker on main (meta#8346) writes its groups' presses itself.
The sentence now says what holds at this head.

This branch was successfully deployed

1 active deployment
Preview — 0b92ae36 Deployed Oct 26, 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