Skip to content

feat(core): the shared drag path takes a component's own drag (internal options) - #9720

Draft
vjeux wants to merge 15 commits into
meta:mainfrom
vjeux:uikit/c12-shared-drag-internal-options
Draft

vjeux wants to merge 15 commits into
meta:mainfrom
vjeux:uikit/c12-shared-drag-internal-options

Conversation

@vjeux

@vjeux vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Depends on #9261 (on #8658, #8655 and #8653). This pull request's own commit: vjeux/astryx@bb0ea84...91d713b

Status: draft. Already passed locally at this head: typecheck, lint (strict), the unit tests of both test projects, the accessibility contracts this change selects (Chromium), and the snap-point drag, gesture and animator interaction proofs in Chromium (pinned to two cores at their default of one worker, every case).

User need

A Core component whose drag needs more than the public layer offers should still run the shared drag path, not a private copy of it. Item's swipe row is the first such component. It chooses which pointers start its drag, models a finger with UIKit's dead zone and tracking spring, and draws its travel into one custom property. The public pan and snap-point drag expose none of that, so today such a component builds its own pan, frame loop and springs.

Problem and solution fit

Current specification

  • spec:AST-107 FR1 and FR2: the public pan reports a drag as values, and the public snap-point drag settles on snap points. Both keep their behavior.
  • spec:AST-107 FR7: Core uses the public layer, except for the known public-API gaps that architecture:react-component-runtime INV13 lists. This pull request adds the drag jobs there.

Public delta and caller burden

  • API: none. The internal factories and options are not exported, and packages/core/api-report.md does not change.
  • Behavior: none for a public caller.
  • Callers: nothing to change.

Design and alternatives

Internal options:

  • createPan, under createPanGesture: which pointers and scrolls start a drag, its claim distance, a begin gate and the selection lock. Each report says what drove the drag, and its travel from where the touch began.
  • createSnapDrag, under createDragToSnapPoints:
    • a model of where a drag draws the element (one for one, on a spring, or nothing yet inside a dead zone) and where a release or a cancel lands;
    • a custom property to draw into;
    • settleTo with a spring, a velocity and a touch release, returning the settle's motion;
    • settleTo({onPass}), a callback on the first animation frame whose presented value has passed an offset, read on the settle's own curve. It uses no timer, and never fires for a settle that lands short, is replaced, or is destroyed.
    • A drag's spring targets run on the drag's own clock. A target that waited for the browser's start would restart from rest on every move in WebKit.
  • The animator draws x into one registered custom property, on the main thread, with an optional companion. The companion is a second custom property drawn from x by a pure mapping in the same keyframes, so it cannot drift from it.

Alternative: make these options public. Not now: spec:AST-107 decides the public layer, and each option serves one Core job. INV13 records them as known gaps, so a later record can promote them.

Evidence

  • createSnapDrag.test.ts, each internal option red first:
    • a model draws a drag one for one or on a spring, or leaves the element as it is inside a dead zone, where a settle carries on until the model's first target catches it;
    • a release or a cancel lands where the model says;
    • the drag draws into a custom property, and its companion follows it in the same animation;
    • the settle-passes-offset callback calls back once, and never for a settle that lands short, is replaced or is destroyed.
  • The public factories' tests pass unchanged: createDragToSnapPoints.test.ts, createDragToSnapPoints.reducedMotion.test.ts and createPanGesture.test.tsx. The Reduce Motion test's stand-in pan now names the input that drives each event, since the pan reports it.

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.

Testing

  • pnpm exec vitest run packages/core/src/dragToSnapPoints packages/core/src/gestures packages/core/src/motion packages/core/src/hooks
  • pnpm check:repo and pnpm check:knowledge; ASTRYX_STRICT_LINT=1 pnpm lint: the source comments this change adds cite no record, claim, pull request or date.

Writing rubric results

Records edited:

  • architecture:react-component-runtime INV13: the known public-API gaps gain a drag's own pointer and scroll filter, claim distance and begin gate, a snap-point drag's own model of where a drag draws its element and where a release lands, and a drag drawn into one custom property, for spec-owner review;

  • spec:AST-107's path applicability lists the two internal factories, dragToSnapPoints/createSnapDrag.ts and gestures/createPan.ts, so pnpm specs:for lists the record for them.

  • R1 narration: none. The edits state what the system does, in the present tense.

  • R2 research: none.

  • R3 evidence: INV13's addition cites spec:AST-057 AR2, FR2 and FR5 for the row's jobs; no evidence cell names a pull request. The path-applicability list is paths only.

  • R4 rejected alternatives: none added.

  • R5 change references: none; the records name no pull request or issue.

  • R6 open questions: none.

vjeux added 15 commits November 2, 2026 19:52
The public animator (spec:AST-107 FR3, FR6): createAnimator(element)
over the internal animator, with animate, decay, set, get, getVelocity,
stop and destroy, from the package root; and useAnimator(ref), which
takes the element's ref like the gesture hooks, so a gesture hook can
share the element, and returns the animator. After every commit the hook
follows the element behind the ref, through the attach lifecycle the
gesture hooks share (useAttachment). The internal factory is renamed
createMotionAnimator, along with its callers in the Slider's and the
Switch's touch controllers and the pager's tests, so no internal factory
shares a public factory's name. Docs, a changeset, and playing-cards
templates in React and plain JavaScript, with their registrations. In
both templates the card is an image named by its rank and suit, renamed
on each deal; one card leaves at a time (a Discard press while a card
leaves deals no second card), a leaving card takes no press, and a press
on a card fading in moves only its position; and a drag the browser
cancels springs back. The plain-JavaScript example mounts its deck from
a callback ref on its wrapper, whose cleanup takes it down. The core API
report gains the animator's rows.

When the element behind useAnimator's ref unmounts, or a node replaces
it, the animator's values return to rest, so a remount or a new node
starts from the element's resting values. AST-107 records the owner's
decision on four edge behaviors of the public animator and pan gesture
(DEC-4): FR1 and FR6 state that a cancelled pan reports zero velocity,
how the pan factory's destroy and the hook's unmount report, and that an
opacity decay stops at 0 or 1; FR3 states the remount.
…s (spec:AST-107 FR1, FR6)

The public pan: `createPanGesture(element, {axis?, onPan}) → {destroy}` and
`usePanGesture(ref, {axis?, onPan})` over it, reporting
`{state, translation, velocity}` as a drag begins, changes, ends or is
cancelled, under a finger, a mouse and a pen; the translation leaves out the
travel the drag needed to begin, so an element that follows it does not
jump. Docs pages for both, with a playing-card example in plain JavaScript
and in React, the route lock and the sandbox's routes.
…ce and text colors

Both playing-card pan blocks painted the card white and left its text
color to the page, so in a dark theme the card's face read light on white.
They now use the theme's surface background and primary text color. A case
per block failed before.
usePanGesture kept its own node, axis and pan bookkeeping across three
layout effects. It now attaches through useAttachment, Core's one owner of
a node's attachment: one pan for the node behind ref with this axis,
released when the node or the axis changes and at unmount. Its release
still silences the pan first, so an effect never calls onPan. No behavior
changes; the hook's cases pass as before.
… sentences

Main's no-comment-provenance rule refuses a record or claim citation in a
new source comment, and source comments use short, one-topic sentences.
The pan factory's and the hook's headers and the package entry's gesture
line cited the record they follow; the record's hooks path and the core
architecture records still link those files. The factory's header and two
test comments are split into short sentences. Comments only.
In both playing-card blocks, a drag that began during the 250 ms throw
wrote the card's transition, which cancels the throw's transitions. They
then end in transitioncancel, so the next card was never dealt: after a
fast release the card stayed invisible and Discard did nothing. A drag
that outlived the throw, or one a Discard interrupted, then moved the next
card by its whole travel, a jump, and a fast release discarded a card the
person never touched. The blocks now ignore a drag that meets a throw, to
its end, and follow the next one. Cases per block, for a drag that begins
during a throw and one a throw interrupts, failed before.
Both pan pages said a scroll that starts on the element moves it when no
axis is set. That holds for a finger only: the pan takes no trackpad
scroll, so a two-finger trackpad scroll over it still scrolls the page.
They now say so. The factory test's header uses short sentences.
… types

The new public exports, createPanGesture, usePanGesture and the
PanGesture, PanGestureEvent, PanGestureOptions and PanGestureState types,
get their rows in the core API report. Only these rows are added; rows
that other changes left stale stay as they are.
…ack ref

The example mounted its deck from an Effect after the table and button
attached. A callback ref on the wrapper now owns that, as the animator's
plain-JavaScript example does, and its cleanup takes the deck down. A
case checks that the deck mounts with the wrapper and comes down, pan
included, when it unmounts.
The comment restated the export below it. Comments only.
Both pan examples named their card "Top card" with an aria-label on a div
with no role, which a screen reader may drop or read in place of the rank.
The card is now an image named by its rank and suit, as the animator's
examples name theirs, and each discard is announced by that name. A case
per example failed before.
The header ran to 15 lines. The claim at 10 pt and the touch-action fact
move into the factory's JSDoc, and the header keeps its input, output,
position and SYNC lines. Comments only.
…element on snap points

`createDragToSnapPoints(element, {axis, snapPoints, flickThreshold?,
onSettle}) → {settleTo, destroy}` and `useDragToSnapPoints(ref, …)` over
it (spec:AST-107 FR2, FR6): a drag moves the element along its axis
under every pointer, and the release settles it where Core's one release
policy says (the nearest point; the next one past the flick speed, 1008
px/s by default; back to where the drag started when the release moves
back toward it), on the settle spring from the release velocity. Under
Reduce Motion the release cross-fades after a touch drag or on a coarse
primary pointer, and lands at once elsewhere. Docs pages and
playing-card examples for both, the route lock and the sandbox's routes,
and the core API report's rows for both. The plain-JavaScript example
mounts its card from a callback ref on its wrapper, whose cleanup takes
it down.
A caller handling an input event (a key, a click, a pointer, a touch)
runs in that event's own task, never in a frame callback, so the first
frame whose callbacks run after its call is the one that commits the
call's animations, however long the task ran. The animator cannot tell
that from a call a page's own frame callback makes before a busy task,
whose browser start is right, so the caller says so with the internal
option `start: 'next-frame'`. A call from rest made with it starts at
that frame, and a later listener of the same event that holds the main
thread delays the motion instead of letting it skip into its curve.

- Chromium tells the page the start a frame or more after the
  compositor takes it. Such a call starts at its first frame, as a call
  whose first frame comes within LATE_START_MS already does, and also
  when an unrelated task delayed that frame.
- WebKit dates the start at its last frame time before the task. The
  task's end is read at the call's first read, not at its first
  microtask, which runs before a later listener's block, so the dated
  start moves past the block. The first read stands for the task's end,
  so a first frame that merely comes late (more than LATE_START_MS after
  the call, with no block) moves the dated start to that frame too.
  Nothing was painted before it, so the motion waits for that slow frame
  instead of skipping into its curve.

A call without the option, a call with an explicit start, and a call
that continues motion keep the rule they had. Internal only: the public
animator does not take it.
…al options)

The public pan and snap-point drag keep their behavior (spec:AST-107 FR1,
FR2) and now run on internal factories a Core component can use for a job
the public layer does not expose (spec:AST-107 FR7):

- createPan, under createPanGesture: the recognizer's own options (which
  pointers and scrolls start the drag, its claim distance, a begin gate, a
  selection lock), and what drove the drag and its travel from where the
  touch began in each report.
- createSnapDrag, under createDragToSnapPoints: a model of where a drag
  draws its element (one for one, a spring, or nothing yet inside a dead
  zone, the first target catching a settle in flight) and where a release
  or a cancel lands; a custom property to draw into; settleTo with a
  spring, a velocity and a touch release, returning the settle's motion,
  and an onPass callback on the first frame the settle's curve passes an
  offset (no timer).
- The animator draws x into a registered custom property (a length or a
  number), on the main thread, for a component whose parts all read one
  value, with an optional companion property drawn from x in the same
  keyframes.

architecture:react-component-runtime INV13 names these jobs among the known
public-API gaps, and spec:AST-107's path applicability links the two
internal factories.
@vercel

vercel Bot commented Nov 3, 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 Nov 3, 2026 7:32am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 3, 2026
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 3, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026
@astracat-bot

astracat-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Nov 3, 2026, 7:44 AM UTC

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 91d713b

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes before this can merge.

  • The diff against main has 7,917 authored lines because it also carries #8653, #8655, #8658 and #9261; please land those first, or rebase once they land, so this pull request reviews as its own 1,363-line commit.

The internal options themselves have spec cover: AST-107 governs the public layer, and an engineering owner approved the AST-107 and INV13 edits for this head. One question about the onPass contract and three small comment fixes are inline. None of them blocks.

[Automated review]

Comment on lines +143 to +145
* and -1 means at or below it. If the element is past it already, the
* call comes on the next frame. It never comes for a settle that lands
* short, that another settle or a drag replaces first, or that a destroy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: these two sentences conflict for a settle that starts past the offset and lands short. A settle from -200 to -100 with offset: -150, direction: -1 calls back once, on its first frame, and lands at -100. Either check the direction of travel, or drop "lands short" from the contract.

/**
* The recognizer's own options: which pointers and scrolls start the drag,
* its claim distance, how many touches, a gate on its begin. Defaults are
* the recognizer's (every pointer, a trackpad's scroll).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: PanGestureRecognizer's allowedScrollTypes defaults to none, so by default the pan takes no trackpad scroll. Suggested wording: Defaults are the recognizer's: every pointer, and no scroll.

Comment on lines +12 to +14
* `translation` leaves out the travel the drag needed to begin.
* `totalTranslation` counts from where the touch began. `input` says what
* drove the drag, such as a trackpad's scroll.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: these lines repeat PanEvent's field docs below, so the header can drop them.

options: PanGestureOptions,
): PanGesture {
const {axis, onPan} = options;
// The public event carries only these three values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this restates the destructuring below. Keep only the reason, for example // totalTranslation and input stay internal.

This branch was successfully deployed

1 active deployment
Preview — 91d713be Deployed Nov 3, 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