Skip to content

feat(core): createPanGesture and usePanGesture report a drag as values (spec:AST-107 FR1, FR6) - #8655

Draft
vjeux wants to merge 12 commits into
meta:mainfrom
vjeux:uikit/f2-public-pan
Draft

vjeux wants to merge 12 commits into
meta:mainfrom
vjeux:uikit/f2-public-pan

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8653 (on #8676 and #8360). This pull request's own commit: vjeux/astryx@28cc019...80ae898

Why

A product that wants a card to follow a finger, or a drawer pulled by its handle, has no public way to read a drag from Astryx: the gesture recognizers behind Astryx's own components are internal, so a product writes its own pointer handling and gets none of the rules Core follows (claiming at 10 pt, leaving taps and scrolls alone, the release velocity).

What led to it

The approved spec for public gesture hooks, spec:AST-107, defines createPanGesture and usePanGesture: a drag reported as values, with no gesture machinery handed out.

Options and tradeoffs

  • Export the recognizer itself: flexible, but spec:AST-107 FR4 keeps recognizers internal, and products would depend on internals.
  • A hook alone: React only, while the spec asks for the same API without React too.
  • A factory and a thin hook over it (FR6), both reporting {state, translation, velocity} (FR1).

Why this choice

It follows the spec and reuses Core's own pan. A finger, a mouse and a pen all drive it (FR1, through #8360's every-pointer host). axis limits it to one direction. translation starts where the drag began, so an element following it does not jump. velocity at the end is the release velocity Core's own components use. The hook never calls onPan from an effect, so an unmount mid-drag ends silently (architecture:react-component-runtime INV2). Two playing-card examples, one per layer, throw one card at a time.

How to check it

  1. In a story, attach usePanGesture(ref, {axis: 'x', onPan}) to a card and log the events.
  2. Drag it sideways with a finger, then with a mouse. Expected: began, changed… ended each time, with translation.y always 0.
  3. Tap the card, and scroll the page vertically over it. Expected: no events.
  4. In a playing-card example, press Discard twice quickly. Expected: the next card, not the one after it.

Evidence

  • Unit cases cover both layers: the event's exact keys, each pointer driving it, the axis, a cancel, destroy mid-drag, no work at import, and the hook's effects calling nothing.
  • A jsdom case renders both playing-card examples: a second Discard during a throw dealt two cards before the fix, one after.
  • Real input in Chromium and WebKit: a finger's drag is reported along its axis; a tap, a long press and a cross-axis drag report nothing; a mouse drag drives it in a desktop context.

Testing

Local gates at 80ae898, one commit on #8653 (19 files):

  • pnpm test:ui (the UI tests of Core, Lab and Richtext): 718 files, 17,763 tests passed.
  • pnpm exec vitest run --project node, after building Core: 832 files passed, the touch-support bundle guard and the CLI cases among them; the only failures are two cases in packages/cli/foundation/release/cache.test.mjs, which fail on main as well (fix(cli): a crashed run's release install is recognized by the Node it ran on, whatever its name #8520).
  • pnpm check:repo, the ShadCN route lock included: clean.
  • The gesture browser proof (playwright.interaction.config.ts, a phone in Chromium and WebKit): Chromium 64 passed and 4 skipped; WebKit 58 passed and 10 skipped.
  • tsc --project packages/core/tsconfig.json --noEmit, strict ESLint and Prettier on every changed source file: clean.

@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 Nov 1, 2026 10:32am UTC

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 27, 2026
@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) Nov 1, 2026, 10:46 AM UTC

@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 review is on hold for an owner decision. The public createPanGesture and usePanGesture rely on spec:AST-107, which exists only in open #8600 and is not on main, where spec:AST-017 FR21 and architecture:react-component-runtime INV13 keep gesture modules internal. An engineering owner can approve this public pan for this exact head by commenting /approve-spec <full head SHA> (the author may do so as an engineering owner), or the PR can wait for #8600 to land. That decision should also settle whether the pan reports mouse and pen drags where the primary pointer is fine; today it reports nothing there. Independent of that decision, these need changes:

  • Land #8076 first (or retarget onto it), so this diff is only the pan: it now carries 16,566 authored lines, including a copy of #8076 that lacks its four newest fixes.
  • usePanGesture calls onPan with cancelled from its layout effects when the component unmounts or axis changes during a drag, which architecture:react-component-runtime INV2 rules out.
  • The hook's docs should match what ships. They promise mouse and pen drags, point to useDragToSnapPoints and useAnimator (neither can be imported), and say a scroll that starts on the element stays a scroll, which a pan without axis prevents.

[Automated review]

if (attached?.element === element && attached?.axis === axis) {
return;
}
attached?.gesture.destroy();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

destroy() here, and again in the unmount cleanup at line 82, cancels a drag in progress, and the recognizer listener then calls the caller's onPan({state: 'cancelled'}) from inside the layout effect. architecture:react-component-runtime INV2 keeps consumer callbacks out of Effects, and useGestureRecognizer in this same diff suppresses exactly this case with isSyncingRef and isReleasingRef. Could the hook do the same, and add tests for an unmount and an axis change during a drag?

],
usage: {
description:
'Reports a drag on an element as values, for a surface the product moves itself: a card that follows the finger, a drawer pulled by its handle. The drag claims its touch at UIKit’s 10 pt, as `UIPanGestureRecognizer` does, so a tap or a scroll that starts on the element stays a tap or a scroll, and a press or a long press is never reported as a pan. The hook is a thin layer over `createPanGesture`, the same API for code without React. A drag has no keyboard or assistive-technology path of its own: whatever the drag does needs another way to do it, such as buttons or arrow keys (WCAG 2.5.7), and `useDragToSnapPoints` provides `settleTo` for that. The pan moves nothing itself, so Reduce Motion applies to the motion you drive from it: animate with `useAnimator`, which plans every animation for Reduce Motion. Where a component already offers the behavior, use its prop instead: BottomSheet’s `snapPoints`, Carousel’s `hasSnap`.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Three statements here don't match the head. useDragToSnapPoints does not exist, and useAnimator is internal (motion/useAnimator.ts is not exported), so neither the keyboard path nor the Reduce Motion guidance can be followed; relatedHooks at line 93 lists both, and the docsite's MCP route serves that list. A pan without axis gives the element touch-action: pinch-zoom, so a scroll that starts on it does not stay a scroll. And the page doesn't say that nothing is reported where the primary pointer is fine.

* What drove the drag: a finger, a mouse or a pen. A caller that animates
* the release can tell a touch's from a pointer's.
*/
pointerType: 'touch' | 'mouse' | 'pen';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pointerType promises a finger, a mouse or a pen. But the gesture host listens for nothing where the primary pointer is fine (GestureHost.ts shouldListen), so on a desktop or laptop no drag of any kind is reported, and the pan's unit tests all run under coarsePrimaryPointer(). Whichever way the owner decides, the JSDoc, the hook page and the changeset should say what happens there.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@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 review is on hold for an owner decision: the public createPanGesture and usePanGesture rely on spec:AST-107, which exists only in open #8600, while spec:AST-017 FR21, architecture:react-component-runtime INV13 and spec:AST-002 DEC-8 on main keep a gesture module internal until its contract is current. An engineering owner can approve this public pan for this exact head by commenting /approve-spec <full head SHA> (the author may do so as an engineering owner), or the PR can wait for #8600 to land; that decision should also settle pointerType and whether the pan may ship reporting nothing where the primary pointer is fine. Independent of that decision, these need changes:

  • Land #8076 first (or retarget onto it), so this diff is only the pan: it now carries 16,696 authored lines, all but 904 of them from #8076.
  • usePanGesture calls onPan with cancelled from its layout effects when the component unmounts or axis changes during a drag, which architecture:react-component-runtime INV2 rules out; the internal gesture hooks in this diff already suppress that case.
  • The hook's docs should match what ships: they describe mouse and pen drags without saying that nothing is reported under a fine primary pointer, point to useDragToSnapPoints and useAnimator (neither can be imported), and say a scroll that starts on the element stays a scroll, which a pan without axis prevents.

[Automated review]

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
Takes meta#8655's head, which carries meta#8665's GestureHost resolution and
meta#8360's head 75392b2, and meta#8653's head, in place of their older copies.
No conflicts: this pull request's own files and its union of the two
stacks' doc inputs and route lock merge cleanly with both.

@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 on hold for an engineering owner's decision: the branch adds a sentence to spec:AST-094 FR41 saying that a mouse or pen drag a recognizer claims suppresses text selection for that drag, which the current record does not state. An engineering owner can approve it by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or reject it or settle it in #8665. Whatever that decision, these still need changes:

  • Land #8665 and #8671 first, or retarget onto them, so this diff is only the pan, about 1,500 of its 29,925 authored lines.
  • Give the pan wheel and trackpad input, as spec:AST-094 FR43 requires, by landing #8676 beneath this pull request or carrying it here with a test.
  • Amend FR41's Verification row, which still fails any change to mouse selection.
  • Stop a quick second Discard from skipping a card in both playing-card examples.

[Automated review]

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
meta#8665's review fixes: an armed press under a fine pointer brings the
claiming listeners, keeps a kept press's finger and goes when the page
hides; a press claimed at its own pointerdown keeps the page's selection;
FR41 says what a claimed drag does with the selection; meta#8360's
input-stream notes. The merge is clean.

@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 on hold for an engineering owner's decision: the branch adds three sentences to spec:AST-094 FR41 and rewrites its Verification row, saying that a mouse or pen drag a recognizer claims takes no new text selection and clears an earlier one once it has moved, which the current record does not state. 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 it can be rejected or settled in #8665. Whatever that decision, these still need changes:

  • Land #8665 and #8671 first, or retarget onto them, so this diff is only the pan, about 1,500 of its 30,252 authored lines.
  • Give the pan wheel and trackpad input, which spec:AST-094 FR43 requires, by landing #8676 beneath this or carrying it here with a test.
  • In both playing-card examples, a flick that starts during a card's throw cancels the throw's transition, so the card stays invisible and Discard stops working; ignore onPan while a throw runs, or deal on transitioncancel too.
  • If FR41's sentences are approved, add Chromium and WebKit cases for a claimed mouse drag's selection and for the action-group click.

[Automated review]

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.

These sentences, and the Verification row change at line 663, add a requirement the current FR41 does not state: on main a mouse "otherwise keeps its released behavior", and spec:AST-017 FR59 sends a new fine-pointer feel to its own owner decision. They need an engineering owner's /approve-spec <full head SHA> on this pull request, or a decision in #8665. If approved, the claimed mouse drag and the action-group click still need Chromium and WebKit cases: the hold's document-wide selectstart refusal rests on a WebKit behavior that only jsdom tests cover today.

Comment on lines +85 to +90
if (state === 'began' || state === 'changed') {
// Follows the finger: translation leaves out the travel the drag
// needed to begin, so the card does not jump.
moveTo(translation.x, translation.y, 'none');
} else if (state === 'ended' && Math.abs(velocity.x) > THROW_SPEED) {
discard(Math.sign(velocity.x));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A drag that begins during the 250 ms throw calls moveTo(…, 'none'), and transition: none cancels the throw's transitions: they end in transitioncancel, never transitionend, so deal never runs. If that drag ends fast, discard returns at the isThrowing guard and nothing clears it: the card stays at opacity 0 and the Discard button does nothing until a slow drag happens to settle it. Ignoring onPan while a throw runs (or dealing on transitioncancel too) would fix it; PanPlayingCard.tsx has the same flow.

@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in this push: a quick second Discard no longer skips a card in either playing-card block, since each throws one card at a time: b0af093 → 75d7704. FR41's Verification row is amended in #8665, which this carries, and wheel and trackpad input comes with #8676.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:38
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
Takes meta#8655's head: the playing-card blocks' one throw at a time, on
meta#8665 c6c02fc, which carries meta#8360 045f666.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 31, 2026
…nGesture, on the animator's own ref

A drag and an animation share one element: usePanGesture reports the drag
(translation, and the release velocity at its end) and useAnimator moves
the card, both on the card's one ref, so the example no longer samples
pointer events itself. Needs usePanGesture (meta#8655); lands right after it.
The examples' test reports the React example's drags through the pan.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 31, 2026
…nGesture, on the animator's own ref

A drag and an animation share one element: usePanGesture reports the drag
(translation, and the release velocity at its end) and useAnimator moves
the card, both on the card's one ref, so the example no longer samples
pointer events itself. Needs usePanGesture (meta#8655); lands right after it.
The examples' test reports the React example's drags through the pan.
vjeux and others added 12 commits November 1, 2026 01:11
meta#8360's change on the internal touch foundation: one click guard shared
by the menu press and the gesture host (its click listener stays once
the menu press arms it, as its record has it, and lasts only while armed
for the gesture host, FR24), the host as the one touch owner (finger
scroll, page holds, attachments, the press tracker hook and a held
control's kept touch, the long press's single source, presented hit
testing on iOS WebKit), its cases in the gesture browser proof (Chromium
and WebKit) and the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320
and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an
unused import). Rebased onto main after section 2 as one commit; the
touch-action rule came with meta#8071. Where fingers come from their pointer
events, a claimed finger's touchend is cancelled from its element
(FR21), under a fine primary pointer wherever the browser sends Touch
Events and inside an open shadow root too; at most eight such lifts wait
for their touchend, and each goes once taken or after 500 ms, kept
across the host's uninstall, which can run in the microtask after the
pointerup, before the touchend that comes later in the same task (FR24).
An armed press's page listeners go once the arena has decided its last
touch, a tap that waits on a double tap included, and after its last
detach the host holds no listener, note, frame or timer (FR24).

The animator's busy proof is judged against the harness's compositor-only
control (meta#8906), with a main-thread mutant as its negative, and the
spring's curve is placed against the control's own clock, so the late
frames a loaded runner sends fail nothing.

With the host serving every pointer (meta#8071), this change's own pieces
follow it: a press tracker takes a mouse press as a press, painting at
once and braking no scroll (FR11); a finger's or a pen's hold that does
something keeps text unselected under any primary pointer, while a mouse
keeps its released behavior (FR41); and `perEventPointer`, from before
FR43, changes nothing. These come from meta#8665, which keeps only the
undecided rule for text selection during a mouse or pen drag.

The hover recognizer and the host's hover routing land with hover's
first user.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…finger landed

On iOS WebKit the host resolves moving content at touch-down and asks the
engine what is under the finger. When a busy main thread hands it the touch
later, the content has moved on: the host now asks at the point where what
was under the finger at the touch's timestamp is drawn, so a tap on a moving
sheet's Done still clicks Done (spec:AST-094 FR11). A presented source
reports its offset at a given time for this.

The presented browser proof drives the sheet with Core's animator and a
critically damped spring, up into place and down off the screen over the
scrim: a tap on the drawn Done clicks it once, and a drag on the drawn sheet
catches it where it is drawn, in both directions.
…the finger (FR11, FR19); INV13 names the shared input listener

Where the engine hit-tests an element a compositor animation moves at the
motion's start (iOS WebKit), a touch on that content goes to what was drawn
under the finger at touch-down, and a tap there activates that element with
one click while the browser's own click is cancelled or swallowed (FR11).
While such content around a recognizer that claims moving touches is moving,
the document may carry a non-passive touchmove listener that cancels only
owned moves and comes off when the motion ends; while it is on, a native
scroll waits for a free main thread (FR19). The gesture host, press
feedback, page hold, finger-scroll watch and click guard hear document-wide
input through one capture-phase listener per event type on the window
(architecture:react-component-runtime INV13).
…view fixes

A touch the host handles late now also leaves alone content that slid
under the finger after it landed: the touch goes to what was beneath it
then (spec:AST-094 FR11). The input-stream changeset says what moved:
the gesture host's listeners, and the press feedback's scroll and
visibility listeners, since its pointer listeners were already on the
window's capture phase. The coarse-pointer gate reads TOUCH_QUERY from
touchQueries.ts instead of spelling it again.

The WebKit interaction job's scope covers every module the gesture host
now bundles: hooks/isRtlElement and utils' flatTree, hapticSwitch,
inputStream, clickGuard, selectionLock and coarsePointer. The scope
test's example of a util outside it moves from clickGuard, which the
gesture fixture now bundles, to focusReturn.

The presented proof checks that the sheet moves on a spring (its speed
changes along the animator's curve) and no longer compares the drawn box
with the animator's offset: under a 3-CPU cap the two disagree by 3 to
27 px in about half the frames whatever reference is used, while the tap
and drag cases go through the host's real path.
…e origin most frames agree with

Under the CI lane's WebKit profile (four CPUs, one worker), "a spring keeps
advancing every frame through a 1 s main-thread block" failed in two ways,
each about once in a few hundred runs, and a failing run's frames show each
cause:

- The spring drifted 138.6 ms against the control. The control's clock took
  its origin from the first usable frame alone, reading that frame's
  direction from the next usable one: the frame sat at x 210 on its way out,
  the next usable one at x 210 after the turn, and the tie read as "back".
  The clock now lives in controlClock.ts and takes the origin most frames
  agree with. On that run's own frames it reads 6.5 ms (bound 12), and its
  unit case for that layout fails on the old reading (150.7 ms astray).
- The spring "moved on 61 of the 62 frames the control moved". At the
  block's end it was in its tail, under two of the cast's 1 px steps a
  frame, where a frame delivered early shows no step and the next catches
  up two. The harness now reads such a tail at its own pace
  (busyWindowProof), and the proof judges the whole block: on that run's
  own frames the earlier proof fails it and this one passes.

No bound changes.
… (architecture:public-component-api INV23)

Main's lint rule now keeps every private data attribute in its owner module.
This change had moved `data-astryx-haptic-switch` from the haptics module to
utils/hapticSwitch.ts, so the click guard could skip the hidden switch
without importing haptics (architecture:react-component-runtime INV13). The
name now lives in privateAttributes/hapticSwitch.stylex.ts, modelled on
segmentDisabled.stylex.ts: it exports markHapticSwitch, which the haptics
module uses on its hidden switch, and isHapticSwitch, which the click guard
reads. utils/hapticSwitch.ts and the deprecated gestures/hapticSwitch.ts
re-export go, the haptics module's baseline entry goes with its raw name,
and the WebKit interaction job's scope covers the new module. No behavior
changes.
…trl-wheel (spec:AST-094 FR43)

meta#8676's reviewed change, rebuilt as one commit on meta#8360's head: a wheel
gesture drives the recognizers that allow its scroll type as a contact
moving with the content, a pan along its own axis once every scroller
between it and the page is at its edge, and a ctrl-wheel drives a pinch.
A screen-edge pan stays a finger's.
…ain input reads as a mouse wheel, and a pan can take a swipe as one step (spec:AST-094 FR43)

The round-4 trackpad behaviors, as a draft for the owner to try each one:

- wheelSegmenter.ts splits the wheel stream into gestures and reads a
  trackpad's lift: once a run of shrinking deltas has lost a fifth of its
  size, momentum began. A pan the host follows ends there at the fingers'
  travel, released at their last move with their speed, and the momentum
  after it never moves the pan. Fingers that stop release still, 180 ms
  (vertical) or 220 ms (horizontal) after their last wheel.
- Input the deltas cannot place reads as a mouse wheel: a delta a hair off a
  whole pixel, a scroll with no legacy wheelDelta (an engine that sends
  none), a legacy wheelDelta that is not three times the delta, or a 120
  tick over a 40 px delta.
- A pan created with wheelStep takes a trackpad swipe as one step: a short
  flick once the swipe travels 24 px, a slow swipe's travel carried across
  pauses, its slow tail absorbed, and one undo back to rest.

A mouse wheel's gesture and a pinch keep their 100 ms end. In Chromium the harness's wheels arrive as notches (wheelDeltaY -120 whatever the delta), which now read as a mouse wheel, so the two trackpad-only browser cases run in WebKit, whose driver sends a trackpad's precise deltas. The lift's
release is stamped at the fingers' last move: stamped at the lift, three
held wheels later, it read as a stop and lost the fling.
…speed until the splitter decides, so it never stalls before a lift (spec:AST-094 FR43)

The splitter reads a lift only after four shrinking deltas, so a pan that
held them stood still for that stretch (about 64 to 130 ms) and then let go
at the fingers' speed: a stop, then a go. Through the undecided stretch the
pan now moves on from where it last was at the fingers' last speed, never
past what that speed projects for the time since. At the lift it keeps that
travel and lets go there, still moving at that speed, and nothing more
moves. When the fingers carry on, their moves absorb what the stretch showed
past their travel or short of it, half a move at a time, so the pan never
steps back and ends on their travel; fingers that stop without a lift end on
their travel where it runs ahead.
…t, and never a mouse wheel (spec:AST-094 FR43, DEC-24)

The merged record decides it: a trackpad's two-finger scroll is direct
manipulation, so a pan follows it where its record allows it; a mouse
wheel's notches are a scrolling control, so a pan takes none, and a scroll
without the trackpad signature, Shift+wheel included, counts as a wheel's.

- A pan's allowedScrollTypes takes only 'continuous', and none by default,
  as UIKit's allowedScrollTypesMask: the sheet, the pager and swipe rows opt
  in. The host never feeds a wheel's notches to a pan, whatever a caller
  passes. ScrollType keeps 'discrete' for the pinch, which takes a
  ctrl-wheel from a trackpad or a mouse.
- A scroll with Shift held reads as a mouse wheel's.
- wheelStep goes: DEC-24 rejects a discrete step per trackpad swipe for rows
  and paging, and nothing used it.
- The splitter's verification streams: ten swipes that each start in the last
  one's momentum, ten whose momentum runs out, and a slow drag with 150 ms
  pauses count ten, ten and one gestures.
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. The internal factory is renamed
createMotionAnimator, 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.
…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.
@vjeux
vjeux force-pushed the uikit/f2-public-pan branch from 75d7704 to 80ae898 Compare November 1, 2026 10:24
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebuilt as one commit on #8653's head 28cc019: 80ae898. The change is the same, plus what the newer base needs: the pan's routes in the ShadCN route lock, usePanGesture in the list of gesture users, and the public pan in the fine-pointer idle-listener proof. Gates are in the description.

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

@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 on hold for an engineering owner's decision: the branch adds three sentences no current spec states, a MUST in spec:AST-094 FR11 for a tap on content iOS WebKit is still moving, a MAY in FR19 for a page-wide non-passive touchmove listener during that motion, and a shared window-capture listener rule in architecture:react-component-runtime INV13. An engineering owner can approve them by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or they can be decided in #8360, which introduces them. Independently of that decision, these still need changes:

  • Land #8360, #8676 and #8653 first, or retarget onto #8653, so this diff (23,275 authored lines today) is only the pan.
  • Have usePanGesture call useAttachment instead of copying its two layout effects, and extend it so useFingerScroll can do the same.
  • In both playing-card blocks, ignore a drag that starts during a throw (or deal on transitioncancel): a fast drag then leaves the card invisible and Discard dead.
  • On createPanGesture's page, say that only a finger's scroll moves an element without axis; a trackpad scroll still scrolls the page.

[Automated review]

Comment on lines +74 to +107
useIsomorphicLayoutEffect(() => {
const element = ref.current;
const attached = attachedRef.current;
if (attached?.element === element && attached?.axis === axis) {
return;
}
detach(attached);
if (element == null) {
attachedRef.current = null;
return;
}
const next: Attached = {
element,
axis,
isForwarding: true,
gesture: createPanGesture(element, {
axis,
onPan: event => {
if (next.isForwarding) {
onPanRef.current(event);
}
},
}),
};
attachedRef.current = next;
});
// Destroyed at unmount.
useIsomorphicLayoutEffect(
() => () => {
detach(attachedRef.current);
attachedRef.current = 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.

These two layout effects are useAttachment (packages/core/src/gestures/useAttachment.ts:68-99) line for line, with axis as the key; useGestureRecognizer and usePressTracker already call it. Could the hook call it instead, so a fix to node replacement reaches public-pan callers too?

useAttachment(
  ref,
  element => {
    let isForwarding = true;
    const gesture = createPanGesture(element, {
      axis,
      onPan: event => {
        if (isForwarding) onPanRef.current(event);
      },
    });
    return () => {
      isForwarding = false;
      gesture.destroy();
    };
  },
  axis,
);

if (state === 'began' || state === 'changed') {
// Follows the finger: translation leaves out the travel the drag
// needed to begin, so the card does not jump.
moveTo(translation.x, translation.y, 'none');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A drag that begins during the 250 ms throw sets transition: none here, which cancels the throw's translate and opacity transitions: they end in transitioncancel, so deal never runs and isThrowing stays set. If that drag ends fast, discard returns at the guard and the card stays at opacity 0 with Discard dead. Ignoring began and changed while isThrowing, or dealing on transitioncancel too, would fix it; usePanGesture/PanPlayingCard.tsx:62-63 has the same pattern.

name: 'options.axis',
type: "'x' | 'y'",
description:
'Limits the drag to one axis. A drag along the other axis is not reported, and that component of every value reads 0, so a scroll across it stays the page’s. Omit it to report both axes; a scroll that starts on the element then moves the element instead of the page.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"A scroll that starts on the element then moves the element instead of the page" holds for a finger only: the pan takes no trackpad scroll (allowedScrollTypes defaults to none and the factory passes none), so a two-finger trackpad scroll over it scrolls the page. Could this say "a finger's scroll"?

This branch was successfully deployed

1 active deployment
Preview — 80ae898a Deployed Nov 1, 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 needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants