+
event.preventDefault()}
- onClick={() => {
- // An aria-disabled stepper does nothing, as a natively disabled
- // one did (spec:AST-085 FR2).
- if (isDisabled || isReadOnly || !canIncrement) {
- return;
- }
- inputRef.current?.focus();
- stepValue(1);
- }}
+ onClick={event => pressStepper(1, event)}
{...stylex.props(
styles.numberStepperButton,
+ hasTouchControl && styles.numberStepperButtonTouch,
+ hasTouchControl && styles.touchGlyph,
+ hasTouchControl && styles.touchGlyphPlus,
interactionOverlayStyles.backgroundImage,
+ hasTouchControl && styles.incrementTouchCorners,
(isDisabled || isReadOnly || !canIncrement) &&
styles.numberStepperButtonDisabled,
+ (isDisabled || isReadOnly || !canIncrement) &&
+ hasTouchControl &&
+ styles.numberStepperButtonDisabledTouch,
)}>
event.preventDefault()}
- onClick={() => {
- // An aria-disabled stepper does nothing, as a natively disabled
- // one did (spec:AST-085 FR2).
- if (isDisabled || isReadOnly || !canDecrement) {
- return;
- }
- inputRef.current?.focus();
- stepValue(-1);
- }}
+ onClick={event => pressStepper(-1, event)}
{...stylex.props(
styles.numberStepperButton,
+ hasTouchControl && styles.numberStepperButtonTouch,
+ hasTouchControl && styles.touchGlyph,
+ hasTouchControl && styles.touchGlyphMinus,
interactionOverlayStyles.backgroundImage,
styles.decrementButton,
+ hasTouchControl && styles.decrementButtonTouch,
+ hasTouchControl && styles.decrementTouchCorners,
(isDisabled || isReadOnly || !canDecrement) &&
styles.numberStepperButtonDisabled,
+ (isDisabled || isReadOnly || !canDecrement) &&
+ hasTouchControl &&
+ styles.numberStepperButtonDisabledTouch,
)}>
-
+
)}
@@ -1111,6 +1570,11 @@ export function NumberInput({
return (
number | null;
+ /** Shows `value` in the field while a press lasts; null: the committed value. */
+ showPressValue: (value: number | null) => void;
+ /** Commits a press's final value: `onChange`, once. */
+ commitValue: (value: number) => void;
+ isDisabled: boolean;
+ isReadOnly: boolean;
+ /** Whether the steppers are shown (`hasNumberSteppers`). */
+ hasSteppers: boolean;
+}
+
+/** What a stepper click steps: a half, nothing, or its own half. */
+export type StepperClick = StepperHalf | 'none' | 'own';
+
+export interface StepperTouch {
+ /**
+ * What a stepper click does. The click a tracked press gets after its lift
+ * steps the half the press lifted on when it was a tap (`spec:AST-094`
+ * FR11), and nothing ('none') when the press stepped while held or lifted
+ * outside. Any other click is its own ('own'): a keyboard's, assistive
+ * technology's, or one long after the lift. Every click ends the wait.
+ */
+ takeClick: (click: MouseEvent) => StepperClick;
+}
+
+/** Tracks a press on the steppers by any pointer. */
+export function useStepperTouch(
+ state: StepperTouchState,
+ incrementRef: RefObject,
+ decrementRef: RefObject,
+ columnRef: RefObject,
+): StepperTouch {
+ // The column's tracker, for the paint its control events read.
+ const trackerRef = useRef(null);
+ // The last lift a click may follow: when, and the half a tap's click steps
+ // (null: its click steps nothing). Null while no click is owed.
+ const liftRef = useRef<{at: number; tap: StepperHalf | null} | null>(null);
+ // The value the press has stepped to; null before its first step.
+ const pressValueRef = useRef(null);
+ const pressRef = useRef<{
+ downTime: number;
+ isDelayed: boolean;
+ /**
+ * The halves' centres in client px, read once at touch-down; null with
+ * no layout (nothing is laid out to tell the halves apart). A point
+ * belongs to the nearer one: the decrement half is below the increment in
+ * the column and beside it in the touch control.
+ */
+ centers: {
+ increment: {x: number; y: number};
+ decrement: {x: number; y: number};
+ } | null;
+ /** The half the press landed on. */
+ landedOn: StepperHalf;
+ /** The half under the pointer, kept through the lift for a quick tap's flash. */
+ half: StepperHalf | null;
+ /** Let go of because the field turned disabled or read-only mid-press. */
+ isReleased: boolean;
+ }>({
+ downTime: 0,
+ isDelayed: false,
+ centers: null,
+ landedOn: 'increment',
+ half: null,
+ isReleased: false,
+ });
+ const latestRef = useRef(state);
+ useIsomorphicLayoutEffect(() => {
+ latestRef.current = state;
+ });
+ const [tracking] = useState(
+ () =>
+ new StepperTracking({
+ step: half => {
+ const latest = latestRef.current;
+ if (latest.isDisabled || latest.isReadOnly) {
+ return false;
+ }
+ const current = pressValueRef.current ?? latest.valueForStepping;
+ const next = latest.stepFrom(current, half === 'increment' ? 1 : -1);
+ if (next == null || next === current) {
+ return false;
+ }
+ pressValueRef.current = next;
+ latest.showPressValue(next);
+ return true;
+ },
+ }),
+ );
+ useEffect(() => () => tracking.dispose(), [tracking]);
+
+ const halfOf = (target: unknown): StepperHalf | null => {
+ if (!(target instanceof Node)) {
+ return null;
+ }
+ if (incrementRef.current?.contains(target) === true) {
+ return 'increment';
+ }
+ return decrementRef.current?.contains(target) === true ? 'decrement' : null;
+ };
+
+ // The half nearer the point; with no layout, the one the press landed on.
+ const halfUnder = (point: {x: number; y: number}): StepperHalf => {
+ const {centers, landedOn} = pressRef.current;
+ return centers == null ? landedOn : halfAt(centers, point);
+ };
+
+ // The one layout read of a press: each half's centre; null with no layout
+ // (nothing is laid out to tell the halves apart).
+ const readCenters = (): {
+ increment: {x: number; y: number};
+ decrement: {x: number; y: number};
+ } | null => {
+ const boxOf = (button: HTMLButtonElement | null) =>
+ button?.getBoundingClientRect() ?? null;
+ const incrementBox = boxOf(incrementRef.current);
+ const decrementBox = boxOf(decrementRef.current);
+ const centerOf = (box: DOMRect) => ({
+ x: box.left + box.width / 2,
+ y: box.top + box.height / 2,
+ });
+ return incrementBox == null ||
+ decrementBox == null ||
+ (decrementBox.width === 0 && decrementBox.height === 0)
+ ? null
+ : {increment: centerOf(incrementBox), decrement: centerOf(decrementBox)};
+ };
+
+ const paint = (half: StepperHalf | null) => {
+ for (const [own, button] of [
+ ['increment', incrementRef.current],
+ ['decrement', decrementRef.current],
+ ] as const) {
+ const next = half === own ? 'on' : 'none';
+ // Only real changes are written: an unchanged write still restyles.
+ if (button != null && readPressPaint(button) !== next) {
+ paintPress(button, next);
+ }
+ }
+ };
+
+ // The press ended with its lift: what it stepped to is committed, once.
+ const commit = () => {
+ const value = pressValueRef.current;
+ pressValueRef.current = null;
+ if (value != null) {
+ latestRef.current.commitValue(value);
+ }
+ latestRef.current.showPressValue(null);
+ };
+
+ // The press was taken away: what it stepped to is dropped.
+ const discard = () => {
+ pressValueRef.current = null;
+ latestRef.current.showPressValue(null);
+ };
+
+ // A field turned disabled or read-only during a press lets go of it at once
+ // (PressTracker reads isEnabled only when a touch begins): no pressed paint
+ // beside aria-disabled, no repeat timer and no commit, until the next press.
+ const wasBlockedRef = useRef(false);
+ useIsomorphicLayoutEffect(() => {
+ const isBlocked = state.isDisabled || state.isReadOnly;
+ if (isBlocked && !wasBlockedRef.current && tracking.isTracking) {
+ const press = pressRef.current;
+ press.isReleased = true;
+ press.half = null;
+ tracking.cancel();
+ paint(null);
+ discard();
+ }
+ wasBlockedRef.current = isBlocked;
+ });
+
+ const options: UsePressTrackerOptions = {
+ // No trackingExpansion of its own: the press keeps tracking within the
+ // control profile's area, `UIKIT_CONTROL.trackingExpansion` (70 px past
+ // the column), so a pointer trembling over the edge stays on the press.
+ // Proposed, pending owner review (M-STP20 in motion/MEASUREMENTS.md):
+ // UIStepper itself tracks within its frame only.
+ //
+ // Once a press is delivered the pointer's touch stays the stepper's, as a
+ // scroll view never cancels a tracking UIControl: a slide onto the other
+ // half is not the page's pan. (A scroll that starts on the column waits
+ // on the main thread for it.)
+ keepsTouch: true,
+ // A stepper's hold repeats, so its press selects no text and opens no
+ // callout, for that press only (`spec:AST-094` FR41).
+ selectionLock: lockSelection,
+ isEnabled: () =>
+ !latestRef.current.isDisabled && !latestRef.current.isReadOnly,
+ onControlEvent: (event, touch) => {
+ const press = pressRef.current;
+ if (event === 'touchDown') {
+ press.isReleased = false;
+ liftRef.current = null;
+ pressValueRef.current = null;
+ press.centers = readCenters();
+ press.downTime = touch.time;
+ // PressTracker's control profile delays a finger's highlight inside a
+ // scroll view; the press reaches the stepper when it lights.
+ press.isDelayed =
+ touch.pointerType === 'touch' &&
+ touch.scroll?.isInsideScroller === true;
+ // The half the press landed on; a press on the control itself (around
+ // or between its halves) takes the nearer half.
+ press.landedOn = halfOf(touch.target) ?? halfAt(press.centers, touch);
+ press.half = press.landedOn;
+ tracking.begin(press.half);
+ return;
+ }
+ const isLift = event === 'touchUpInside' || event === 'touchUpOutside';
+ if (press.isReleased) {
+ // Let go of when the field turned disabled or read-only: nothing
+ // tracks, paints or commits until the next press.
+ liftRef.current = isLift ? {at: browserClock.now(), tap: null} : null;
+ return;
+ }
+ const isInside =
+ event === 'touchDragInside' ||
+ event === 'touchDragEnter' ||
+ event === 'touchUpInside';
+ const half: StepperHalf | null = isInside ? halfUnder(touch) : null;
+ if (event === 'touchCancel') {
+ // No click follows a touch a scroll or the system took.
+ liftRef.current = null;
+ tracking.cancel();
+ press.half = null;
+ discard();
+ } else if (isLift) {
+ press.half = half;
+ // A tap steps on the click that follows (FR11): the lift only says
+ // which half.
+ liftRef.current = {at: browserClock.now(), tap: tracking.end(half)};
+ commit();
+ } else {
+ press.half = half;
+ tracking.move(half);
+ }
+ paint(trackerRef.current?.isHighlighted === true ? press.half : null);
+ },
+ onHighlightChange: isHighlighted => {
+ const press = pressRef.current;
+ if (press.isReleased) {
+ return;
+ }
+ if (isHighlighted) {
+ tracking.deliver(
+ press.isDelayed
+ ? press.downTime + UIKIT_PRESS.contentTouchDelay.value
+ : press.downTime,
+ );
+ }
+ paint(isHighlighted ? press.half : null);
+ },
+ };
+
+ const takeClick = (click: MouseEvent): StepperClick => {
+ const lift = liftRef.current;
+ liftRef.current = null;
+ // A click a pointer sent. Where the engine dispatches clicks as pointer
+ // events it names the pointer: Chrome on Android names a tap's click
+ // 'touch' (detail 0), Safari on iOS names it 'mouse' (detail 1). Where
+ // clicks name none, detail > 0. A screen reader's activation, a key or a
+ // programmatic click() names no pointer ('') and has detail 0.
+ const clickPointer =
+ 'pointerType' in click && typeof click.pointerType === 'string'
+ ? click.pointerType
+ : undefined;
+ const isFromPointer =
+ clickPointer === undefined ? click.detail > 0 : clickPointer !== '';
+ // A press's click follows its lift at once. One that comes later (after
+ // a hold, which the engine sends no click for) is a press of its own.
+ if (
+ !isFromPointer ||
+ lift == null ||
+ browserClock.now() - lift.at > CLICK_GUARD_WINDOW_MS
+ ) {
+ return 'own';
+ }
+ return lift.tap ?? 'none';
+ };
+
+ // A press whose owner goes away mid-hold lets go at once, as a disabled
+ // field's does: the steppers unmounting (`hasNumberSteppers` turning
+ // false) detach the press tracker, which then sends no end, so nothing else
+ // would stop the repeat.
+ const hadSteppersRef = useRef(state.hasSteppers);
+ useIsomorphicLayoutEffect(() => {
+ if (state.hasSteppers !== hadSteppersRef.current && tracking.isTracking) {
+ const press = pressRef.current;
+ press.isReleased = true;
+ press.half = null;
+ liftRef.current = null;
+ tracking.cancel();
+ paint(null);
+ discard();
+ }
+ hadSteppersRef.current = state.hasSteppers;
+ });
+
+ // The hook attaches to the column whenever it is mounted (hasNumberSteppers
+ // can turn on after the field mounts, or off and on again), for every
+ // pointer (`spec:AST-094` FR43).
+ const tracker = usePressTracker(columnRef, options);
+ useIsomorphicLayoutEffect(() => {
+ trackerRef.current = tracker;
+ });
+
+ return {takeClick};
+}
diff --git a/packages/core/src/NumberInput/__tests__/NumberInput.stepper-focus.a11y.chromium.spec.ts b/packages/core/src/NumberInput/__tests__/NumberInput.stepper-focus.a11y.chromium.spec.ts
new file mode 100644
index 0000000000000..d53a9045b255b
--- /dev/null
+++ b/packages/core/src/NumberInput/__tests__/NumberInput.stepper-focus.a11y.chromium.spec.ts
@@ -0,0 +1,144 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * Real-browser proof of where focus goes when a number stepper is pressed.
+ *
+ * A finger on a stepper steps the value and leaves focus where it was, as
+ * UIStepper (which never becomes first responder) does: focusing the field
+ * would bring up the on-screen keyboard on a phone, and so does an activation
+ * with no pointer there. A mouse click still moves focus to the field, so the
+ * arrow keys and the wheel keep stepping, and so does an activation with no
+ * pointer where the primary pointer is fine (a desktop screen reader's). Touches
+ * are real touch input (Chromium's touch emulation), not synthetic DOM events.
+ */
+
+import {expect, test, type Page} from '@playwright/test';
+import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
+import {
+ DEFAULT_STORYBOOK_DIR,
+ serveStorybook,
+ type StaticServer,
+} from '@astryxdesign/a11y-spec/storybook';
+
+let storybook: StaticServer;
+
+test.beforeAll(async () => {
+ storybook = await serveStorybook(
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
+ );
+});
+
+test.afterAll(async () => storybook?.close());
+
+async function open(page: Page): Promise {
+ await page.goto(
+ `${storybook.origin}/iframe.html?id=core-numberinput--with-number-steppers&viewMode=story`,
+ );
+ await holdMotionStill(page);
+ await page.getByRole('spinbutton', {name: 'Quantity'}).waitFor();
+}
+
+async function center(page: Page, name: string) {
+ const box = await page.getByRole('button', {name}).boundingBox();
+ if (box == null) {
+ throw new Error(`${name} has no box`);
+ }
+ return {x: box.x + box.width / 2, y: box.y + box.height / 2};
+}
+
+const focusedRole = async (page: Page) =>
+ page.evaluate(() => document.activeElement?.getAttribute('role') ?? null);
+
+test.describe('on a touch screen', () => {
+ test.use({
+ viewport: {width: 390, height: 844},
+ hasTouch: true,
+ isMobile: true,
+ });
+
+ test('a tap on a stepper steps the value and leaves the field unfocused', async ({
+ page,
+ }) => {
+ await open(page);
+ const input = page.getByRole('spinbutton', {name: 'Quantity'});
+ await expect(input).toHaveValue('2');
+
+ const increment = await center(page, 'Increment Quantity');
+ await page.touchscreen.tap(increment.x, increment.y);
+ await expect(input).toHaveAttribute('aria-valuenow', '3');
+ expect(await focusedRole(page)).not.toBe('spinbutton');
+
+ const decrement = await center(page, 'Decrement Quantity');
+ await page.touchscreen.tap(decrement.x, decrement.y);
+ await page.touchscreen.tap(decrement.x, decrement.y);
+ await expect(input).toHaveAttribute('aria-valuenow', '1');
+ expect(await focusedRole(page)).not.toBe('spinbutton');
+ });
+
+ test('a tap while the field is being edited keeps it focused and steps from the draft', async ({
+ page,
+ }) => {
+ await open(page);
+ const input = page.getByRole('spinbutton', {name: 'Quantity'});
+ const field = await input.boundingBox();
+ if (field == null) {
+ throw new Error('the field has no box');
+ }
+ await page.touchscreen.tap(field.x + 20, field.y + field.height / 2);
+ await expect(input).toBeFocused();
+ await input.fill('7.5');
+
+ const increment = await center(page, 'Increment Quantity');
+ await page.touchscreen.tap(increment.x, increment.y);
+ await expect(input).toHaveAttribute('aria-valuenow', '8');
+ await expect(input).toBeFocused();
+ });
+
+ test('an activation with no pointer steps the value and leaves the field unfocused', async ({
+ page,
+ }) => {
+ await open(page);
+ // A phone: the primary pointer is coarse.
+ expect(
+ await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
+ ).toBe(true);
+ const input = page.getByRole('spinbutton', {name: 'Quantity'});
+ await page
+ .getByRole('button', {name: 'Increment Quantity'})
+ .evaluate(button => (button as HTMLElement).click());
+ await expect(input).toHaveAttribute('aria-valuenow', '3');
+ expect(await focusedRole(page)).not.toBe('spinbutton');
+ });
+});
+
+test.describe('with a mouse', () => {
+ test('a click on a stepper steps the value and focuses the field', async ({
+ page,
+ }) => {
+ await open(page);
+ // A mouse: this context has no coarse pointer.
+ expect(
+ await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
+ ).toBe(false);
+ const input = page.getByRole('spinbutton', {name: 'Quantity'});
+ await page.getByRole('button', {name: 'Increment Quantity'}).click();
+ await expect(input).toHaveAttribute('aria-valuenow', '3');
+ await expect(input).toBeFocused();
+ await page.keyboard.press('ArrowUp');
+ await expect(input).toHaveAttribute('aria-valuenow', '4');
+ });
+
+ test('an activation with no pointer steps the value and focuses the field', async ({
+ page,
+ }) => {
+ await open(page);
+ // A desktop screen reader activates with a click of detail 0 and no
+ // pointer press; the field takes focus, so the new value is read.
+ const input = page.getByRole('spinbutton', {name: 'Quantity'});
+ await page
+ .getByRole('button', {name: 'Increment Quantity'})
+ .evaluate(button => (button as HTMLElement).click());
+ await expect(input).toHaveAttribute('aria-valuenow', '3');
+ await expect(input).toBeFocused();
+ });
+});
diff --git a/packages/core/src/NumberInput/__tests__/NumberInput.stepper-press.a11y.chromium.spec.ts b/packages/core/src/NumberInput/__tests__/NumberInput.stepper-press.a11y.chromium.spec.ts
new file mode 100644
index 0000000000000..feb644050cdda
--- /dev/null
+++ b/packages/core/src/NumberInput/__tests__/NumberInput.stepper-press.a11y.chromium.spec.ts
@@ -0,0 +1,257 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * Real-browser proof that a finger held on a number stepper presses it.
+ *
+ * iOS Safari selects the nearest text after a finger rests about half a second
+ * on content that allows selection: on a stepper that is the field's label,
+ * with the edit menu over the field, and the tap that would have stepped is
+ * lost. `spec:AST-094` FR41 suppresses text-selection start and the callout
+ * for a stepper's hold-repeat press: the press tracker's selection lock turns
+ * them off while a finger is down and back on at the lift. The column takes
+ * `touch-action: manipulation`, which keeps panning and pinch-zoom the
+ * browser's: a scroll that starts on a stepper still scrolls the page and
+ * steps nothing. (Chromium's touch emulation does not select on a long press,
+ * so the selection itself is checked on the iOS simulator; this proves the
+ * paths a hold must not change, including a hold longer than a tap.)
+ */
+
+import {expect, test, type Page} from '@playwright/test';
+import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
+import {PEN_SKIP, webInputDriver} from '@astryxdesign/interaction-harness';
+import {
+ DEFAULT_STORYBOOK_DIR,
+ serveStorybook,
+ type StaticServer,
+} from '@astryxdesign/a11y-spec/storybook';
+
+let storybook: StaticServer;
+
+test.beforeAll(async () => {
+ storybook = await serveStorybook(
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
+ );
+});
+
+test.afterAll(async () => storybook?.close());
+
+test.use({viewport: {width: 390, height: 844}, hasTouch: true, isMobile: true});
+
+async function open(page: Page): Promise {
+ await page.goto(
+ `${storybook.origin}/iframe.html?id=core-numberinput--with-number-steppers&viewMode=story`,
+ );
+ await holdMotionStill(page);
+ await page.getByRole('spinbutton', {name: 'Quantity'}).waitFor();
+}
+
+// The gesture host writes it inline for the column's kept press where the
+// primary pointer is coarse (`spec:AST-094` FR18); the column declares none.
+test('under a finger the stepper column takes touch-action manipulation, and the field stays pannable', async ({
+ page,
+}) => {
+ await open(page);
+ const column = page
+ .getByRole('button', {name: 'Increment Quantity'})
+ .locator('..');
+ await expect(column).toHaveCSS('touch-action', 'manipulation');
+ await expect(page.getByRole('spinbutton', {name: 'Quantity'})).toHaveCSS(
+ 'touch-action',
+ 'auto',
+ );
+});
+
+// `spec:AST-094` FR41: a stepper's hold-repeat press suppresses text-selection
+// start and the callout for that press only, through the press tracker's
+// selection lock.
+test('a held finger refuses text selection on the stepper column for that press only', async ({
+ page,
+}) => {
+ await open(page);
+ const plus = page.getByRole('button', {name: 'Increment Quantity'});
+ const column = plus.locator('..');
+ // Away from iOS WebKit the lock cancels `selectstart` while a finger is
+ // down, and writes no style, so nothing repaints.
+ const refuses = async () =>
+ column.evaluate(el => {
+ const event = new Event('selectstart', {bubbles: true, cancelable: true});
+ el.dispatchEvent(event);
+ return event.defaultPrevented;
+ });
+ const style = async () =>
+ column.evaluate(el =>
+ (el as HTMLElement).style.getPropertyValue('user-select'),
+ );
+ expect(await refuses()).toBe(false);
+ const box = await plus.boundingBox();
+ if (box == null) {
+ throw new Error('the stepper has no box');
+ }
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send('Input.dispatchTouchEvent', {
+ type: 'touchStart',
+ touchPoints: [{x: box.x + box.width / 2, y: box.y + box.height / 2}],
+ });
+ await expect.poll(refuses).toBe(true);
+ expect(await style()).toBe('');
+ await cdp.send('Input.dispatchTouchEvent', {
+ type: 'touchEnd',
+ touchPoints: [],
+ });
+ await expect.poll(refuses).toBe(false);
+});
+
+test('under a fine pointer the stepper column takes touch-action manipulation too, so a finger on a touchscreen laptop presses as on a phone (spec:AST-094 FR18, FR43)', async ({
+ browser,
+}) => {
+ const context = await browser.newContext({
+ viewport: {width: 1024, height: 768},
+ hasTouch: false,
+ isMobile: false,
+ });
+ const page = await context.newPage();
+ await open(page);
+ // A fine pointer: this context has no coarse pointer.
+ expect(
+ await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
+ ).toBe(false);
+ const column = page
+ .getByRole('button', {name: 'Increment Quantity'})
+ .locator('..');
+ await expect(column).toHaveCSS('touch-action', 'manipulation');
+ await context.close();
+});
+
+test('under a fine pointer a pen presses a stepper as a mouse does: a tap steps once and focuses the field, a hold repeats and commits once (spec:AST-094 FR43)', async ({
+ browser,
+}) => {
+ const context = await browser.newContext({
+ viewport: {width: 1024, height: 768},
+ hasTouch: false,
+ isMobile: false,
+ });
+ const page = await context.newPage();
+ await open(page);
+ expect(
+ await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
+ ).toBe(false);
+ // Trusted pen input through the engine, paced on the wall clock.
+ const driver = await webInputDriver(page);
+ test.skip(!driver.pens, PEN_SKIP);
+ const input = page.getByRole('spinbutton');
+ const box = await page
+ .getByRole('button', {name: 'Increment Quantity'})
+ .boundingBox();
+ if (box == null) {
+ throw new Error('the increment half is not laid out');
+ }
+ const at = {x: box.x + box.width / 2, y: box.y + box.height / 2};
+ const before = Number(await input.inputValue());
+ // A tap steps once, on its click, and focuses the field where the primary
+ // pointer is fine (`component:NumberInput` FR5).
+ await driver.pen([
+ {...at, t: 0},
+ {...at, t: 80},
+ ]);
+ await expect(input).toHaveValue(String(before + 1));
+ await expect(input).toBeFocused();
+ // A hold repeats while the pen stays down and commits at the lift.
+ await driver.pen([
+ {...at, t: 0},
+ {...at, t: 1300},
+ ]);
+ await expect
+ .poll(async () => Number(await input.inputValue()))
+ .toBeGreaterThan(before + 3);
+ await context.close();
+});
+
+test('a vertical drag that starts on a stepper scrolls the page and steps nothing', async ({
+ page,
+}) => {
+ await open(page);
+ await page.evaluate(() => {
+ document.body.style.minHeight = '3000px';
+ });
+ const input = page.getByRole('spinbutton', {name: 'Quantity'});
+ const box = await page
+ .getByRole('button', {name: 'Increment Quantity'})
+ .boundingBox();
+ if (box == null) {
+ throw new Error('the stepper has no box');
+ }
+ const x = box.x + box.width / 2;
+ const y = box.y + box.height / 2;
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send('Input.dispatchTouchEvent', {
+ type: 'touchStart',
+ touchPoints: [{x, y}],
+ });
+ for (let step = 1; step <= 12; step += 1) {
+ await cdp.send('Input.dispatchTouchEvent', {
+ type: 'touchMove',
+ touchPoints: [{x, y: y - 8 * step}],
+ });
+ }
+ await cdp.send('Input.dispatchTouchEvent', {
+ type: 'touchEnd',
+ touchPoints: [],
+ });
+ await expect
+ .poll(async () => page.evaluate(() => scrollY))
+ .toBeGreaterThan(0);
+ await expect(input).toHaveAttribute('aria-valuenow', '2');
+});
+
+test('a tap on a stepper still steps the value', async ({page}) => {
+ await open(page);
+ const box = await page
+ .getByRole('button', {name: 'Increment Quantity'})
+ .boundingBox();
+ if (box == null) {
+ throw new Error('the stepper has no box');
+ }
+ await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
+ await expect(
+ page.getByRole('spinbutton', {name: 'Quantity'}),
+ ).toHaveAttribute('aria-valuenow', '3');
+});
+
+/** Holds a finger on + for 1.6 s, longer than a tap; returns the value after. */
+async function holdPlus(page: Page): Promise {
+ const input = page.getByRole('spinbutton', {name: 'Quantity'});
+ const box = await page
+ .getByRole('button', {name: 'Increment Quantity'})
+ .boundingBox();
+ if (box == null) {
+ throw new Error('the stepper has no box');
+ }
+ const point = {x: box.x + box.width / 2, y: box.y + box.height / 2};
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send('Input.dispatchTouchEvent', {
+ type: 'touchStart',
+ touchPoints: [point],
+ });
+ await page.waitForTimeout(1600);
+ await cdp.send('Input.dispatchTouchEvent', {
+ type: 'touchEnd',
+ touchPoints: [],
+ });
+ await page.waitForTimeout(300);
+ expect(await page.evaluate(() => String(getSelection()))).toBe('');
+ return Number(await input.getAttribute('aria-valuenow'));
+}
+
+test('a finger held 1.6 s selects nothing and keeps stepping', async ({
+ page,
+}) => {
+ // The steppers track the finger: a hold repeats from 0.5 s on one steady
+ // ramp and commits once, at the lift; the click after the lift adds
+ // nothing.
+ await open(page);
+ // From 2: steps at 500, 750, 975, 1177.5, 1359.75 and 1523.8 ms, and one
+ // more if the lift comes after 1671.4 ms.
+ const value = await holdPlus(page);
+ expect(value).toBeGreaterThanOrEqual(7);
+ expect(value).toBeLessThanOrEqual(9);
+});
diff --git a/packages/core/src/NumberInput/__tests__/NumberInputStepperHold.interaction.browser.spec.ts b/packages/core/src/NumberInput/__tests__/NumberInputStepperHold.interaction.browser.spec.ts
new file mode 100644
index 0000000000000..cd0eba952e257
--- /dev/null
+++ b/packages/core/src/NumberInput/__tests__/NumberInputStepperHold.interaction.browser.spec.ts
@@ -0,0 +1,934 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file NumberInputStepperHold.interaction.browser.spec.ts
+ * @input The built "With Number Steppers" NumberInput story, real touch input
+ * (Chromium CDP / WebKit's input command) and the interaction harness
+ * @output Interaction proofs for the steppers under a finger and a mouse
+ * (motion/uikit/stepper.ts): a tap steps once at the lift, paints only
+ * while the finger is down, and the engine's click after it adds nothing,
+ * a light tap trembling on the edge too; a hold shows a step at 0.5 s, then
+ * on one steady ramp, stops at the bound and commits once, at the release;
+ * a slide onto the other half carries the paint and the repeat; trembling
+ * on the edge adds no step; leaving the column pauses the repeat and coming
+ * back resumes it, never with a step at once; a scroll that starts on a
+ * stepper scrolls and steps nothing; a mouse holds like a finger; a step
+ * made late by a blocked main thread runs once, with no burst; storyboards
+ * of a tap, a hold, a slide, leaving and coming back, and a hold in a
+ * scrolling page
+ * @position NumberInput's `*.interaction.browser.spec.ts`, shaped like the
+ * harness exemplar (BottomSheetDetent). The paths run under a finger (the
+ * phone profile, as Android Chrome is); one runs with a mouse. Times are
+ * read in the page, on its own clock: the value the field shows, sampled
+ * every frame; the committed value (aria-valuenow, what assistive
+ * technology hears); and the press-paint mutations.
+ */
+
+import {expect, test, type Page} from '@playwright/test';
+import {
+ blockMainThread,
+ openStory,
+ Storyboard,
+ touchDriver,
+ withStorybook,
+ writeEvidence,
+ type ClipBox,
+ type Point,
+ type TouchDriver,
+ type TouchFrame,
+ type TouchScript,
+} from '@astryxdesign/interaction-harness';
+
+const storybook = withStorybook(test);
+
+const STORY = 'core-numberinput--with-number-steppers';
+/** How far a step may land from its beat, ms (a timer's jitter plus a render). */
+const SLACK = 45;
+/** UIStepper's first step of a hold, iOS 26.2, ms after touch-down. */
+const UIKIT_FIRST_STEP = 514;
+/**
+ * A hold's steps, ms after the press is delivered (motion/uikit/stepper.ts):
+ * 500, then 250 ms, each interval 0.9 of the one before.
+ */
+const SCHEDULE = [
+ 500, 750, 975, 1177.5, 1359.75, 1523.775, 1671.3975, 1804.25775,
+];
+
+interface Log {
+ /** The value the field shows, sampled every frame. */
+ shown: {t: number; value: number}[];
+ /** The committed value (aria-valuenow): what assistive technology hears. */
+ commits: {t: number; value: number}[];
+ paints: {
+ t: number;
+ half: 'increment' | 'decrement';
+ from: string | null;
+ paint: string | null;
+ }[];
+ touches: {t: number; type: string}[];
+}
+
+/** The page time of the log's first `type` touch event; fails if it has none. */
+function touchTime(log: Log, type: string): number {
+ const touch = log.touches.find(entry => entry.type === type);
+ if (touch == null) {
+ throw new Error(`the log has no ${type}`);
+ }
+ return touch.t;
+}
+
+/** The last item of a list that must not be empty. */
+function last(items: ReadonlyArray): T {
+ const item = items.at(-1);
+ if (item === undefined) {
+ throw new Error('the list is empty');
+ }
+ return item;
+}
+
+interface Stepper {
+ driver: TouchDriver;
+ plus: Point;
+ minus: Point;
+ /** The column's top edge, CSS px. */
+ top: number;
+ /** The column's bottom edge, CSS px. */
+ bottom: number;
+ clip: ClipBox;
+}
+
+/**
+ * Records the shown value every frame, every commit, press paint, touch and
+ * pointer press, in epoch ms on the page clock.
+ */
+function installLog(): void {
+ const now = () => performance.timeOrigin + performance.now();
+ const log: Log = {shown: [], commits: [], paints: [], touches: []};
+ (window as unknown as {stepperLog: Log}).stepperLog = log;
+ const input = document.querySelector('[role="spinbutton"]');
+ const plus = document.querySelector('[aria-label^="Increment"]');
+ const minus = document.querySelector('[aria-label^="Decrement"]');
+ if (input == null || plus == null || minus == null) {
+ throw new Error('no stepper');
+ }
+ if (!(input instanceof HTMLInputElement)) {
+ throw new Error('no input');
+ }
+ let lastShown = input.value;
+ const sample = () => {
+ if (input.value !== lastShown) {
+ lastShown = input.value;
+ log.shown.push({t: now(), value: Number(input.value)});
+ }
+ requestAnimationFrame(sample);
+ };
+ requestAnimationFrame(sample);
+ new MutationObserver(() => {
+ log.commits.push({
+ t: now(),
+ value: Number(input.getAttribute('aria-valuenow')),
+ });
+ }).observe(input, {attributes: true, attributeFilter: ['aria-valuenow']});
+ const paints = new MutationObserver(records => {
+ for (const record of records) {
+ const target = record.target as Element;
+ log.paints.push({
+ t: now(),
+ half: target === plus ? 'increment' : 'decrement',
+ from: record.oldValue,
+ paint: target.getAttribute('data-astryx-press'),
+ });
+ }
+ });
+ for (const half of [plus, minus]) {
+ paints.observe(half, {
+ attributes: true,
+ attributeOldValue: true,
+ attributeFilter: ['data-astryx-press'],
+ });
+ }
+ for (const type of [
+ 'touchstart',
+ 'touchmove',
+ 'touchend',
+ 'touchcancel',
+ 'pointerdown',
+ 'pointerup',
+ 'click',
+ ]) {
+ document.addEventListener(type, () => log.touches.push({t: now(), type}), {
+ capture: true,
+ passive: true,
+ });
+ }
+}
+
+const readLog = async (page: Page): Promise =>
+ page.evaluate(() => (window as unknown as {stepperLog: Log}).stepperLog);
+
+/** Opens the story and reads where the steppers are. */
+async function openStepper(page: Page): Promise {
+ await openStory(page, storybook(), STORY);
+ await page.getByRole('spinbutton', {name: 'Quantity'}).waitFor();
+ const driver = await touchDriver(page);
+ const box = async (name: string) => {
+ const rect = await page.getByRole('button', {name}).boundingBox();
+ if (rect == null) {
+ throw new Error(`no ${name} button`);
+ }
+ return rect;
+ };
+ const plusBox = await box('Increment Quantity');
+ const minusBox = await box('Decrement Quantity');
+ const input = await page
+ .getByRole('spinbutton', {name: 'Quantity'})
+ .boundingBox();
+ if (input == null) {
+ throw new Error('no field');
+ }
+ await page.evaluate(installLog);
+ // The field with its label above it.
+ const label = await page.getByText('Quantity', {exact: true}).boundingBox();
+ const left = Math.max(0, Math.floor(input.x - 12));
+ const top = Math.max(0, Math.floor((label?.y ?? plusBox.y) - 8));
+ return {
+ driver,
+ plus: {x: plusBox.x + plusBox.width / 2, y: plusBox.y + plusBox.height / 2},
+ minus: {
+ x: minusBox.x + minusBox.width / 2,
+ y: minusBox.y + minusBox.height / 2,
+ },
+ top: plusBox.y,
+ bottom: minusBox.y + minusBox.height,
+ clip: {
+ x: left,
+ y: top,
+ width: Math.ceil(plusBox.x + plusBox.width + 12) - left,
+ height: Math.ceil(minusBox.y + minusBox.height + 12) - top,
+ },
+ };
+}
+
+/**
+ * Turns the page's own panning off. Until a press is delivered the steppers
+ * claim nothing, so a drag the page can pan is the browser's and cancels the
+ * press (the scroll test below); once delivered, the press keeps its touch
+ * (keepsTouch, the slide test on a scrolling page). With panning off the
+ * browser feeds every touch to the page, which a finger outside the column
+ * needs to be seen at all.
+ */
+const keepTouchInPage = async (page: Page) =>
+ page.evaluate(() => {
+ document.documentElement.style.touchAction = 'none';
+ });
+
+/** Makes the page itself a scroll view, as a long form on a phone is. */
+const makePageScroll = async (page: Page) =>
+ page.evaluate(() => {
+ document.body.style.minHeight = '3000px';
+ });
+
+/** A finger held still at `at` for `ms`. */
+const hold = (at: Point, ms: number): TouchScript => [
+ {t: 0, fingers: [{id: 0, ...at}]},
+ {t: ms, fingers: []},
+];
+
+/**
+ * A tap steps on the browser's click after its lift (`spec:AST-094` FR11).
+ * These proofs script the touch (its hold, its tremble), and Playwright's
+ * WebKit (Linux build) synthesizes no click after a scripted touch on any
+ * element, a plain button included (measured against one); its own tap path
+ * does. So a scripted proof that needs the click runs in Chromium and on the
+ * iOS simulator.
+ */
+const WEBKIT_TAP_CLICK_SKIP =
+ "Playwright's WebKit synthesizes no click after a scripted touch on any element, a plain button included (measured), and a tap steps on its click (spec:AST-094 FR11); proven in Chromium and on the iOS simulator";
+
+/** Moves from `from` to `to` between `t0` and `t1` at 60 Hz. */
+function moves(from: Point, to: Point, t0: number, t1: number): TouchFrame[] {
+ const count = Math.max(1, Math.round(((t1 - t0) / 1000) * 60));
+ return Array.from({length: count}, (_, i) => {
+ const p = (i + 1) / count;
+ return {
+ t: t0 + (t1 - t0) * p,
+ fingers: [
+ {
+ id: 0,
+ x: from.x + (to.x - from.x) * p,
+ y: from.y + (to.y - from.y) * p,
+ },
+ ],
+ };
+ });
+}
+
+/** The first press's down time: its touchstart, or a mouse's pointerdown. */
+function downTime(log: Log): number {
+ const down =
+ log.touches.find(touch => touch.type === 'touchstart') ??
+ log.touches.find(touch => touch.type === 'pointerdown');
+ if (down == null) {
+ throw new Error('no press logged');
+ }
+ return down.t;
+}
+
+/** Shown step times and values, relative to the first press. */
+function stepsOf(log: Log): {t: number; value: number}[] {
+ const down = downTime(log);
+ return log.shown.map(({t, value}) => ({t: t - down, value}));
+}
+
+/** The commits: exactly one, at the release, with `value`. */
+function expectOneCommitAtRelease(log: Log, value: number): void {
+ expect(log.commits.map(commit => commit.value)).toEqual([value]);
+ const release = last(
+ log.touches.filter(
+ touch => touch.type === 'touchend' || touch.type === 'pointerup',
+ ),
+ ).t;
+ expect(log.commits[0].t).toBeGreaterThanOrEqual(release - 1);
+ expect(log.commits[0].t - release).toBeLessThan(SLACK);
+}
+
+/**
+ * Checks each step against its own beat: the first `schedule[0]` after the
+ * press was delivered (its first paint, written when the hold is armed), and
+ * each later one its interval after the step before. The repeat arms every
+ * beat from the moment its step ran (stepperTracking.ts), so a beat that a
+ * loaded machine runs late moves the beats after it. Timed from touch-down,
+ * the input's dispatch delay and every earlier beat's lateness would count
+ * against each later step.
+ */
+function expectSchedule(log: Log, times: number[], schedule: number[]): void {
+ expect(times, JSON.stringify(times)).toHaveLength(schedule.length);
+ const down = downTime(log);
+ const delivered = (log.paints[0]?.t ?? down) - down;
+ schedule.forEach((at, i) => {
+ const due =
+ i === 0 ? delivered + at : times[i - 1] + (at - schedule[i - 1]);
+ expect(
+ Math.abs(times[i] - due),
+ `step ${i + 1} at ${times[i]}, due ${Math.round(due)}: ${JSON.stringify(times)}`,
+ ).toBeLessThanOrEqual(SLACK);
+ });
+}
+
+test("a tap steps once, on the browser's click after the lift, and paints only while the finger is down", async ({
+ page,
+}) => {
+ const {driver, plus} = await openStepper(page);
+ test.skip(driver.engine === 'webkit', WEBKIT_TAP_CLICK_SKIP);
+ await driver.play(hold(plus, 80));
+ await page.waitForTimeout(700);
+ const log = await readLog(page);
+ const down = touchTime(log, 'touchstart');
+ const up = touchTime(log, 'touchend');
+ // One step, on the click the engine sends after the lift (`spec:AST-094`
+ // FR11), not at touch-down or at the lift itself.
+ expect(log.shown.map(v => v.value)).toEqual([3]);
+ expectOneCommitAtRelease(log, 3);
+ const click = touchTime(log, 'click');
+ expect(click).toBeGreaterThanOrEqual(up);
+ expect(log.commits[0].t).toBeGreaterThanOrEqual(click - 1);
+ // The touched half lights in the touch-down frame and clears at the lift,
+ // with no fade (UIStepper's fill switches in one frame both ways).
+ expect(log.paints.map(({half, paint}) => [half, paint])).toEqual([
+ ['increment', 'on'],
+ ['increment', null],
+ ]);
+ expect(log.paints[0].t - down).toBeLessThan(20);
+ expect(log.paints[1].t - up).toBeLessThan(20);
+});
+
+test('a light tap trembling on the top edge steps exactly once', async ({
+ page,
+}) => {
+ const {driver, plus, top} = await openStepper(page);
+ test.skip(driver.engine === 'webkit', WEBKIT_TAP_CLICK_SKIP);
+ const at = (dy: number) => ({x: plus.x, y: top + dy});
+ await driver.play([
+ {t: 0, fingers: [{id: 0, ...at(1)}]},
+ {t: 20, fingers: [{id: 0, ...at(-2)}]},
+ {t: 40, fingers: [{id: 0, ...at(1)}]},
+ {t: 60, fingers: [{id: 0, ...at(-3)}]},
+ {t: 80, fingers: []},
+ ]);
+ await page.waitForTimeout(700);
+ const log = await readLog(page);
+ expect(log.shown.map(v => v.value)).toEqual([3]);
+ expectOneCommitAtRelease(log, 3);
+});
+
+test('a hold shows a step at 0.5 s, then on one steady ramp, stops at the bound and commits once at the release', async ({
+ page,
+}, testInfo) => {
+ const {driver, plus} = await openStepper(page);
+ await driver.play(hold(plus, 2400));
+ await page.waitForTimeout(600);
+ const log = await readLog(page);
+ const steps = stepsOf(log);
+ // The story counts 0 to 10 from 2: eight steps reach the maximum, and the
+ // finger held on past it steps no more.
+ expect(steps.map(s => s.value)).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
+ const times = steps.map(s => Math.round(s.t));
+ expectSchedule(log, times, SCHEDULE);
+ testInfo.annotations.push({
+ type: 'uikit-parity',
+ description: `first step at ${times[0]} ms; UIStepper's at ${UIKIT_FIRST_STEP} ms`,
+ });
+ await writeEvidence(
+ 'traces/numberinput-stepper-hold',
+ {
+ [`${testInfo.project.name}.json`]: JSON.stringify(
+ {steps: times, schedule: SCHEDULE, log},
+ null,
+ 1,
+ ),
+ },
+ testInfo,
+ );
+ // Nothing was committed, or told to assistive technology, until the lift.
+ expectOneCommitAtRelease(log, 10);
+ // The lift after a repeat adds nothing, and the paint clears with it.
+ expect(log.paints.at(-1)).toMatchObject({half: 'increment', paint: null});
+});
+
+test('a slide onto the other half carries the paint and the repeat', async ({
+ page,
+}) => {
+ const {driver, plus, minus} = await openStepper(page);
+ await keepTouchInPage(page);
+ await driver.play([
+ {t: 0, fingers: [{id: 0, ...plus}]},
+ ...moves(plus, minus, 800, 900),
+ {t: 1450, fingers: []},
+ ]);
+ await page.waitForTimeout(600);
+ const log = await readLog(page);
+ const steps = stepsOf(log);
+ // + at 500 and 750 ms, then − on the same ramp: 975, 1177.5, 1359.75 ms.
+ expect(steps.map(s => s.value)).toEqual([3, 4, 3, 2, 1]);
+ expectSchedule(
+ log,
+ steps.map(s => Math.round(s.t)),
+ SCHEDULE.slice(0, 5),
+ );
+ expect(log.paints.map(({half, paint}) => [half, paint])).toEqual([
+ ['increment', 'on'],
+ ['increment', null],
+ ['decrement', 'on'],
+ ['decrement', null],
+ ]);
+ expectOneCommitAtRelease(log, 1);
+});
+
+test('a finger trembling on the edge during a hold adds no step', async ({
+ page,
+}) => {
+ const {driver, plus, top} = await openStepper(page);
+ await keepTouchInPage(page);
+ // Across the column's top edge and back every frame from 800 to 1000 ms,
+ // within the press's tracking area.
+ const tremble = Array.from({length: 12}, (_, i) => ({
+ t: 800 + i * 17,
+ fingers: [{id: 0, x: plus.x, y: top + (i % 2 === 0 ? -6 : 4)}],
+ }));
+ await driver.play([
+ {t: 0, fingers: [{id: 0, ...plus}]},
+ ...tremble,
+ {t: 1270, fingers: []},
+ ]);
+ await page.waitForTimeout(600);
+ const log = await readLog(page);
+ const steps = stepsOf(log);
+ // The steady hold's steps, on the steady hold's times.
+ expect(steps.map(s => s.value)).toEqual([3, 4, 5, 6]);
+ expectSchedule(
+ log,
+ steps.map(s => Math.round(s.t)),
+ SCHEDULE.slice(0, 4),
+ );
+ expectOneCommitAtRelease(log, 6);
+});
+
+test('on a page that scrolls, a delivered press keeps its touch: a slide up onto + carries the repeat', async ({
+ page,
+}) => {
+ const {driver, plus, minus, top} = await openStepper(page);
+ test.skip(
+ !driver.drivesNativeGestures,
+ 'injected touches do not pan this engine natively; the hand-off is proven on the Android emulator',
+ );
+ await makePageScroll(page);
+ const start = Number(
+ await page
+ .getByRole('spinbutton', {name: 'Quantity'})
+ .getAttribute('aria-valuenow'),
+ );
+ // A press on − delivered after the content-touch delay (150 ms in a scroll
+ // view), then slid up onto + (about 20 px, past the browser's slop, the way
+ // the page scrolls) before any long press: a pan the browser would take,
+ // which a tracking UIControl keeps. Held on + past its first step (+650 ms).
+ const onPlus = {x: plus.x, y: (plus.y + top) / 2};
+ await driver.play([
+ {t: 0, fingers: [{id: 0, ...minus}]},
+ ...moves(minus, onPlus, 300, 400),
+ {t: 1300, fingers: []},
+ ]);
+ await page.waitForTimeout(300);
+ // The page did not take the slide as a pan...
+ expect(await page.evaluate(() => scrollY)).toBe(0);
+ // ...and the repeat went on, on +.
+ const log = await readLog(page);
+ const steps = stepsOf(log);
+ expect(steps.length).toBeGreaterThan(0);
+ expect(steps.map(step => step.value)).toEqual(
+ steps.map((_, i) => start + 1 + i),
+ );
+ expectOneCommitAtRelease(log, last(steps).value);
+});
+
+test('leaving the column pauses the repeat, and coming back resumes it with no step at once', async ({
+ page,
+}) => {
+ const {driver, plus, bottom} = await openStepper(page);
+ await keepTouchInPage(page);
+ // Below the column, past the press's 70 pt tracking area.
+ const below = {x: plus.x, y: bottom + 100};
+ await driver.play([
+ {t: 0, fingers: [{id: 0, ...plus}]},
+ {t: 800, fingers: [{id: 0, ...below}]},
+ {t: 2700, fingers: [{id: 0, ...plus}]},
+ {t: 3000, fingers: []},
+ ]);
+ await page.waitForTimeout(600);
+ const log = await readLog(page);
+ const steps = stepsOf(log);
+ const down = downTime(log);
+ const movesAt = log.touches
+ .filter(touch => touch.type === 'touchmove')
+ .map(touch => touch.t - down);
+ const out = movesAt.find(t => t > 700);
+ const back = movesAt.find(t => t > 2600);
+ if (out == null || back == null) {
+ throw new Error(`moves not logged: ${JSON.stringify(movesAt)}`);
+ }
+ expect(steps.map(s => s.value)).toEqual([3, 4, 5]);
+ expectSchedule(
+ log,
+ steps.slice(0, 2).map(s => Math.round(s.t)),
+ [500, 750],
+ );
+ // Nothing while outside; back inside, the rest of the beat that was
+ // running when the finger left (armed 225 ms after the second step) runs
+ // first: no step at once.
+ expect(steps[2].t - back).toBeGreaterThan(40);
+ const beat = steps[1].t + (SCHEDULE[2] - SCHEDULE[1]);
+ expect(Math.abs(steps[2].t - (back + (beat - out)))).toBeLessThanOrEqual(
+ SLACK,
+ );
+ // The paint goes off when the finger leaves and comes back with it.
+ expect(log.paints.map(({paint}) => paint)).toEqual(['on', null, 'on', null]);
+ expectOneCommitAtRelease(log, 5);
+});
+
+test('under a finger each half keeps its own size, with no touch-target growth', async ({
+ page,
+}) => {
+ await openStepper(page);
+ const hits = await page.evaluate(() => {
+ const column = document.querySelector(
+ '[aria-label^="Increment"]',
+ )?.parentElement;
+ if (column == null) {
+ throw new Error('no stepper column');
+ }
+ const box = column.getBoundingClientRect();
+ const midX = (box.left + box.right) / 2;
+ const midY = (box.top + box.bottom) / 2;
+ const on = (x: number, y: number) => {
+ const element = document.elementFromPoint(x, y);
+ return element != null && column.contains(element);
+ };
+ return {
+ inside: on(midX, midY),
+ above: on(midX, box.top - 2),
+ below: on(midX, box.bottom + 2),
+ pastTheEnd: on(box.right + 2, midY),
+ reach: getComputedStyle(column, '::before').content,
+ };
+ });
+ expect(hits).toEqual({
+ inside: true,
+ above: false,
+ below: false,
+ pastTheEnd: false,
+ reach: 'none',
+ });
+});
+
+test('a scroll that starts on a stepper scrolls the page and steps nothing', async ({
+ page,
+}) => {
+ const {driver, plus} = await openStepper(page);
+ test.skip(
+ !driver.drivesNativeGestures,
+ 'injected touches do not scroll this engine natively; the scroll hand-off is proven on the iOS simulator',
+ );
+ await makePageScroll(page);
+ await driver.play([
+ {t: 0, fingers: [{id: 0, ...plus}]},
+ ...moves(plus, {x: plus.x, y: plus.y - 160}, 30, 230),
+ {t: 260, fingers: []},
+ ]);
+ await expect
+ .poll(async () => page.evaluate(() => scrollY))
+ .toBeGreaterThan(0);
+ await page.waitForTimeout(700);
+ const log = await readLog(page);
+ expect(log.shown).toEqual([]);
+ expect(log.commits).toEqual([]);
+ // The page is a scroll view, so the press waits for the content-touch
+ // delay; a finger that moves before it is a scroll and paints nothing.
+ expect(log.paints).toEqual([]);
+});
+
+test('inside a scrolling page, a held press reaches the stepper after the content-touch delay', async ({
+ page,
+}) => {
+ const {driver, plus} = await openStepper(page);
+ await makePageScroll(page);
+ await driver.play(hold(plus, 1000));
+ await page.waitForTimeout(600);
+ const log = await readLog(page);
+ const down = touchTime(log, 'touchstart');
+ // UIScrollView delivers a still finger after 150 ms; in one, UIStepper's
+ // first step came at +653 ms. The ramp then runs from the delivery.
+ expect(log.paints[0]).toMatchObject({half: 'increment', paint: 'on'});
+ expect(Math.abs(log.paints[0].t - down - 150)).toBeLessThanOrEqual(SLACK);
+ expectSchedule(
+ log,
+ stepsOf(log).map(s => Math.round(s.t)),
+ [500, 750],
+ );
+ expectOneCommitAtRelease(log, 4);
+});
+
+test('inside a scrolling page, a quick tap steps on its click and flashes the press', async ({
+ page,
+}) => {
+ const {driver, plus} = await openStepper(page);
+ test.skip(driver.engine === 'webkit', WEBKIT_TAP_CLICK_SKIP);
+ await makePageScroll(page);
+ await driver.play(hold(plus, 60));
+ await page.waitForTimeout(600);
+ const log = await readLog(page);
+ const up = touchTime(log, 'touchend');
+ expect(log.shown.map(v => v.value)).toEqual([3]);
+ expectOneCommitAtRelease(log, 3);
+ expect(log.commits[0].t).toBeGreaterThanOrEqual(touchTime(log, 'click') - 1);
+ // Quicker than the delay: the press shows at the lift for UIKit's 100 ms
+ // flash.
+ expect(log.paints.map(p => p.paint)).toEqual(['on', null]);
+ expect(log.paints[0].t - up).toBeLessThan(20);
+ expect(Math.abs(log.paints[1].t - log.paints[0].t - 100)).toBeLessThanOrEqual(
+ SLACK,
+ );
+});
+
+test('with a mouse, a held press repeats like a finger and commits once, at the release', async ({
+ browser,
+}) => {
+ const context = await browser.newContext({
+ viewport: {width: 402, height: 714},
+ hasTouch: false,
+ isMobile: false,
+ });
+ const page = await context.newPage();
+ const {plus} = await openStepper(page);
+ // A mouse: this context has no coarse pointer.
+ expect(
+ await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
+ ).toBe(false);
+ await page.mouse.move(plus.x, plus.y);
+ await page.mouse.down();
+ await page.waitForTimeout(1230);
+ await page.mouse.up();
+ await page.waitForTimeout(400);
+ const log = await readLog(page);
+ const steps = stepsOf(log);
+ expect(steps.map(s => s.value)).toEqual([3, 4, 5, 6]);
+ expectSchedule(
+ log,
+ steps.map(s => Math.round(s.t)),
+ SCHEDULE.slice(0, 4),
+ );
+ // One commit, at the release; the click after it adds nothing.
+ expectOneCommitAtRelease(log, 6);
+ expect(log.paints.map(({half, paint}) => [half, paint])).toEqual([
+ ['increment', 'on'],
+ ['increment', null],
+ ]);
+ // A mouse press moves focus to the field, as a click on a spin button does.
+ await expect(page.getByRole('spinbutton', {name: 'Quantity'})).toBeFocused();
+ await context.close();
+});
+
+test('on a touch screen a mouse click steps once and a held mouse repeats: the press tracker is its one feeder', async ({
+ page,
+}) => {
+ const {plus} = await openStepper(page);
+ // A touch device with a mouse or trackpad: the primary pointer is coarse.
+ expect(
+ await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
+ ).toBe(true);
+ const valueNow = async () =>
+ page
+ .getByRole('spinbutton', {name: 'Quantity'})
+ .getAttribute('aria-valuenow');
+ await page.mouse.click(plus.x, plus.y);
+ await expect.poll(valueNow).toBe('3');
+ await page.mouse.move(plus.x, plus.y);
+ await page.mouse.down();
+ await page.waitForTimeout(1230);
+ await page.mouse.up();
+ await expect.poll(valueNow).toBe('7');
+});
+
+test('after a scroll takes the touch, a click with no pointer still steps', async ({
+ page,
+}) => {
+ // A screen reader's activation or a programmatic click() has detail 0 and
+ // no pointerdown. No click follows a touch a scroll took, so that press must
+ // leave nothing behind that would swallow the next activation.
+ const {driver, plus} = await openStepper(page);
+ test.skip(
+ !driver.drivesNativeGestures,
+ 'injected touches do not scroll this engine natively; the cancel path is covered in jsdom',
+ );
+ await makePageScroll(page);
+ await driver.play([
+ {t: 0, fingers: [{id: 0, ...plus}]},
+ ...moves(plus, {x: plus.x, y: plus.y - 160}, 30, 230),
+ {t: 260, fingers: []},
+ ]);
+ await expect
+ .poll(async () => page.evaluate(() => scrollY))
+ .toBeGreaterThan(0);
+ await page.waitForTimeout(700);
+ expect((await readLog(page)).commits).toEqual([]);
+ await page.evaluate(() => {
+ const increment = document.querySelector('button[aria-label^="Increment"]');
+ if (!(increment instanceof HTMLElement)) {
+ throw new Error('no increment button');
+ }
+ increment.click();
+ });
+ await page.waitForTimeout(200);
+ expect((await readLog(page)).commits.map(({value}) => value)).toEqual([3]);
+});
+
+test('a step made late by a blocked main thread runs once, and the cadence resumes from it', async ({
+ page,
+}, testInfo) => {
+ const {driver, plus} = await openStepper(page);
+ const playing = driver.play(hold(plus, 2800));
+ await expect
+ .poll(async () => (await readLog(page)).shown.length, {intervals: [20]})
+ .toBe(2);
+ // Hold the main thread past the 975 ms beat.
+ const block = await blockMainThread(page, 700);
+ await playing;
+ await page.waitForTimeout(600);
+ const log = await readLog(page);
+ const after = log.shown.filter(v => v.t > block.from);
+ await writeEvidence(
+ 'busy-main-thread/numberinput-stepper-hold',
+ {
+ [`${testInfo.project.name}.json`]: JSON.stringify({block, log}, null, 1),
+ },
+ testInfo,
+ );
+ // The beat the block swallowed runs once, right after it; the next one is a
+ // full interval later (202.5 ms after a third step: no catch-up burst).
+ expect(after.length).toBeGreaterThanOrEqual(2);
+ expect(after[0].t - block.to).toBeLessThan(SLACK);
+ expect(after[1].t - after[0].t).toBeGreaterThan(202.5 - SLACK);
+ expect(after[1].t - after[0].t).toBeLessThan(202.5 + SLACK);
+});
+
+test(
+ 'storyboard: a tap, a hold, a slide, leaving and coming back, and a hold in a scrolling page',
+ {tag: '@storyboard'},
+ async ({page}, testInfo) => {
+ // The value the field shows (a press's value while it lasts).
+ const value = async () =>
+ Number(
+ await page.getByRole('spinbutton', {name: 'Quantity'}).inputValue(),
+ );
+ const paint = async () =>
+ page.evaluate(() =>
+ Array.from(document.querySelectorAll('[aria-label$="Quantity"]'))
+ .filter(el => el.tagName === 'BUTTON')
+ .map(el => el.getAttribute('data-astryx-press') ?? '-')
+ .join(' '),
+ );
+ const board = (name: string, clip: ClipBox) =>
+ new Storyboard(page, {name, clip, columns: 6, cellScale: 1});
+ const shoot = async (b: Storyboard, label: string, t: number) =>
+ b.capture(label, t, {value: await value(), paint: await paint()});
+ /** Starts one take's clock on the page: the moment of its first touch. */
+ const armTouchClock = async () =>
+ page.evaluate(() => {
+ const take = window as unknown as {takeDown?: number | null};
+ take.takeDown = null;
+ document.addEventListener(
+ 'touchstart',
+ () => {
+ take.takeDown ??= performance.now();
+ },
+ {capture: true, passive: true},
+ );
+ });
+ /**
+ * Waits on the page's own clock until `at` ms after the take's first
+ * touch, so the host's load cannot move a moment onto the next step.
+ */
+ const untilPage = async (at: number) =>
+ page.evaluate(
+ async after =>
+ new Promise(resolve => {
+ const take = window as unknown as {takeDown?: number | null};
+ const tick = () => {
+ if (
+ take.takeDown != null &&
+ performance.now() - take.takeDown >= after
+ ) {
+ resolve();
+ } else {
+ requestAnimationFrame(tick);
+ }
+ };
+ tick();
+ }),
+ at,
+ );
+ /** Plays `script` and photographs the field at each of `moments` (ms). */
+ async function filmed(
+ b: Storyboard,
+ driver: TouchDriver,
+ script: TouchScript,
+ moments: [number, string][],
+ ) {
+ await shoot(b, 'before', -1);
+ await armTouchClock();
+ const playing = driver.play(script);
+ for (const [at, label] of moments) {
+ await untilPage(at);
+ await shoot(b, label, at);
+ }
+ await playing;
+ await page.waitForTimeout(150);
+ await shoot(b, 'lifted', last(script).t + 150);
+ }
+
+ // Each board starts from the story as it loads: 2, counting 0 to 10. The
+ // moments sit between the hold's steps (500, 750, 975, 1177.5, 1359.75,
+ // 1523.8 … ms).
+ let stepper = await openStepper(page);
+ const tap = board('numberinput-stepper-tap', stepper.clip);
+ await filmed(tap, stepper.driver, hold(stepper.plus, 250), [
+ [100, 'finger down on +'],
+ ]);
+ stepper = await openStepper(page);
+ const held = board('numberinput-stepper-hold', stepper.clip);
+ await filmed(held, stepper.driver, hold(stepper.plus, 2400), [
+ [200, 'held, before 0.5 s'],
+ [620, 'first step at 0.5 s'],
+ [860, 'the next 250 ms later'],
+ [1440, 'the ramp quickens'],
+ [2200, 'stopped at the maximum'],
+ ]);
+ stepper = await openStepper(page);
+ await keepTouchInPage(page);
+ const slide = board('numberinput-stepper-slide', stepper.clip);
+ await filmed(
+ slide,
+ stepper.driver,
+ [
+ {t: 0, fingers: [{id: 0, ...stepper.plus}]},
+ ...moves(stepper.plus, stepper.minus, 780, 840),
+ {t: 1250, fingers: []},
+ ],
+ [
+ [620, 'held on +'],
+ [900, 'slid onto −'],
+ [1080, '− steps on the beat'],
+ ],
+ );
+ stepper = await openStepper(page);
+ await keepTouchInPage(page);
+ const below = {x: stepper.plus.x, y: stepper.bottom + 100};
+ const leave = board('numberinput-stepper-leave', stepper.clip);
+ await filmed(
+ leave,
+ stepper.driver,
+ [
+ {t: 0, fingers: [{id: 0, ...stepper.plus}]},
+ {t: 760, fingers: [{id: 0, ...below}]},
+ {t: 2000, fingers: [{id: 0, ...stepper.plus}]},
+ {t: 2360, fingers: []},
+ ],
+ [
+ [620, 'held on +'],
+ [1200, 'finger off the stepper'],
+ [1900, 'nothing steps outside'],
+ [2080, 'back on +: no step at once'],
+ [2300, 'the paused beat resumes'],
+ ],
+ );
+ stepper = await openStepper(page);
+ await makePageScroll(page);
+ const scroller = board('numberinput-stepper-scroller-hold', stepper.clip);
+ await filmed(scroller, stepper.driver, hold(stepper.plus, 1000), [
+ [60, 'held, before the delay'],
+ [300, 'reached at 150 ms'],
+ [780, 'first step at 0.65 s'],
+ ]);
+ for (const b of [tap, held, slide, leave, scroller]) {
+ await b.write(testInfo);
+ }
+ // The traces are the assertion; the sheets are the evidence.
+ const values = (b: Storyboard) => b.frames.map(f => f.data.value as number);
+ const paints = (b: Storyboard) => b.frames.map(f => f.data.paint as string);
+ // A tap steps on the browser's click (FR11): WebKit sends none after a
+ // scripted touch sequence, so there it steps nothing.
+ expect(values(tap)).toEqual(
+ stepper.driver.engine === 'webkit' ? [2, 2, 2] : [2, 2, 3],
+ );
+ expect(paints(tap)).toEqual(['- -', 'on -', '- -']);
+ expect(values(held)).toEqual([2, 2, 3, 4, 7, 10, 10]);
+ expect(paints(held).slice(1, 6)).toEqual(Array(5).fill('on -'));
+ expect(paints(held).at(-1)).toBe('- -');
+ // + steps at 500 and 750 ms, the finger slides at 0.8 s, − steps at 975
+ // and 1177.5 ms.
+ expect(values(slide)).toEqual([2, 3, 4, 3, 2]);
+ expect(paints(slide)).toEqual(['- -', 'on -', '- on', '- on', '- -']);
+ // Out at 760 ms with 215 ms of the 975 ms beat left; back at 2 s, so the
+ // next step comes at 2215 ms.
+ expect(values(leave)).toEqual([2, 3, 4, 4, 4, 5, 5]);
+ expect(paints(leave)).toEqual([
+ '- -',
+ 'on -',
+ '- -',
+ '- -',
+ 'on -',
+ 'on -',
+ '- -',
+ ]);
+ expect(values(scroller)).toEqual([2, 2, 2, 3, 4]);
+ expect(paints(scroller)).toEqual(['- -', '- -', 'on -', 'on -', '- -']);
+ },
+);
diff --git a/packages/core/src/NumberInput/__tests__/NumberInputStepperLook.interaction.browser.spec.ts b/packages/core/src/NumberInput/__tests__/NumberInputStepperLook.interaction.browser.spec.ts
new file mode 100644
index 0000000000000..f45af236eef7b
--- /dev/null
+++ b/packages/core/src/NumberInput/__tests__/NumberInputStepperLook.interaction.browser.spec.ts
@@ -0,0 +1,736 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file NumberInputStepperLook.interaction.browser.spec.ts
+ * @input The built "With Number Steppers" NumberInput story on a phone-sized
+ * touch screen (a coarse pointer, as on iOS and Android) and with a mouse,
+ * real touch input and the interaction harness
+ * @output Proofs for the steppers' touch presentation, against UIStepper's
+ * measured shape (motion/uikit/stepper.ts `look`): one 94 px wide capsule
+ * beside the field at its inline end, at the field size's element height
+ * (md: UIKit's 32), − at the inline start and + at the inline end
+ * (mirrored in a right-to-left page), a divider 4 px from the top and the
+ * bottom, 13pt glyphs with 1.67pt strokes; at the maximum the +
+ * glyph takes the divider colour and the + half does nothing; the touched
+ * half darkens in the touch-down frame and only that half; taps and a
+ * slide across the divider step the right half; with a mouse the column
+ * stays in the field; storyboards of the control
+ * @position NumberInput's touch-presentation proofs, beside
+ * NumberInputStepperHold.interaction.browser.spec.ts (the press timing)
+ */
+
+import {expect, test, type Page} from '@playwright/test';
+import {
+ frames,
+ Storyboard,
+ touchDriver,
+ withStorybook,
+ type Point,
+ type TouchFrame,
+} from '@astryxdesign/interaction-harness';
+
+const storybook = withStorybook(test);
+const STORY = 'core-numberinput--with-number-steppers';
+const STATUS_STORY = 'core-numberinput--error-status';
+
+interface Box {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+interface Layout {
+ field: Box;
+ control: Box;
+ minus: Box;
+ plus: Box;
+ divider: {width: string; height: string; top: string; left: string};
+ glyphs: {w: number; h: number; stroke: string | null}[];
+ chevrons: string[];
+}
+
+/**
+ * A tap steps on the browser's click after its lift (`spec:AST-094` FR11).
+ * The steppers cancel a touch's pointerdown, since Chrome on Android focuses
+ * a held stepper at its long press otherwise (measured), and WebKit's Linux
+ * build then sends no click after a tap, unlike iOS Safari and Chrome
+ * (measured; it clicks a plain button). That engine is outside
+ * `spec:AST-013`'s support, so a proof that needs the click runs in Chromium
+ * and on the iOS simulator.
+ */
+const WEBKIT_TAP_CLICK_SKIP =
+ "The steppers cancel a touch's pointerdown (Chrome on Android focuses a held stepper otherwise), and WebKit's Linux build then sends no click after a tap, unlike iOS Safari and Chrome (measured); outside spec:AST-013's support, so the tap's click is proven in Chromium and on the iOS simulator";
+
+/** Opens the story: under a finger in the phone profile, or with a mouse in a desktop context. */
+async function open(
+ page: Page,
+ globals: Record = {},
+ args: Record = {},
+ {story = STORY}: {story?: string} = {},
+): Promise {
+ const params = new URLSearchParams({id: story, viewMode: 'story'});
+ const join = (pairs: Record) =>
+ Object.entries(pairs)
+ .map(([k, v]) => `${k}:${v}`)
+ .join(';');
+ if (Object.keys(globals).length > 0) {
+ params.set('globals', join(globals));
+ }
+ if (Object.keys(args).length > 0) {
+ params.set('args', join(args));
+ }
+ await page.goto(`${storybook()}/iframe.html?${params.toString()}`);
+ await page.getByRole('spinbutton').first().waitFor();
+ await frames(page, 2);
+}
+
+/**
+ * Sets the open story's args through Storybook's preview channel: URL args
+ * keep only what the story's argTypes declare, and `width` is not one.
+ */
+async function setArgs(
+ page: Page,
+ story: string,
+ args: Record,
+): Promise {
+ await page.evaluate(
+ ([id, json]) => {
+ const channel = (
+ window as unknown as {
+ __STORYBOOK_ADDONS_CHANNEL__?: {
+ emit: (event: string, payload: unknown) => void;
+ };
+ }
+ ).__STORYBOOK_ADDONS_CHANNEL__;
+ if (channel == null) {
+ throw new Error('no Storybook preview channel');
+ }
+ channel.emit('updateStoryArgs', {
+ storyId: id,
+ updatedArgs: JSON.parse(json) as unknown,
+ });
+ },
+ [story, JSON.stringify(args)],
+ );
+ await frames(page, 3);
+ await frames(page, 2);
+}
+
+interface Boxes {
+ fieldBox: {left: number; right: number};
+ inputBox: {left: number; right: number};
+ input: {width: number};
+ steppers: {left: number; right: number; position: string};
+ status: {left: number; right: number} | null;
+}
+
+/** The Field box, the input box, the input, the steppers and an attached status. */
+const readBoxes = async (page: Page): Promise =>
+ page.evaluate(() => {
+ const edges = (el: Element) => {
+ const r = el.getBoundingClientRect();
+ return {left: r.left, right: r.right};
+ };
+ const input = document.querySelector('[role="spinbutton"]');
+ const plus = document.querySelector('[aria-label^="Increment"]');
+ if (input == null || plus == null) {
+ throw new Error('no stepper field');
+ }
+ const steppers = plus.parentElement as HTMLElement;
+ const inputBox = input.parentElement as HTMLElement;
+ let fieldBox: Element | null = inputBox;
+ while (fieldBox != null && fieldBox.querySelector('label') == null) {
+ fieldBox = fieldBox.parentElement;
+ }
+ if (fieldBox == null) {
+ throw new Error('no Field box');
+ }
+ const status = (input.getAttribute('aria-describedby') ?? '')
+ .split(/\s+/)
+ .map(id => (id === '' ? null : document.getElementById(id)))
+ .find(el => el != null && el.closest('label') == null);
+ return {
+ fieldBox: edges(fieldBox),
+ inputBox: edges(inputBox),
+ input: {width: input.getBoundingClientRect().width},
+ steppers: {
+ ...edges(steppers),
+ position: getComputedStyle(steppers).position,
+ },
+ status: status == null ? null : edges(status),
+ };
+ });
+
+const readLayout = async (page: Page): Promise =>
+ page.evaluate(() => {
+ const box = (el: Element) => {
+ const r = el.getBoundingClientRect();
+ return {x: r.x, y: r.y, w: r.width, h: r.height};
+ };
+ const plus = document.querySelector('[aria-label^="Increment"]');
+ const minus = document.querySelector('[aria-label^="Decrement"]');
+ if (plus == null || minus == null) {
+ throw new Error('no steppers');
+ }
+ const control = plus.parentElement as HTMLElement;
+ const field = control.parentElement as HTMLElement;
+ const after = getComputedStyle(control, '::after');
+ return {
+ field: box(field),
+ control: box(control),
+ minus: box(minus),
+ plus: box(plus),
+ divider: {
+ width: after.width,
+ height: after.height,
+ top: after.top,
+ left: after.left,
+ },
+ glyphs: [minus, plus].map(half => {
+ // Each half's glyph: a ::before box masked with UIKit's vector glyph.
+ const glyph = getComputedStyle(half, '::before');
+ const drawn = glyph.content !== 'none' && glyph.content !== 'normal';
+ const mask =
+ glyph.getPropertyValue('mask-image') ||
+ glyph.getPropertyValue('-webkit-mask-image');
+ const stroke = /stroke-width(?:=|%3D)(?:'|%27)([\d.]+)/.exec(mask);
+ return {
+ w: drawn ? Number.parseFloat(glyph.width) : 0,
+ h: drawn ? Number.parseFloat(glyph.height) : 0,
+ stroke: drawn ? (stroke?.[1] ?? null) : null,
+ };
+ }),
+ chevrons: [minus, plus].map(
+ half => getComputedStyle(half.firstElementChild as Element).display,
+ ),
+ };
+ });
+
+const center = (b: Box): Point => ({x: b.x + b.w / 2, y: b.y + b.h / 2});
+
+const pressed = async (page: Page) =>
+ page.evaluate(() =>
+ ['Decrement', 'Increment'].map(name => {
+ const half = document.querySelector(`[aria-label^="${name}"]`);
+ return half == null ? null : getComputedStyle(half).backgroundImage;
+ }),
+ );
+
+/** Moves from `from` to `to` between `t0` and `t1` at 60 Hz. */
+function moves(from: Point, to: Point, t0: number, t1: number): TouchFrame[] {
+ const count = Math.max(1, Math.round(((t1 - t0) / 1000) * 60));
+ return Array.from({length: count}, (_, i) => {
+ const p = (i + 1) / count;
+ return {
+ t: t0 + (t1 - t0) * p,
+ fingers: [
+ {
+ id: 0,
+ x: from.x + (to.x - from.x) * p,
+ y: from.y + (to.y - from.y) * p,
+ },
+ ],
+ };
+ });
+}
+
+/**
+ * Turns the page's own panning off. Chromium's touch emulation takes even a
+ * sideways drag on a page that cannot pan sideways, cancelling the press;
+ * Mobile Safari keeps such a touch on the page (the simulator evidence). With
+ * panning off, the slide across the divider is seen in either engine.
+ */
+const keepTouchInPage = async (page: Page) =>
+ page.evaluate(() => {
+ document.documentElement.style.touchAction = 'none';
+ });
+
+/** The committed value (aria-valuenow). */
+const valueOf = async (page: Page) =>
+ Number(
+ await page
+ .getByRole('spinbutton', {name: 'Quantity'})
+ .getAttribute('aria-valuenow'),
+ );
+
+/** The value the field shows: a press's own while it lasts. */
+const shownOf = async (page: Page) =>
+ Number(await page.getByRole('spinbutton', {name: 'Quantity'}).inputValue());
+
+test('under a finger the steppers are one 94 × 32 capsule beside the field', async ({
+ page,
+}) => {
+ await open(page);
+ const layout = await readLayout(page);
+ expect(layout.control.w).toBeCloseTo(94, 0);
+ expect(layout.control.h).toBeCloseTo(32, 0);
+ // 8px past the field's inline end, centred on it.
+ expect(layout.control.x - (layout.field.x + layout.field.w)).toBeCloseTo(
+ 8,
+ 0,
+ );
+ expect(layout.control.y + layout.control.h / 2).toBeCloseTo(
+ layout.field.y + layout.field.h / 2,
+ 0,
+ );
+ // − on the left, + on the right, 47px each.
+ expect(layout.minus.x).toBeCloseTo(layout.control.x, 0);
+ expect(layout.plus.x).toBeCloseTo(layout.control.x + 47, 0);
+ expect(layout.minus.w).toBeCloseTo(47, 0);
+ expect(layout.plus.w).toBeCloseTo(47, 0);
+ expect(layout.divider).toEqual({
+ width: '1px',
+ height: '24px',
+ top: '4px',
+ left: '46.5px',
+ });
+ expect(layout.glyphs).toEqual([
+ {w: 13, h: 13, stroke: '1.67'},
+ {w: 13, h: 13, stroke: '1.67'},
+ ]);
+ expect(layout.chevrons).toEqual(['none', 'none']);
+});
+
+test('in a right-to-left page the capsule sits at the inline end and mirrors: + at the inline end (left), − at the inline start', async ({
+ page,
+}) => {
+ await open(page, {direction: 'rtl'});
+ const layout = await readLayout(page);
+ expect(layout.field.x - (layout.control.x + layout.control.w)).toBeCloseTo(
+ 8,
+ 0,
+ );
+ expect(layout.plus.x).toBeLessThan(layout.minus.x);
+ expect(layout.plus.x).toBeCloseTo(layout.control.x, 0);
+});
+
+test('the touched half darkens in the touch-down frame, and only that half', async ({
+ page,
+}) => {
+ await open(page);
+ const layout = await readLayout(page);
+ const driver = await touchDriver(page);
+ const states: (string | null)[][] = [await pressed(page)];
+ await driver.play(
+ [
+ {t: 0, fingers: [{id: 0, ...center(layout.plus)}]},
+ {t: 200, fingers: []},
+ ],
+ {
+ onEvent: async step => {
+ await frames(page, 1);
+ states.push([step.type, ...(await pressed(page))]);
+ },
+ },
+ );
+ const [rest, down, up] = states;
+ expect(rest).toEqual(['none', 'none']);
+ expect(down[0]).toBe('touchStart');
+ expect(down[1]).toBe('none');
+ expect(down[2]).not.toBe('none');
+ expect(up).toEqual(['touchEnd', 'none', 'none']);
+});
+
+test('a tap on the left half steps down, on the right half up', async ({
+ page,
+}) => {
+ await open(page);
+ const layout = await readLayout(page);
+ const driver = await touchDriver(page);
+ test.skip(driver.engine === 'webkit', WEBKIT_TAP_CLICK_SKIP);
+ await driver.tap(center(layout.minus));
+ await expect.poll(async () => valueOf(page)).toBe(1);
+ await driver.tap(center(layout.plus));
+ await expect.poll(async () => valueOf(page)).toBe(2);
+});
+
+test('a slide across the divider carries the repeat to the other half', async ({
+ page,
+}) => {
+ await open(page);
+ await keepTouchInPage(page);
+ const layout = await readLayout(page);
+ const driver = await touchDriver(page);
+ const minus = center(layout.minus);
+ const plus = center(layout.plus);
+ const seen: number[] = [];
+ const watching = (async () => {
+ for (let i = 0; i < 40; i += 1) {
+ const value = await shownOf(page);
+ if (seen.at(-1) !== value) {
+ seen.push(value);
+ }
+ await page.waitForTimeout(40);
+ }
+ })();
+ await driver.play([
+ {t: 0, fingers: [{id: 0, ...minus}]},
+ ...moves(minus, plus, 600, 700),
+ {t: 1100, fingers: []},
+ ]);
+ await watching;
+ // − at 0.5 s (2 → 1), then + on the same ramp at 750 and 975 ms; one
+ // commit, at the lift.
+ expect(seen).toEqual([2, 1, 2, 3]);
+ expect(await valueOf(page)).toBe(3);
+});
+
+test('under a finger each half of the capsule keeps its own size, 47 × 32, with no touch-target growth', async ({
+ page,
+}) => {
+ await open(page);
+ const layout = await readLayout(page);
+ expect(layout.plus.w).toBeCloseTo(47, 0);
+ expect(layout.plus.h).toBeCloseTo(32, 0);
+ const hits = await page.evaluate(
+ ({control}) => {
+ const capsule = document.querySelector(
+ '[aria-label^="Increment"]',
+ )?.parentElement;
+ const at = (x: number, y: number) => {
+ const element = document.elementFromPoint(x, y);
+ return element != null && capsule?.contains(element) === true;
+ };
+ const x = control.x + control.w * 0.75;
+ return {
+ inside: at(x, control.y + control.h / 2),
+ above: at(x, control.y - 2),
+ below: at(x, control.y + control.h + 2),
+ };
+ },
+ {control: layout.control},
+ );
+ expect(hits).toEqual({inside: true, above: false, below: false});
+});
+
+for (const direction of ['ltr', 'rtl'] as const) {
+ test(`a pressed half keeps the capsule's rounded outer corners (${direction})`, async ({
+ page,
+ }) => {
+ await open(page, {direction});
+ const radii = await page.evaluate(() => {
+ const read = (selector: string) => {
+ const half = document.querySelector(selector);
+ if (half == null) {
+ throw new Error(`no ${selector}`);
+ }
+ const style = getComputedStyle(half);
+ return [
+ style.borderTopLeftRadius,
+ style.borderTopRightRadius,
+ style.borderBottomRightRadius,
+ style.borderBottomLeftRadius,
+ ].join(' ');
+ };
+ return {
+ minus: read('[aria-label^="Decrement"]'),
+ plus: read('[aria-label^="Increment"]'),
+ };
+ });
+ // Each half rounds its outer corners: − at the inline start, + at the
+ // inline end, so they swap sides in a right-to-left page (the browser
+ // clamps 9999px to half the height: a capsule).
+ const start = '9999px 0px 0px 9999px';
+ const end = '0px 9999px 9999px 0px';
+ expect(radii).toEqual(
+ direction === 'ltr'
+ ? {minus: start, plus: end}
+ : {minus: end, plus: start},
+ );
+ });
+}
+
+test("at each field size the capsule takes the size's element height (28, 32, 36), 94 wide, centred on the field", async ({
+ page,
+}) => {
+ // UIStepper is 32pt tall whatever surrounds it; the field keeps its own
+ // height (28, 32, 36).
+ const heights = {sm: 28, md: 32, lg: 36} as const;
+ for (const [size, height] of Object.entries(heights)) {
+ await open(page, {}, {size});
+ const layout = await readLayout(page);
+ expect(layout.field.h, size).toBeCloseTo(height, 0);
+ expect(layout.control.w, size).toBeCloseTo(94, 0);
+ expect(layout.control.h, size).toBeCloseTo(height, 0);
+ expect(layout.control.y + layout.control.h / 2, size).toBeCloseTo(
+ layout.field.y + layout.field.h / 2,
+ 0,
+ );
+ expect(
+ layout.control.x - (layout.field.x + layout.field.w),
+ size,
+ ).toBeCloseTo(8, 0);
+ }
+});
+
+test("under forced colors the glyphs, the divider and the capsule's edge stay visible", async ({
+ page,
+}) => {
+ // Windows High Contrast and the like: backgrounds turn Canvas.
+ await page.emulateMedia({forcedColors: 'active'});
+ await open(page, {}, {value: '10'});
+ const colors = await page.evaluate(() => {
+ const control = document.querySelector('[aria-label^="Increment"]')
+ ?.parentElement as HTMLElement;
+ const plus = control.querySelector('[aria-label^="Increment"]') as Element;
+ const minus = control.querySelector('[aria-label^="Decrement"]') as Element;
+ const probe = document.createElement('div');
+ probe.style.backgroundColor = 'Canvas';
+ document.body.append(probe);
+ const canvas = getComputedStyle(probe).backgroundColor;
+ probe.remove();
+ return {
+ canvas,
+ minusGlyph: getComputedStyle(minus, '::before').backgroundColor,
+ plusGlyph: getComputedStyle(plus, '::before').backgroundColor,
+ divider: getComputedStyle(control, '::after').backgroundColor,
+ edge: getComputedStyle(control).outlineStyle,
+ };
+ });
+ expect(colors.minusGlyph).not.toBe(colors.canvas);
+ expect(colors.divider).not.toBe(colors.canvas);
+ // + is at its bound: GrayText, apart from the live glyph's CanvasText.
+ expect(colors.plusGlyph).not.toBe(colors.canvas);
+ expect(colors.plusGlyph).not.toBe(colors.minusGlyph);
+ expect(colors.edge).toBe('solid');
+});
+
+test('at the maximum the + glyph takes the divider colour and the + half does nothing', async ({
+ page,
+}) => {
+ await open(page, {}, {value: '10'});
+ const layout = await readLayout(page);
+ const colors = await page.evaluate(() => {
+ const control = document.querySelector('[aria-label^="Increment"]')
+ ?.parentElement as HTMLElement;
+ return {
+ plus: getComputedStyle(
+ control.querySelector('[aria-label^="Increment"]') as Element,
+ ).color,
+ minus: getComputedStyle(
+ control.querySelector('[aria-label^="Decrement"]') as Element,
+ ).color,
+ divider: getComputedStyle(control, '::after').backgroundColor,
+ };
+ });
+ expect(colors.plus).toBe(colors.divider);
+ expect(colors.minus).not.toBe(colors.divider);
+ const driver = await touchDriver(page);
+ await driver.tap(center(layout.plus));
+ await page.waitForTimeout(300);
+ expect(await valueOf(page)).toBe(10);
+ expect(await pressed(page)).toEqual(['none', 'none']);
+});
+
+test('in a field too narrow for the control the steppers stay in its box', async ({
+ page,
+}) => {
+ // A quantity field sized to 96 px (the checkout-wizard template) cannot hold
+ // the control (94 px and a gap) beside a usable box: the column stays inside.
+ await open(page);
+ for (const width of [96, '96px']) {
+ await setArgs(page, STORY, {width});
+ // In the box, with no positioning of its own (no touch-target growth).
+ await expect
+ .poll(async () => (await readBoxes(page)).steppers.position)
+ .toBe('static');
+ const boxes = await readBoxes(page);
+ expect(boxes.input.width).toBeGreaterThanOrEqual(40);
+ expect(boxes.steppers.right).toBeLessThanOrEqual(
+ boxes.fieldBox.right + 0.5,
+ );
+ }
+ // Wide enough again, the control is back beside the box.
+ await setArgs(page, STORY, {width: 240});
+ await expect
+ .poll(async () => (await readBoxes(page)).steppers.position)
+ .toBe('absolute');
+});
+
+test('an attached status ends where the input box ends, clear of the control', async ({
+ page,
+}) => {
+ await open(page, {}, {}, {story: STATUS_STORY});
+ await setArgs(page, STATUS_STORY, {hasNumberSteppers: true});
+ const boxes = await readBoxes(page);
+ expect(boxes.steppers.position).toBe('absolute');
+ if (boxes.status == null) {
+ throw new Error('no attached status');
+ }
+ expect(Math.abs(boxes.status.right - boxes.inputBox.right)).toBeLessThan(1);
+ expect(boxes.steppers.left).toBeGreaterThan(boxes.status.right);
+ expect(boxes.steppers.right).toBeLessThanOrEqual(boxes.fieldBox.right + 0.5);
+});
+
+test('with a mouse every base style survives the touch styles (computed)', async ({
+ browser,
+}) => {
+ // Without the touch key the touch styles must change nothing: each default is
+ // a value, never null, which would erase a style merged before it.
+ const context = await browser.newContext({
+ viewport: {width: 402, height: 714},
+ hasTouch: false,
+ isMobile: false,
+ });
+ const page = await context.newPage();
+ await open(page);
+ // A mouse: this context has no coarse pointer.
+ expect(
+ await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
+ ).toBe(false);
+ const computed = await page.evaluate(() => {
+ const plus = document.querySelector(
+ 'button[aria-label^="Increment"]',
+ ) as HTMLElement;
+ const column = plus.parentElement as HTMLElement;
+ const wrapper = column.parentElement as HTMLElement;
+ const icons = [plus, plus.nextElementSibling as HTMLElement].map(half => {
+ const svg = half.querySelector('svg');
+ return getComputedStyle(svg?.parentElement as Element).display;
+ });
+ const css = (el: Element, prop: string) =>
+ getComputedStyle(el).getPropertyValue(prop);
+ return {
+ icons,
+ columnPosition: css(column, 'position'),
+ columnBackground: css(column, 'background-color'),
+ afterContent: getComputedStyle(column, '::after').content,
+ wrapperMargin: css(wrapper, 'margin-inline-end'),
+ };
+ });
+ expect(computed).toEqual({
+ // Icon's own inline-flex, blockified as the button's flex item (an erased
+ // display would fall back to inline and compute as block).
+ icons: ['flex', 'flex'],
+ columnPosition: 'static',
+ columnBackground: 'rgba(0, 0, 0, 0)',
+ afterContent: 'none',
+ wrapperMargin: '0px',
+ });
+ await context.close();
+});
+
+test('with a mouse the column stays inside the field', async ({browser}) => {
+ const context = await browser.newContext({
+ viewport: {width: 402, height: 714},
+ hasTouch: false,
+ isMobile: false,
+ });
+ const page = await context.newPage();
+ await open(page);
+ // A mouse: this context has no coarse pointer.
+ expect(
+ await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
+ ).toBe(false);
+ const layout = await readLayout(page);
+ expect(layout.control.w).toBeCloseTo(16, 0);
+ expect(layout.control.x + layout.control.w).toBeCloseTo(
+ layout.field.x + layout.field.w - 1,
+ 0,
+ );
+ expect(layout.plus.y).toBeLessThan(layout.minus.y);
+ expect(layout.glyphs.map(g => g.w)).toEqual([0, 0]);
+ await context.close();
+});
+
+/** Starts one take's clock on the page: the moment of its first touch. */
+const armTouchClock = async (page: Page) =>
+ page.evaluate(() => {
+ const take = window as unknown as {takeDown?: number | null};
+ take.takeDown = null;
+ document.addEventListener(
+ 'touchstart',
+ () => {
+ take.takeDown ??= performance.now();
+ },
+ {capture: true, passive: true},
+ );
+ });
+
+/**
+ * Waits on the page's own clock until `at` ms after the take's first touch,
+ * so the host's load cannot move a moment onto the next step.
+ */
+const untilPage = async (page: Page, at: number) =>
+ page.evaluate(
+ async after =>
+ new Promise(resolve => {
+ const take = window as unknown as {takeDown?: number | null};
+ const tick = () => {
+ if (
+ take.takeDown != null &&
+ performance.now() - take.takeDown >= after
+ ) {
+ resolve();
+ } else {
+ requestAnimationFrame(tick);
+ }
+ };
+ tick();
+ }),
+ at,
+ );
+
+test(
+ 'storyboard: the touch control at rest, pressed, held and slid',
+ {tag: '@storyboard'},
+ async ({page}, testInfo) => {
+ for (const mode of ['light', 'dark'] as const) {
+ await open(page, {colorMode: mode});
+ await keepTouchInPage(page);
+ const layout = await readLayout(page);
+ const clip = {
+ x: Math.floor(layout.field.x - 8),
+ y: Math.floor(layout.field.y - 36),
+ width: Math.ceil(
+ layout.control.x + layout.control.w + 8 - (layout.field.x - 8),
+ ),
+ height: Math.ceil(layout.field.h + 44),
+ };
+ const board = new Storyboard(page, {
+ name: `numberinput-stepper-control-${mode}`,
+ clip,
+ columns: 5,
+ cellScale: 1,
+ });
+ const driver = await touchDriver(page);
+ const data = async () => ({
+ value: await shownOf(page),
+ pressed: (await pressed(page)).map(image =>
+ image === 'none' ? '-' : 'on',
+ ),
+ });
+ await board.capture('rest', -1, await data());
+ const minus = center(layout.minus);
+ const plus = center(layout.plus);
+ await armTouchClock(page);
+ const playing = driver.play([
+ {t: 0, fingers: [{id: 0, ...plus}]},
+ ...moves(plus, minus, 780, 840),
+ {t: 1250, fingers: []},
+ ]);
+ for (const [at, label] of [
+ [150, '+ pressed'],
+ [620, '+ steps at 0.5 s'],
+ [900, 'slid onto −'],
+ [1080, '− steps on the beat'],
+ ] as const) {
+ await untilPage(page, at);
+ await board.capture(label, at, await data());
+ }
+ await playing;
+ await page.waitForTimeout(150);
+ await board.capture('lifted', 1400, await data());
+ await board.write(testInfo);
+ const values = board.frames.map(f => Number(f.data.value));
+ const paints = board.frames.map(f =>
+ (f.data.pressed as string[]).join(' '),
+ );
+ // + steps at 500 and 750 ms, the finger slides at 0.8 s, − steps at 975
+ // and 1177.5 ms.
+ expect(values).toEqual([2, 2, 3, 4, 3, 2]);
+ expect(paints).toEqual(['- -', '- on', '- on', 'on -', 'on -', '- -']);
+ }
+ },
+);
diff --git a/packages/core/src/NumberInput/stepperTracking.test.ts b/packages/core/src/NumberInput/stepperTracking.test.ts
new file mode 100644
index 0000000000000..24d1ef2b00d6d
--- /dev/null
+++ b/packages/core/src/NumberInput/stepperTracking.test.ts
@@ -0,0 +1,422 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file stepperTracking.test.ts
+ * @input vitest, StepperTracking, and UIStepper's measured delay
+ * @output The stepper's tap and autorepeat rules on explicit times: taps step
+ * at the lift (a tap trembling on the edge too), the hold's one steady ramp,
+ * slides, leaving and coming back (a pause, never a step at once), edge
+ * tremble during a hold, bounds, a busy main thread, cancellation
+ * @position Unit tests for NumberInput/stepperTracking.ts (no DOM, no real
+ * time)
+ */
+
+import {describe, expect, it} from 'vitest';
+import type {GestureClock} from '../gestures';
+import {stepper} from '../motion/uikit/stepper';
+import {
+ halfAt,
+ repeatInterval,
+ StepperTracking,
+ type StepperHalf,
+} from './stepperTracking';
+
+/** A clock whose timers fire only when the test moves time. */
+function manualClock() {
+ let now = 0;
+ let nextId = 1;
+ const timers = new Map void}>();
+ const clock: GestureClock = {
+ now: () => now,
+ setTimeout: (run, ms) => {
+ const id = nextId++;
+ timers.set(id, {at: now + ms, run});
+ return id;
+ },
+ clearTimeout: id => {
+ timers.delete(id as number);
+ },
+ };
+ const nextDue = (upTo: number) => {
+ let due: [number, {at: number; run: () => void}] | null = null;
+ for (const entry of timers) {
+ if (entry[1].at <= upTo && (due == null || entry[1].at < due[1].at)) {
+ due = entry;
+ }
+ }
+ return due;
+ };
+ return {
+ clock,
+ /** Moves time to `to`, running each timer at its own deadline. */
+ advanceTo(to: number) {
+ for (let due = nextDue(to); due != null; due = nextDue(to)) {
+ timers.delete(due[0]);
+ now = due[1].at;
+ due[1].run();
+ }
+ now = to;
+ },
+ /** A blocked main thread until `to`: then every timer due runs once, late. */
+ stallTo(to: number) {
+ now = to;
+ for (let due = nextDue(to); due != null; due = nextDue(to)) {
+ timers.delete(due[0]);
+ due[1].run();
+ }
+ },
+ get now() {
+ return now;
+ },
+ };
+}
+
+function setup(canStep: (half: StepperHalf) => boolean = () => true) {
+ const time = manualClock();
+ const steps: {half: StepperHalf; at: number}[] = [];
+ const tracking = new StepperTracking({
+ clock: time.clock,
+ step: half => {
+ if (!canStep(half)) {
+ return false;
+ }
+ steps.push({half, at: time.now});
+ return true;
+ },
+ });
+ /** A finger lands on `half` at time 0 and the press reaches the control at `deliverAt`. */
+ const press = (half: StepperHalf, deliverAt = 0) => {
+ tracking.begin(half);
+ if (deliverAt === 0) {
+ tracking.deliver(0);
+ } else {
+ time.advanceTo(deliverAt);
+ tracking.deliver(deliverAt);
+ }
+ };
+ return {time, steps, tracking, press};
+}
+
+const at = (steps: {at: number}[]) => steps.map(step => step.at);
+
+/** The times the ramp steps at, from the delivery: 500, then 250 ms shrinking by 0.9 to 50. */
+function rampTimes(count: number, from = 0): number[] {
+ const times = [from + stepper.autorepeat.delayMs];
+ while (times.length < count) {
+ times.push(times.at(-1)! + repeatInterval(times.length));
+ }
+ return times;
+}
+
+const expectTimes = (actual: number[], expected: number[]) => {
+ expect(actual, JSON.stringify(actual)).toHaveLength(expected.length);
+ expected.forEach((time, i) => expect(actual[i]).toBeCloseTo(time, 6));
+};
+
+describe('StepperTracking: taps', () => {
+ it('a tap steps nothing at the lift: end names the half its click steps', () => {
+ // A control activates by the browser's click on a lift inside
+ // (`spec:AST-094` FR11): the lift only says which half that click steps.
+ const {time, steps, tracking, press} = setup();
+ press('increment');
+ time.advanceTo(60);
+ expect(tracking.end('increment')).toBe('increment');
+ expect(steps).toEqual([]);
+ time.advanceTo(3000);
+ expect(steps).toEqual([]);
+ });
+
+ it('a light tap trembling across the edge names its half, once', () => {
+ const {time, steps, tracking, press} = setup();
+ press('increment');
+ for (const t of [10, 20, 30, 40, 50]) {
+ time.advanceTo(t);
+ tracking.move(null);
+ time.advanceTo(t + 4);
+ tracking.move('increment');
+ }
+ time.advanceTo(70);
+ expect(tracking.end('increment')).toBe('increment');
+ time.advanceTo(3000);
+ expect(steps).toEqual([]);
+ });
+
+ it('a finger that slid from − onto + before any repeat names +', () => {
+ const {time, steps, tracking, press} = setup();
+ press('decrement');
+ time.advanceTo(200);
+ tracking.move('increment');
+ time.advanceTo(350);
+ expect(tracking.end('increment')).toBe('increment');
+ expect(steps).toEqual([]);
+ });
+
+ it('a lift outside the control names nothing and steps nothing', () => {
+ const {time, steps, tracking, press} = setup();
+ press('increment');
+ time.advanceTo(120);
+ tracking.move(null);
+ expect(tracking.end(null)).toBeNull();
+ time.advanceTo(2000);
+ expect(steps).toEqual([]);
+ });
+
+ it('a tap quicker than a scroll view’s delay still names its half', () => {
+ const {time, steps, tracking} = setup();
+ tracking.begin('increment');
+ time.advanceTo(60);
+ expect(tracking.end('increment')).toBe('increment');
+ expect(steps).toEqual([]);
+ });
+
+ it('a lift with no press names nothing', () => {
+ const {tracking} = setup();
+ expect(tracking.end('increment')).toBeNull();
+ });
+});
+
+describe('StepperTracking: holding a half', () => {
+ it('steps at UIStepper’s 0.5 s, then on one steady ramp down to 50 ms', () => {
+ const {time, steps, press} = setup();
+ press('increment');
+ time.advanceTo(1200);
+ // 500, then 250, 225 and 202.5 ms apart.
+ expectTimes(at(steps), [500, 750, 975, 1177.5]);
+ time.advanceTo(4000);
+ expectTimes(at(steps), rampTimes(steps.length));
+ expect(steps.every(step => step.half === 'increment')).toBe(true);
+ });
+
+ it('the ramp has no jump: each interval is 0.9 of the last until 50 ms, then stays', () => {
+ const {time, steps, press} = setup();
+ press('increment');
+ time.advanceTo(6000);
+ const gaps = at(steps)
+ .slice(1)
+ .map((t, i) => t - at(steps)[i]);
+ expect(gaps[0]).toBeCloseTo(250, 6);
+ for (let i = 1; i < gaps.length; i += 1) {
+ const expected = Math.max(50, gaps[i - 1] * 0.9);
+ expect(gaps[i], `gap ${i + 1}`).toBeCloseTo(expected, 6);
+ }
+ expect(Math.min(...gaps)).toBeCloseTo(50, 6);
+ // The ramp reaches its floor within three seconds of the press.
+ expect(
+ at(steps).find((t, i) => i > 0 && t - at(steps)[i - 1] === 50),
+ ).toBeLessThan(3000);
+ });
+
+ it('a lift after a repeat names nothing and adds no step', () => {
+ // Its click is no step either: the press stepped already.
+ const {time, steps, tracking, press} = setup();
+ press('increment');
+ time.advanceTo(600);
+ expect(tracking.end('increment')).toBeNull();
+ time.advanceTo(3000);
+ expect(at(steps)).toEqual([500]);
+ });
+
+ it('inside a scroll view the delay counts from the delayed delivery', () => {
+ const {time, steps, press} = setup();
+ // UIKit's first step in a scroll view: +653 ms.
+ press('increment', 150);
+ time.advanceTo(1000);
+ expect(at(steps)).toEqual([650, 900]);
+ });
+
+ it('nothing repeats before the press reaches the control', () => {
+ const {time, steps, tracking} = setup();
+ tracking.begin('increment');
+ time.advanceTo(2000);
+ expect(steps).toEqual([]);
+ });
+
+ it('repeatInterval is 250 ms, then 0.9 of the last, never under 50 ms', () => {
+ const intervals = [0, 1, 2, 3, 10, 16, 17, 80].map(repeatInterval);
+ expectTimes(intervals, [
+ 250,
+ 250,
+ 225,
+ 202.5,
+ 250 * 0.9 ** 9,
+ 250 * 0.9 ** 15,
+ 50,
+ 50,
+ ]);
+ });
+});
+
+describe('StepperTracking: moving the finger', () => {
+ it('a slide onto the other half keeps the running cadence', () => {
+ const {time, steps, tracking, press} = setup();
+ press('decrement');
+ time.advanceTo(800);
+ tracking.move('increment');
+ time.advanceTo(1200);
+ expect(steps.map(step => step.half)).toEqual([
+ 'decrement',
+ 'decrement',
+ 'increment',
+ 'increment',
+ ]);
+ expectTimes(at(steps), [500, 750, 975, 1177.5]);
+ });
+
+ it('leaving the control pauses the cadence', () => {
+ const {time, steps, tracking, press} = setup();
+ press('increment');
+ time.advanceTo(800);
+ tracking.move(null);
+ time.advanceTo(6000);
+ expect(at(steps)).toEqual([500, 750]);
+ });
+
+ it('coming back resumes the rest of the paused interval, never a step at once', () => {
+ const {time, steps, tracking, press} = setup();
+ // Left at 800 with 175 ms of the 975 interval still to run.
+ press('increment');
+ time.advanceTo(800);
+ tracking.move(null);
+ time.advanceTo(5000);
+ tracking.move('increment');
+ expect(at(steps)).toEqual([500, 750]);
+ time.advanceTo(5400);
+ expectTimes(at(steps), [500, 750, 5175, 5377.5]);
+ });
+
+ it('trembling on the edge during a hold adds no step and does not speed the ramp', () => {
+ const steady = setup();
+ steady.press('increment');
+ steady.time.advanceTo(2000);
+
+ const trembling = setup();
+ trembling.press('increment');
+ // Out for 2 ms at a time, ten times, between 800 and 900.
+ for (let t = 800; t < 900; t += 10) {
+ trembling.time.advanceTo(t);
+ trembling.tracking.move(null);
+ trembling.time.advanceTo(t + 2);
+ trembling.tracking.move('increment');
+ }
+ trembling.time.advanceTo(2000 + 20);
+ // The same steps, each later by the 20 ms spent outside.
+ expectTimes(
+ at(trembling.steps),
+ at(steady.steps).map(t => (t < 800 ? t : t + 20)),
+ );
+ });
+
+ it('coming back before the first repeat keeps the rest of the delay', () => {
+ const early = setup();
+ early.press('increment');
+ early.time.advanceTo(200);
+ early.tracking.move(null);
+ early.time.advanceTo(300);
+ early.tracking.move('increment');
+ early.time.advanceTo(900);
+ expect(at(early.steps)).toEqual([600, 850]);
+
+ const late = setup();
+ late.press('increment');
+ late.time.advanceTo(200);
+ late.tracking.move(null);
+ late.time.advanceTo(700);
+ late.tracking.move('increment');
+ late.time.advanceTo(1300);
+ expect(at(late.steps)).toEqual([1000, 1250]);
+ });
+
+ it('a half at its bound does not step; the other half steps on the next beat', () => {
+ const time = manualClock();
+ let value = 8;
+ const steps: {half: StepperHalf; at: number}[] = [];
+ const tracking = new StepperTracking({
+ clock: time.clock,
+ step: half => {
+ const next = half === 'increment' ? value + 1 : value - 1;
+ if (next < 0 || next > 10) {
+ return false;
+ }
+ value = next;
+ steps.push({half, at: time.now});
+ return true;
+ },
+ });
+ tracking.begin('increment');
+ tracking.deliver(0);
+ time.advanceTo(1300);
+ expect(at(steps)).toEqual([500, 750]);
+ expect(value).toBe(10);
+ // The clock ran on at the bound (975, 1200, 1425 …): − steps on the next beat.
+ tracking.move('decrement');
+ time.advanceTo(1500);
+ expect(steps.at(-1)).toEqual({half: 'decrement', at: 1425});
+ expect(value).toBe(9);
+ });
+});
+
+describe('StepperTracking: a busy main thread and cancellation', () => {
+ it('a step that runs late runs once, and the cadence resumes from it', () => {
+ const {time, steps, press} = setup();
+ press('increment');
+ time.advanceTo(1000);
+ // The main thread is blocked from 1000 to 1700: the 1177.5 step runs late,
+ // once, and the next one is a full interval after it.
+ time.stallTo(1700);
+ time.advanceTo(1900);
+ expectTimes(at(steps), [500, 750, 975, 1700, 1882.25]);
+ });
+
+ it('a cancel steps nothing and stops the clock', () => {
+ const {time, steps, tracking, press} = setup();
+ press('increment');
+ time.advanceTo(300);
+ tracking.cancel();
+ time.advanceTo(2000);
+ expect(steps).toEqual([]);
+ expect(tracking.isTracking).toBe(false);
+ });
+
+ it('a cancel after a repeat stops the clock', () => {
+ const {time, steps, tracking, press} = setup();
+ press('increment');
+ time.advanceTo(800);
+ tracking.cancel();
+ time.advanceTo(3000);
+ expect(at(steps)).toEqual([500, 750]);
+ });
+
+ it('a new press starts from nothing', () => {
+ const {time, steps, tracking, press} = setup();
+ press('increment');
+ time.advanceTo(3600);
+ tracking.end('increment');
+ const before = steps.length;
+ const startedAt = time.now;
+ tracking.begin('increment');
+ tracking.deliver(startedAt);
+ time.advanceTo(startedAt + 800);
+ expect(at(steps.slice(before))).toEqual([startedAt + 500, startedAt + 750]);
+ });
+});
+
+describe('halfAt', () => {
+ it('splits a column at the middle', () => {
+ const column = {increment: {x: 8, y: 8}, decrement: {x: 8, y: 24}};
+ expect(halfAt(column, {x: 8, y: 2})).toBe('increment');
+ expect(halfAt(column, {x: 12, y: 15})).toBe('increment');
+ expect(halfAt(column, {x: 4, y: 17})).toBe('decrement');
+ });
+
+ it('splits the touch control at its divider: − on the left, + on the right', () => {
+ const control = {decrement: {x: 23.5, y: 16}, increment: {x: 70.5, y: 16}};
+ expect(halfAt(control, {x: 1, y: 30})).toBe('decrement');
+ expect(halfAt(control, {x: 46, y: 2})).toBe('decrement');
+ expect(halfAt(control, {x: 48, y: 2})).toBe('increment');
+ expect(halfAt(control, {x: 93, y: 16})).toBe('increment');
+ });
+
+ it('answers the increment half before any layout was read', () => {
+ expect(halfAt(null, {x: 0, y: 0})).toBe('increment');
+ });
+});
diff --git a/packages/core/src/NumberInput/stepperTracking.ts b/packages/core/src/NumberInput/stepperTracking.ts
new file mode 100644
index 0000000000000..5108a90e01b3b
--- /dev/null
+++ b/packages/core/src/NumberInput/stepperTracking.ts
@@ -0,0 +1,226 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file stepperTracking.ts
+ * @input The half of a number stepper under a pointer over time, and a clock
+ * @output Exports StepperTracking (the stepper's tap and autorepeat rules on
+ * explicit times), repeatInterval, halfAt (which half a point is on) and
+ * the StepperHalf type
+ * @position Pure half of NumberInput's stepper press: no DOM. The press layer
+ * (NumberInputTouch.tsx) feeds it from the gesture system's PressTracker,
+ * for every pointer; tested by stepperTracking.test.ts with a manual clock
+ *
+ * The rules (motion/uikit/stepper.ts):
+ *
+ * - A tap steps nothing at its lift: a control activates by the browser's
+ * click on a lift inside (`spec:AST-094` FR11). The lift names the half
+ * that click steps, the one under the pointer then: a pointer that slid
+ * from − onto + before anything repeated names +. A lift outside the
+ * control names nothing.
+ * - A held press steps `delayMs` after it reached the control (UIStepper's
+ * measured delay), then keeps stepping on one steady ramp: after
+ * `firstIntervalMs`, then each interval `acceleration` times the one
+ * before, never under `minIntervalMs`. Each interval is timed from the
+ * step before it, so a step that runs late (a busy main thread) runs once,
+ * never as a burst. A lift after a repeat names nothing, so its click adds
+ * nothing.
+ * - Sliding onto the other half keeps the running cadence; the next step
+ * lands on the new half.
+ * - Leaving the control pauses the cadence and coming back resumes it: the
+ * next step comes once the rest of the paused interval has passed, never
+ * at once, so a pointer trembling at the edge adds no step and does not
+ * speed the ramp.
+ * - A half at its bound does not step; the clock runs on, so sliding to the
+ * other half steps it on the next beat.
+ *
+ * SYNC: When modified, update stepperTracking.test.ts and the press layer
+ * in NumberInputTouch.tsx.
+ */
+
+import {browserClock, type GestureClock} from '../gestures';
+import {stepper} from '../motion/uikit/stepper';
+
+export type StepperHalf = 'increment' | 'decrement';
+
+export interface StepperTrackingOptions {
+ /**
+ * Step the value toward `half`. Returns false when that half cannot step
+ * (its bound is reached, or the field is disabled or read-only).
+ */
+ step: (half: StepperHalf) => boolean;
+ /** The clock timers run on: `performance.now()`, the base of `event.timeStamp`. */
+ clock?: GestureClock;
+}
+
+interface Point {
+ readonly x: number;
+ readonly y: number;
+}
+
+/**
+ * The half a point is on: the one whose centre is nearer, so the same rule
+ * serves halves stacked in a column and halves side by side. The increment
+ * half wins a tie; with no centres read, the point is on it.
+ */
+export function halfAt(
+ centers: {readonly increment: Point; readonly decrement: Point} | null,
+ point: Point,
+): StepperHalf {
+ if (centers == null) {
+ return 'increment';
+ }
+ const distance = (center: Point) =>
+ (center.x - point.x) ** 2 + (center.y - point.y) ** 2;
+ return distance(centers.decrement) < distance(centers.increment)
+ ? 'decrement'
+ : 'increment';
+}
+
+/** The interval before the next repeat, once `stepsMade` steps were made. */
+export function repeatInterval(stepsMade: number): number {
+ const {firstIntervalMs, acceleration, minIntervalMs} = stepper.autorepeat;
+ return Math.max(
+ minIntervalMs,
+ firstIntervalMs * acceleration ** Math.max(0, stepsMade - 1),
+ );
+}
+
+export class StepperTracking {
+ /** Whether a press is tracked. */
+ isTracking = false;
+ /** Whether the press has reached the control (the repeat clock runs). */
+ isDelivered = false;
+ /** The half under the pointer; null while it is outside the control. */
+ half: StepperHalf | null = null;
+ /** Steps made by this press (taps and repeats). */
+ steps = 0;
+
+ private readonly options: StepperTrackingOptions;
+ private readonly clock: GestureClock;
+ /** When the armed repeat fires; null while none is armed. */
+ private deadline: number | null = null;
+ /** What was left of the interval when the pointer left the control. */
+ private remaining: number | null = null;
+ private timer: unknown = null;
+
+ constructor(options: StepperTrackingOptions) {
+ this.options = options;
+ this.clock = options.clock ?? browserClock;
+ }
+
+ /** A pointer landed on `half`. */
+ begin(half: StepperHalf): void {
+ this.reset();
+ this.isTracking = true;
+ this.half = half;
+ }
+
+ /**
+ * The press reached the control at `time`: at touch-down outside a scroll
+ * view, after its content-touch delay inside one. Starts the repeat clock.
+ */
+ deliver(time: number): void {
+ if (!this.isTracking || this.isDelivered) {
+ return;
+ }
+ this.isDelivered = true;
+ const deadline = time + stepper.autorepeat.delayMs;
+ if (this.half == null) {
+ this.remaining = Math.max(0, deadline - this.clock.now());
+ } else {
+ this.arm(deadline);
+ }
+ }
+
+ /** The pointer moved; `half` is null when it is outside the control. */
+ move(half: StepperHalf | null): void {
+ if (!this.isTracking) {
+ return;
+ }
+ const wasOutside = this.half == null;
+ this.half = half;
+ if (!this.isDelivered || wasOutside === (half == null)) {
+ // A slide between the halves keeps the running cadence.
+ return;
+ }
+ if (half == null) {
+ // Leaving the control pauses the cadence.
+ this.remaining =
+ this.deadline == null
+ ? null
+ : Math.max(0, this.deadline - this.clock.now());
+ this.disarm();
+ } else {
+ // Coming back resumes it, with the rest of the paused interval.
+ this.arm(
+ this.clock.now() + (this.remaining ?? repeatInterval(this.steps)),
+ );
+ this.remaining = null;
+ }
+ }
+
+ /**
+ * The pointer lifted over `half` (null: outside the control). Steps
+ * nothing: returns the half the browser's click after a tap steps, or null
+ * after a repeat, for a lift outside, or with no press.
+ */
+ end(half: StepperHalf | null): StepperHalf | null {
+ if (!this.isTracking) {
+ return null;
+ }
+ const isTap = this.steps === 0;
+ this.reset();
+ return isTap ? half : null;
+ }
+
+ /** The press was taken away (a scroll, the system): nothing more steps. */
+ cancel(): void {
+ this.reset();
+ }
+
+ /** Drop the press and its timer (the control unmounted). */
+ dispose(): void {
+ this.reset();
+ }
+
+ private repeat(): void {
+ const half = this.half;
+ if (half != null && this.options.step(half)) {
+ this.steps += 1;
+ }
+ // Timed from after this step's work, so a late step runs once.
+ this.arm(this.clock.now() + repeatInterval(this.steps));
+ }
+
+ private arm(deadline: number): void {
+ this.disarm();
+ this.deadline = deadline;
+ this.timer = this.clock.setTimeout(
+ () => {
+ this.timer = null;
+ this.deadline = null;
+ if (this.isTracking && this.half != null) {
+ this.repeat();
+ }
+ },
+ Math.max(0, deadline - this.clock.now()),
+ );
+ }
+
+ private disarm(): void {
+ if (this.timer != null) {
+ this.clock.clearTimeout(this.timer);
+ this.timer = null;
+ }
+ this.deadline = null;
+ }
+
+ private reset(): void {
+ this.disarm();
+ this.isTracking = false;
+ this.isDelivered = false;
+ this.half = null;
+ this.steps = 0;
+ this.remaining = null;
+ }
+}
diff --git a/packages/core/src/RadioList/RadioList.doc.mjs b/packages/core/src/RadioList/RadioList.doc.mjs
index adce60803934c..c4b912e086929 100644
--- a/packages/core/src/RadioList/RadioList.doc.mjs
+++ b/packages/core/src/RadioList/RadioList.doc.mjs
@@ -33,7 +33,7 @@ export const docs = {
],
},
description:
- 'Radio group container with field integration for label, description, and status.',
+ 'Radio group container with field integration for label, description, and status. Under a coarse pointer each option’s target reaches the touch target (`--size-touch-target`, 44 px) through an invisible hit area that stops at half the gap to each neighbour, so a middle option reaches 32 px at the default gap; rows keep their size and spacing. That hit area is the row’s `::after`: decorate a row with a child rather than its `::after`.',
props: [
{
name: 'label',
diff --git a/packages/core/src/RadioList/RadioListItem.tsx b/packages/core/src/RadioList/RadioListItem.tsx
index 683f7dbee9280..cdcadcf8b3025 100644
--- a/packages/core/src/RadioList/RadioListItem.tsx
+++ b/packages/core/src/RadioList/RadioListItem.tsx
@@ -4,7 +4,7 @@
/**
* @file RadioListItem.tsx
- * @input Uses React use, useId, useRef, RadioListContext, Item
+ * @input Uses React use, useId, useRef, RadioListContext, Item, useTouchHitArea
* @output Exports RadioListItem component, RadioListItemProps
* @position Core implementation; consumed by index.ts, tested by RadioList.test.tsx
*
@@ -28,9 +28,13 @@ import {mergeProps, isRenderable, rtlStyles} from '../utils';
import {indicatorScope} from '../Indicator/indicator.markers.stylex';
import {useIndicatorFocusRing} from '../hooks/useIndicatorFocusRing';
import {usePressFeedback} from '../hooks/usePressFeedback';
+import {useMergedRefs} from '../hooks/useMergedRefs';
+import {useTouchHitArea} from '../utils/touchHitArea';
+import {touchHitAreaStyles} from '../utils/touchHitArea.stylex';
import {useIndicator} from '../Indicator';
import {Item, type ItemAlign} from '../Item';
import {themeProps} from '../utils/themeProps';
+import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
// The touch press's paint, declared by the shared overlay styles on the
// element the controller writes to (`pressedAlpha`) at the press's strength,
@@ -148,6 +152,10 @@ const rowStyles = stylex.create({
// strength still lands on the row, which is where the layer over the
// circle reads it.
backgroundImage: 'none',
+ // ...and the row's touch press layer (Item's `cell`): no box.
+ '::before': {
+ content: 'none',
+ },
},
// The mouse press (a finger's lives on `data-astryx-press`): the row is
// pressed, but not through a nested link or button, which keep their own
@@ -273,6 +281,14 @@ export function RadioListItem({
const labelID = useId();
const descriptionID = useId();
const isDisabled = context.isDisabled || isItemDisabled;
+ // Under a finger an option's target reaches the touch target through an
+ // invisible hit area that stops at half the gap to each neighbour, and the
+ // row keeps its size (spec:AST-094 FR42). A tap on the area lands on the
+ // row, which hands it to the radio.
+ const hasHitArea = !isDisabled;
+ const rowRef = useRef(null);
+ const mergedRowRef = useMergedRefs(ref, rowRef);
+ useTouchHitArea(rowRef, hasHitArea);
// When the whole group is disabled with a disabledMessage, radios stay
// focusable via aria-disabled (instead of native `disabled`) so the group's
// reason tooltip is keyboard-discoverable. Per-item disabling is unaffected
@@ -372,7 +388,7 @@ export function RadioListItem({
return (
- path.join(SRC, file)));
+ return files.map(file => path.relative(SRC, file));
+}
+
+describe('internal modules', () => {
+ it('declare no @experimental: only an experimental public API carries it', () => {
+ const offenders: string[] = [];
+ for (const file of checkedFiles()) {
+ fs.readFileSync(path.join(SRC, file), 'utf8')
+ .split('\n')
+ .forEach((line, index) => {
+ if (/@experimental\b/.test(line)) {
+ offenders.push(`${file}:${index + 1}`);
+ }
+ });
+ }
+ expect(offenders).toEqual([]);
+ });
+
+ it('checks the directories and files it names', () => {
+ expect(fs.existsSync(path.join(SRC, 'motion'))).toBe(true);
+ expect(fs.existsSync(path.join(SRC, 'gestures'))).toBe(true);
+ expect(sourcesUnder(path.join(SRC, 'motion')).length).toBeGreaterThan(5);
+ for (const file of INTERNAL_FILES) {
+ expect(fs.existsSync(path.join(SRC, file)), file).toBe(true);
+ }
+ });
+
+ it('leaves the public utils entry alone: of src/utils, only its package-private files are checked', () => {
+ const utils = checkedFiles().filter(file =>
+ file.startsWith('utils' + path.sep),
+ );
+ expect(utils).toEqual([path.join('utils', 'touchPresentation.ts')]);
+ });
+});
diff --git a/packages/core/src/__tests__/pressState.ts b/packages/core/src/__tests__/pressState.ts
index 352f450af397d..86ab56ed6ea21 100644
--- a/packages/core/src/__tests__/pressState.ts
+++ b/packages/core/src/__tests__/pressState.ts
@@ -19,7 +19,8 @@
* The touch arms are asserted the same way: the paint reads the press's
* strength (`--astryx-press-alpha`, see utils/interactionOverlay.stylex.ts)
* and the element the controller writes to runs the release animation on the
- * machine's own clock.
+ * machine's own clock; a list row's compositor layer (utils/rowPressLayer.ts)
+ * runs its own release on the row's `::before`, which these checks leave out.
*
* SYNC: When modified, update this header.
*/
@@ -179,7 +180,18 @@ export function declaresPressPaint(el: Element): boolean {
/** `.2s`, `200ms`, `0.2s` → 200. */
export function durationToMs(value: string): number {
- const match = value.trim().match(/^([\d.]+)(ms|s)$/);
+ const text = value.trim();
+ // StyleX folds a constant cap into `calc(
)`.
+ const folded = /^calc\((.+)\)$/.exec(text);
+ if (folded != null) {
+ return durationToMs(folded[1]);
+ }
+ // Under reduced motion the clock is capped: `min(, 200ms)`.
+ const capped = /^min\((.+),\s*([\d.]+m?s)\)$/.exec(text);
+ if (capped != null) {
+ return Math.min(durationToMs(capped[1]), durationToMs(capped[2]));
+ }
+ const match = text.match(/^([\d.]+)(ms|s)$/);
if (match == null) {
return Number.NaN;
}
@@ -194,7 +206,11 @@ export function durationToMs(value: string): number {
* on a descendant that merely reads the strength.
*/
export function hasReleaseFade(el: Element): boolean {
- const fading = rulesWithSelector(el, FADING_ARM);
+ // The element's own release: a layer of its own (a row's `::before`, a
+ // card's `::after`) runs its clock separately.
+ const fading = rulesWithSelector(el, FADING_ARM).filter(
+ rule => !rule.slice(0, rule.indexOf('{')).includes('::'),
+ );
const durations = fading.flatMap(rule => {
const match = rule.match(/animation-duration:\s*([^;}]+)/);
return match == null ? [] : [durationToMs(match[1])];
@@ -205,8 +221,8 @@ export function hasReleaseFade(el: Element): boolean {
);
return (
named &&
- durations.length === 1 &&
- durations[0] === PRESS_FADE_MS &&
+ durations.length > 0 &&
+ durations.every(duration => duration === PRESS_FADE_MS) &&
!onArmAnimates
);
}
diff --git a/packages/core/src/gestures/GestureArena.ts b/packages/core/src/gestures/GestureArena.ts
index 3acc3edc83c66..76c4ec8b7d746 100644
--- a/packages/core/src/gestures/GestureArena.ts
+++ b/packages/core/src/gestures/GestureArena.ts
@@ -58,6 +58,7 @@ import type {
TouchSample,
} from './types';
import {DEFAULT_ENVIRONMENT} from './types';
+import {internals} from './recognizerInternals';
export interface GestureClock {
now(): number;
@@ -119,10 +120,15 @@ interface Timer {
callback: () => void;
}
+/**
+ * The arena that decides which recognizer gets a touch.
+ */
export class GestureArena {
readonly environment: GestureEnvironment;
private readonly clock: GestureClock;
private readonly options: GestureArenaOptions;
+ /** The touches whose move the arena is delivering right now. */
+ private movingIds: ReadonlySet = new Set();
private readonly records = new Map();
/** Recognizers involved in the current interaction, in resolution order. */
private active: GestureRecognizer[] = [];
@@ -134,6 +140,10 @@ export class GestureArena {
/** The latest time processed: input never moves the clock backwards. */
private latestTime = Number.NEGATIVE_INFINITY;
private depth = 0;
+ /** Open `deferResolution` calls: what their stops unblock waits. */
+ private deferDepth = 0;
+ /** A deferred stop left a resolution for the next pass. */
+ private owesResolution = false;
constructor(options: GestureArenaOptions = {}) {
this.options = options;
@@ -153,11 +163,25 @@ export class GestureArena {
return this.records.get(id)?.isClaimed === true;
}
+ /** @internal The view-level consumer of this touch, if any. */
+ consumerOf(id: number): TouchConsumer | null {
+ return this.records.get(id)?.consumer ?? null;
+ }
+
/** The live touch with this id. */
getTouch(id: number): GestureTouch | undefined {
return this.records.get(id)?.touch;
}
+ /**
+ * The recognizers that took touch `id` when it began: each was enabled,
+ * accepted its pointer, and its `shouldReceiveTouch` agreed. Empty for a
+ * touch the arena does not track.
+ */
+ participantsOf(id: number): ReadonlyArray {
+ return this.records.get(id)?.participants ?? [];
+ }
+
/** Every touch the arena tracks, lifted ones awaiting a decision included. */
get touches(): ReadonlyArray {
return [...this.records.values()].map(record => record.touch);
@@ -201,11 +225,11 @@ export class GestureArena {
};
const participants: GestureRecognizer[] = [];
for (const [depth, recognizer] of input.recognizers.entries()) {
- if (!recognizer.wantsTouch(touch)) {
+ if (!internals(recognizer).wantsTouch(touch)) {
continue;
}
participants.push(recognizer);
- recognizer.arena = this;
+ internals(recognizer).arena = this;
if (!this.active.includes(recognizer)) {
// Resolution follows `active`, so a recognizer joins ahead of any
// that sits further out on this touch's path: when two recognize
@@ -241,7 +265,7 @@ export class GestureArena {
for (const recognizer of [...this.active]) {
const touches = newTouches.get(recognizer);
if (touches != null) {
- recognizer.deliverBegan(touches, context);
+ internals(recognizer).deliverBegan(touches, context);
}
}
// A recognizer already tracking a gesture hears of a touch that began
@@ -253,7 +277,7 @@ export class GestureArena {
.map(record => record.touch)
.filter(touch => own == null || !own.includes(touch));
if (elsewhere.length > 0 && recognizer.numberOfTouches > 0) {
- recognizer.deliverBeganElsewhere(elsewhere, context);
+ internals(recognizer).deliverBeganElsewhere(elsewhere, context);
}
}
for (const record of created) {
@@ -262,6 +286,41 @@ export class GestureArena {
});
}
+ /**
+ * A contact began where nothing takes part (plain page content). It is
+ * never tracked and stays the browser's; recognizers already tracking a
+ * gesture hear of it, as of any touch off their hit path.
+ */
+ untrackedBegan(input: TouchBeganInput): void {
+ if (!this.active.some(recognizer => recognizer.numberOfTouches > 0)) {
+ return;
+ }
+ this.run(input.time, () => {
+ const time = this.clampTime(input.time);
+ const touch: GestureTouch = {
+ id: input.id,
+ pointerType: input.pointerType,
+ phase: 'began',
+ x: input.x,
+ y: input.y,
+ time,
+ startX: input.x,
+ startY: input.y,
+ startTime: time,
+ samples: [{x: input.x, y: input.y, time}],
+ target: input.target,
+ scroll: input.scroll ?? null,
+ takenByBrowser: false,
+ };
+ const context = this.context();
+ for (const recognizer of [...this.active]) {
+ if (recognizer.numberOfTouches > 0) {
+ internals(recognizer).deliverBeganElsewhere([touch], context);
+ }
+ }
+ });
+ }
+
/** Contacts moved. */
moved(inputs: ReadonlyArray): void {
if (inputs.length === 0) {
@@ -273,8 +332,14 @@ export class GestureArena {
return;
}
const context = this.context();
- for (const recognizer of [...this.active]) {
- recognizer.deliverMoved(changed, context);
+ const previous = this.movingIds;
+ this.movingIds = new Set(changed.map(touch => touch.id));
+ try {
+ for (const recognizer of [...this.active]) {
+ internals(recognizer).deliverMoved(changed, context);
+ }
+ } finally {
+ this.movingIds = previous;
}
for (const touch of changed) {
const record = this.records.get(touch.id);
@@ -298,7 +363,7 @@ export class GestureArena {
const changed = this.update(inputs, 'ended');
const context = this.context();
for (const recognizer of [...this.active]) {
- recognizer.deliverEnded(changed, context);
+ internals(recognizer).deliverEnded(changed, context);
}
// A lift can decide a waiting recognition (its requirement lifted
// undecided and failed): settle those first, so each click's answer is
@@ -325,7 +390,7 @@ export class GestureArena {
}
const context = this.context();
for (const recognizer of [...this.active]) {
- recognizer.deliverCancelled(touches, context);
+ internals(recognizer).deliverCancelled(touches, context);
}
for (const touch of touches) {
const record = this.records.get(touch.id);
@@ -360,7 +425,7 @@ export class GestureArena {
}
const context = this.context();
for (const recognizer of [...this.active]) {
- recognizer.deliverTakenByBrowser(touches, context);
+ internals(recognizer).deliverTakenByBrowser(touches, context);
}
for (const touch of touches) {
const record = this.records.get(touch.id);
@@ -387,7 +452,10 @@ export class GestureArena {
this.fail(recognizer);
return;
}
- if (recognizer.pending != null && (next === 'began' || next === 'ended')) {
+ if (
+ internals(recognizer).pending != null &&
+ (next === 'began' || next === 'ended')
+ ) {
// Already waiting on a requirement; the recognition stands as asked.
return;
}
@@ -421,6 +489,38 @@ export class GestureArena {
}
}
+ /**
+ * Runs `body`, code outside input that stops recognizers (an Effect that
+ * turns one off or detaches it), without resolving what those stops
+ * unblock: the recognitions waiting on them, the touches they held from
+ * a view, the resets. A pass a microtask later resolves all of it, at the
+ * last event's time, so the listeners and consumers it reaches hear it
+ * outside the caller's frame and read the recognized state
+ * (react-component-runtime INV2).
+ */
+ deferResolution(body: () => void): void {
+ this.deferDepth += 1;
+ try {
+ body();
+ } finally {
+ this.deferDepth -= 1;
+ }
+ if (this.deferDepth === 0 && this.owesResolution) {
+ queueMicrotask(() => {
+ if (this.owesResolution && this.deferDepth === 0) {
+ this.run(this.lastEventTime(), () => {});
+ }
+ });
+ }
+ }
+
+ /** The last event's time, or now before any event. */
+ private lastEventTime(): number {
+ return Number.isFinite(this.latestTime)
+ ? this.latestTime
+ : this.clock.now();
+ }
+
/** Cancel an active recognizer, or fail a possible one. */
stop(recognizer: GestureRecognizer): void {
// A cancel from code between events happens at the last event's time:
@@ -444,13 +544,19 @@ export class GestureArena {
recognizer: GestureRecognizer,
next: 'began' | 'ended',
): void {
- if (!recognizer.shouldBegin() || this.isVetoedByConsumer(recognizer)) {
+ if (
+ !internals(recognizer).shouldBegin() ||
+ this.isVetoedByConsumer(recognizer)
+ ) {
this.fail(recognizer);
return;
}
let isWaiting = false;
for (const other of this.active) {
- if (other === recognizer || !recognizer.requiresFailureOf(other)) {
+ if (
+ other === recognizer ||
+ !internals(recognizer).requiresFailureOf(other)
+ ) {
continue;
}
if (other.state === 'failed') {
@@ -466,7 +572,7 @@ export class GestureArena {
}
}
if (isWaiting) {
- recognizer.pending = next;
+ internals(recognizer).pending = next;
this.claim(recognizer);
return;
}
@@ -490,14 +596,16 @@ export class GestureArena {
continue;
}
if (
- other.requiresFailureOf(recognizer) ||
+ internals(other).requiresFailureOf(recognizer) ||
this.conflicts(recognizer, other)
) {
this.fail(other);
}
}
this.claim(recognizer);
- if (recognizer.cancelsTouchesInView) {
+ // A watcher takes nothing from the view either: the press and the click
+ // under it stay (spec:AST-094 FR11, FR19; FR15 exempts watchers).
+ if (recognizer.claimsTouches && recognizer.cancelsTouchesInView) {
this.cancelViewTouchesOf(recognizer);
}
}
@@ -507,7 +615,14 @@ export class GestureArena {
winner: GestureRecognizer,
other: GestureRecognizer,
): boolean {
- if (!sharesTouches(winner, other) || winner.allowsSimultaneous(other)) {
+ if (
+ !sharesTouches(winner, other) ||
+ internals(winner).allowsSimultaneous(other) ||
+ // A watcher (claimsTouches false) prevents nothing and nothing
+ // prevents it.
+ !winner.claimsTouches ||
+ !other.claimsTouches
+ ) {
return false;
}
return winner.canPrevent(other) && other.canBePrevented(winner);
@@ -515,7 +630,7 @@ export class GestureArena {
/** UIControl's rule: a control may veto an overlapping ancestor recognizer. */
private isVetoedByConsumer(recognizer: GestureRecognizer): boolean {
- for (const id of recognizer.seenTouchIds) {
+ for (const id of internals(recognizer).seenTouchIds) {
const consumer = this.records.get(id)?.consumer;
if (consumer?.allowsAncestorRecognizer?.(recognizer) === false) {
return true;
@@ -532,7 +647,8 @@ export class GestureArena {
this.apply(recognizer, 'cancelled');
return;
}
- recognizer.pending = null;
+ internals(recognizer).pending = null;
+ // apply() gives back any claim it held (releaseClaim), waiting or not.
this.apply(recognizer, 'failed');
this.reviewPending();
}
@@ -541,12 +657,19 @@ export class GestureArena {
* A recognition that claimed its touches and then fails or is cancelled (a
* waiting recognition that fails, a gesture a second finger or the app
* cancels) gives them back to the browser unless another recognition owns
- * them, so a finger still resting there can pinch-zoom the page.
+ * them, so a finger still resting there can pinch-zoom the page. A gesture
+ * cancelled while one of its fingers is moving (in that move's dispatch)
+ * keeps that moving finger until it lifts: the drag goes on, and the page
+ * never takes it over midway (`spec:AST-094` FR14), as UIKit's scroll view never
+ * takes a touch another recognizer had.
*/
- private releaseClaim(recognizer: GestureRecognizer): void {
+ private releaseClaim(
+ recognizer: GestureRecognizer,
+ keep: ReadonlySet = new Set(),
+ ): void {
for (const touch of recognizer.trackedTouches) {
const record = this.records.get(touch.id);
- if (record == null || !record.isClaimed) {
+ if (record == null || !record.isClaimed || keep.has(touch.id)) {
continue;
}
// A recognized discrete gesture (`ended`, a swipe) still owns its
@@ -557,7 +680,7 @@ export class GestureArena {
other.claimsTouches &&
(isActive(other.state) ||
other.state === 'ended' ||
- other.pending != null) &&
+ internals(other).pending != null) &&
other.trackedTouches.some(tracked => tracked.id === touch.id),
);
if (!isOwned) {
@@ -568,7 +691,7 @@ export class GestureArena {
private apply(recognizer: GestureRecognizer, next: GestureState): void {
const from = recognizer.state;
- recognizer.applyState(next);
+ internals(recognizer).applyState(next);
if (recognizer.state === next && from !== next) {
this.options.onTransition?.(recognizer, from, next);
} else if (next === 'changed' && from === 'changed') {
@@ -577,7 +700,9 @@ export class GestureArena {
if (isTerminal(next)) {
this.clearTimer(recognizer);
}
- if (next === 'cancelled' || next === 'failed') {
+ if (next === 'cancelled') {
+ this.releaseClaim(recognizer, this.movingIds);
+ } else if (next === 'failed') {
this.releaseClaim(recognizer);
}
}
@@ -588,25 +713,29 @@ export class GestureArena {
* settle in one pass.
*/
private reviewPending(): void {
+ if (this.deferDepth > 0) {
+ this.owesResolution = true;
+ return;
+ }
let changed = true;
while (changed) {
changed = false;
for (const recognizer of [...this.active]) {
- const next = recognizer.pending;
+ const next = internals(recognizer).pending;
if (next == null || recognizer.state !== 'possible') {
continue;
}
const blocked = this.active.some(
other =>
other !== recognizer &&
- recognizer.requiresFailureOf(other) &&
+ internals(recognizer).requiresFailureOf(other) &&
other.state === 'possible' &&
other.isInvolved(),
);
if (blocked) {
continue;
}
- recognizer.pending = null;
+ internals(recognizer).pending = null;
this.attemptRecognition(recognizer, next);
changed = true;
}
@@ -735,7 +864,7 @@ export class GestureArena {
recognizer.delaysTouchesBegan &&
recognizer.state === 'possible' &&
recognizer.isInvolved() &&
- recognizer.seenTouchIds.has(record.touch.id),
+ internals(recognizer).seenTouchIds.has(record.touch.id),
);
}
@@ -745,12 +874,12 @@ export class GestureArena {
recognizer.delaysTouchesEnded &&
recognizer.state === 'possible' &&
recognizer.isInvolved() &&
- recognizer.seenTouchIds.has(record.touch.id),
+ internals(recognizer).seenTouchIds.has(record.touch.id),
);
}
private cancelViewTouchesOf(recognizer: GestureRecognizer): void {
- for (const id of recognizer.seenTouchIds) {
+ for (const id of internals(recognizer).seenTouchIds) {
const record = this.records.get(id);
if (record == null) {
continue;
@@ -831,13 +960,26 @@ export class GestureArena {
private run(time: number, body: () => void): void {
this.depth += 1;
try {
+ if (this.depth === 1 && this.deferDepth === 0 && this.owesResolution) {
+ // A deferred stop resolves before anything newer: input that comes
+ // ahead of its microtask (a nested dispatch) sees settled state.
+ this.owesResolution = false;
+ this.currentTime = this.clampTime(this.lastEventTime());
+ this.settle();
+ }
this.flushTimers(time);
this.currentTime = this.clampTime(time);
body();
// Re-entrant calls settle with the outermost one: settling mid-delivery
- // would reset recognizers that have not heard the event yet.
+ // would reset recognizers that have not heard the event yet. Inside
+ // `deferResolution`, the next pass settles.
if (this.depth === 1) {
- this.settle();
+ if (this.deferDepth > 0) {
+ this.owesResolution = true;
+ } else {
+ this.owesResolution = false;
+ this.settle();
+ }
}
} finally {
this.depth -= 1;
@@ -897,7 +1039,7 @@ export class GestureArena {
}
this.active = this.active.filter(other => other !== recognizer);
const from = recognizer.state;
- recognizer.resetToPossible();
+ internals(recognizer).resetToPossible();
didReset = true;
if (from !== 'possible') {
this.options.onTransition?.(recognizer, from, 'possible');
@@ -957,8 +1099,8 @@ export class GestureArena {
}
function sharesTouches(a: GestureRecognizer, b: GestureRecognizer): boolean {
- for (const id of a.seenTouchIds) {
- if (b.seenTouchIds.has(id)) {
+ for (const id of internals(a).seenTouchIds) {
+ if (internals(b).seenTouchIds.has(id)) {
return true;
}
}
diff --git a/packages/core/src/gestures/GestureHost.ts b/packages/core/src/gestures/GestureHost.ts
new file mode 100644
index 0000000000000..440e617d90bd9
--- /dev/null
+++ b/packages/core/src/gestures/GestureHost.ts
@@ -0,0 +1,2903 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file GestureHost.ts
+ * @input DOM touch, pointer, click, contextmenu, scroll and focus events of
+ * one document; elements with recognizers and consumers attached
+ * @output Exports GestureHost (the DOM side of the gesture system) and
+ * getGestureHost (one host per document)
+ * @position Browser adapter of packages/core/src/gestures: turns real events
+ * into GestureArena input, and carries out what the arena decides
+ *
+ * FOR EVERY POINTER
+ *
+ * - Recognizers and press consumers attach for a mouse, a pen and a finger
+ * alike (`spec:AST-094` FR43, FR19). Where the primary pointer is coarse
+ * the host listens while something enabled could take a touch. Where it
+ * 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, whose `pointerdown` comes
+ * before its `touchstart`) installs the host's page listeners, which go
+ * once that press's contacts end (FR24). Under every pointer, an element
+ * whose recognizer claims a moving finger also keeps its blocking
+ * `touchmove`, a listener FR24 counts apart from the press's one.
+ * - A press consumer takes a mouse press as a press (DEC-21): PressTracker's
+ * fine-pointer profile paints at once and brakes no scroll (FR11).
+ * - The host follows the query (a tablet docking or undocking a mouse):
+ * turning fine cancels every touch in flight.
+ * - `perEventPointer` (AttachOptions), from before FR43, changes nothing.
+ *
+ * INPUT CHANNELS
+ *
+ * - Fingers begin and end with Touch Events: they are the only cancelable
+ * channel (a pointer event's preventDefault cannot stop a scroll or a
+ * click), they keep arriving after the browser takes a pan (which a
+ * watcher following the scroll to the lift needs, `spec:AST-094` FR20,
+ * DEC-7), and every event lists all the fingers at once. A
+ * finger's movement is sampled from ONE channel: its `pointermove`s, once
+ * its pointerdown has paired with its touchstart (by target and
+ * position), since Chromium sends no `touchmove` until a finger has
+ * travelled about 16 px while a tap fails and a pan begins at 10; touch
+ * events sample only a finger no pointer event reported. The other
+ * channel keeps its control duties but feeds no sample.
+ * - A finger's events go to the element it landed on even after that
+ * element leaves the document, and then never reach the document. Each
+ * tracked finger's target therefore gets its own listeners, and a finger
+ * the events say is no longer down is cancelled, so a re-rendered or
+ * removed target never strands a gesture.
+ * - Mice and pens come from Pointer Events, with coalesced samples for
+ * velocity. A pen on an iPad also produces `stylus` touches; those are left
+ * to the pointer path, except that a claimed pen's stylus touches are
+ * cancelled as a finger's, its touchend included (FR21).
+ * - `pointercancel` on a finger is the browser saying it took that finger
+ * for its own pan or zoom; so is a `touchmove` that can no longer be
+ * cancelled where `touch-action` let the browser pan, and a scroll above
+ * the touch.
+ *
+ * FINGERS
+ *
+ * - The host keeps one record of where each finger on the screen landed
+ * (`fingersDown()`). Finger scrolls (fingerScroll.ts) read it, and a
+ * scroll lock's page hold (pageHold.ts) asks which touches a recognizer
+ * owns; both live in their own modules, so a page that needs neither
+ * never ships them.
+ *
+ * TOUCHES ON MOVING ELEMENTS
+ *
+ * - iOS WebKit hit-tests an element a compositor animation moves where the
+ * animation started. There, at touch-down, the host resolves the moving
+ * elements (motion/presentation.ts: one style write each) and asks the
+ * engine again, so a contact goes to what is drawn under it; press
+ * trackers read `presentedRect`, and a retargeted tap clicks what it
+ * touched once, with the engine's own click cancelled or swallowed.
+ *
+ * WHAT THE BROWSER IS TOLD
+ *
+ * - Each element gets the `touch-action` its recognizers leave the browser
+ * (touchAction.ts), never wider than the element's own value (a caller's
+ * explicit `touch-action` stays in force), set inline when they attach and
+ * restored when they detach.
+ * - While a finger or a pen is down, text selection and the touch callout
+ * are off only where the hold does something (`spec:AST-094` FR41): on the
+ * elements of recognizers that bring a `selectionLock`, and on a non-text
+ * input under the finger inside them (WebKit's own style makes every
+ * input selectable); restored when the contact ends. A finger or a pen
+ * takes it under any primary pointer; a mouse never does (`spec:AST-017`
+ * FR59).
+ * - Each element with a recognizer that can claim a touch while it moves (a
+ * pan, a swipe, a long press that then drags, a pinch, a rotation) gets a
+ * NON-passive `touchmove` listener that cancels the event only while a
+ * recognizer owns one of its touches (a pan claims when it begins, at
+ * UIKit's 10 pt, `spec:AST-094` FR17). Nothing else is ever cancelled, so a
+ * touch nobody claims scrolls exactly as it would without the system. The
+ * known cost: a scroll that BEGINS on such an element waits for a busy main
+ * thread (measured in iOS Safari; a passive listener would hand every
+ * diagonal to the scroll instead). Elements whose recognizers only claim
+ * at the lift (taps), recognizers that never claim (`claimsTouches` false)
+ * and press consumers get no blocking listener, and the listener a
+ * finger's target gets at touch-down is passive for them too, so a scroll
+ * starting on them never waits.
+ * - The browser took a finger on `pointercancel`, on its first uncancelable
+ * `touchmove` where it could pan, or on a scroll above it once the finger
+ * has travelled the slop; a scroll under a finger that has not moved is
+ * the page's own and leaves the touch alone.
+ * - A lift whose click must not happen cancels its `touchend` (no click, no
+ * compatibility mouse events) and arms the shared click guard
+ * (utils/clickGuard.ts, the menu press model's too) for the browser's next
+ * click; a click held for a recognizer that may still recognize is
+ * replayed if they all fail. Keyboard and assistive-technology clicks
+ * (`detail === 0`) always pass.
+ * - A `contextmenu` while a recognizer owns a touch is cancelled, so a long
+ * press never also opens the browser's menu.
+ * - When the app loses focus (a window blur, a hidden page), every touch in
+ * flight is cancelled, as in UIKit; a finger the browser keeps alive lifts
+ * on nothing: its `touchend` is cancelled (`spec:AST-094` FR28).
+ *
+ * SSR-safe: nothing touches `window` or `document` until something attaches.
+ *
+ * SYNC: When modified, update GestureHost.test.ts and README.md.
+ */
+
+import {isRtlElement} from '../hooks/isRtlElement';
+import {
+ presentedElements,
+ presentedOffsetOf,
+ resolvePresented,
+ watchPresentedElements,
+} from '../motion/presentation';
+import {UIKIT_PRESS, UIKIT_TAP} from '../motion/uikit/gestures';
+import {
+ browserClock,
+ GestureArena,
+ type ArenaBeganInput,
+ type ClickDisposition,
+ type GestureArenaOptions,
+ type GestureClock,
+} from './GestureArena';
+import type {GestureRecognizer, SelectionLock} from './GestureRecognizer';
+import type {PressBounds} from './PressTracker';
+import type {HoverGestureRecognizer} from './HoverGestureRecognizer';
+import type {PanGestureRecognizer} from './PanGestureRecognizer';
+import {createNativeScrollProbe, parentAcrossShadow} from './nativeScroll';
+import {
+ gestureHostStyle,
+ intersectTouchAction,
+ type GestureHostStyle,
+} from './touchAction';
+import type {
+ GestureEnvironment,
+ GestureTouch,
+ PointerKind,
+ TouchBeganInput,
+ TouchConsumer,
+ TouchInput,
+ TouchSample,
+} from './types';
+import {internals} from './recognizerInternals';
+import {
+ armClickGuard,
+ disarmClickGuard,
+ withoutClickGuard,
+} from '../utils/clickGuard';
+import {subscribeInput} from '../utils/inputStream';
+import {isPrimaryClick} from '../utils/primaryClick';
+import {
+ type DragSelectionReleaseOptions,
+ lockDragSelection,
+ locksSelectionFor,
+} from '../utils/selectionLock';
+import {isCoarsePointer, watchCoarsePointer} from '../utils/coarsePointer';
+import {gestureHosts} from './hostRegistry';
+import {
+ markTouchAction,
+ TOUCH_ACTION_ATTRIBUTE,
+ unmarkTouchAction,
+} from './touchActionRule';
+
+interface Registration {
+ readonly recognizers: GestureRecognizer[];
+ readonly hovers: HoverGestureRecognizer[];
+ consumer: TouchConsumer | null;
+ /** Whether the element carries the host's shared blocking touchmove handler. */
+ hasTouchMoveListener: boolean;
+ /** Inline values the host replaced, to restore on detach. */
+ readonly savedStyle: Map;
+ /**
+ * What the element's own stylesheet gives it, read in a frame for the
+ * derived value the host marked then: `null` where the host's rule is in
+ * force (the author sets nothing else); the host never allows more than
+ * the author does. `null` before the read.
+ */
+ authorTouchAction: {derived: string; author: string | null} | null;
+ manageStyle: boolean;
+ /** An action group (`family:action-groups` FR5). */
+ actionGroup: boolean;
+ /** The attachments that asked for the action group; it lasts while one does. */
+ actionGroupAsks: number;
+}
+
+export interface GestureHostOptions {
+ /** The arena's clock; tests pass a manual one. */
+ clock?: GestureClock;
+ /**
+ * Where fingers come from: Touch Events where the browser has them
+ * (`auto`), or Pointer Events only.
+ */
+ inputMode?: 'auto' | 'pointer-events';
+ /** Every recognizer transition, for debugging and tests. */
+ onTransition?: GestureArenaOptions['onTransition'];
+ /**
+ * Send a contact to what is drawn while a compositor animation moves an
+ * element (motion/presentation.ts): `auto` where the engine hit-tests such
+ * an element where its animation started (iOS WebKit); `always` or
+ * `never` for tests. @default 'auto'
+ */
+ presentedHitTesting?: 'auto' | 'always' | 'never';
+ /**
+ * Whether the primary pointer is coarse (a phone, a tablet). Gestures
+ * attach for every pointer (`spec:AST-094` FR43); where it is fine, an
+ * element that uses a gesture keeps one passive `pointerdown` listener
+ * (beside a claiming recognizer's blocking `touchmove`, FR19) and the
+ * page's listeners last only a press (FR24).
+ * @default `(pointer: coarse)` on the document's window
+ */
+ isPrimaryPointerCoarse?: () => boolean;
+}
+
+export interface AttachOptions {
+ /** Let the host set touch-action (and long-press callout) styles. @default true */
+ manageStyle?: boolean;
+ /**
+ * Accepted and ignored: every attachment reads each event's pointer
+ * (`spec:AST-094` FR43). Kept so callers written before FR43 compile.
+ * @deprecated
+ */
+ perEventPointer?: boolean;
+ /**
+ * The element is an action group, sibling actions a held pointer slides
+ * across (`family:action-groups` FR5). It is served like every
+ * attachment, for every pointer (`spec:AST-094` FR43). @default false
+ */
+ actionGroup?: boolean;
+}
+
+const STYLE_PROPERTIES: Record = {
+ touchAction: 'touch-action',
+ WebkitTouchCallout: '-webkit-touch-callout',
+ WebkitUserSelect: '-webkit-user-select',
+ userSelect: 'user-select',
+};
+
+/**
+ * Run `task` in the next frame, before it paints: a read there styles what
+ * the frame must style anyway, and its writes are what the frame shows.
+ */
+function inNextFrame(view: Window | null, task: () => void): void {
+ if (view != null && typeof view.requestAnimationFrame === 'function') {
+ view.requestAnimationFrame(task);
+ } else {
+ setTimeout(task, 0);
+ }
+}
+
+/** An armed press tracker's own element listeners: they never cancel. */
+const PASSIVE_CAPTURE = {capture: true, passive: true} as const;
+const PASSIVE = {passive: true} as const;
+
+/**
+ * Whether a recognizer can take a touch while it moves, and so needs the
+ * blocking `touchmove` listener that stops the browser's pan. A tap claims
+ * only at the lift (it fails once the finger travels), so it needs none.
+ */
+function claimsWhileMoving(recognizer: GestureRecognizer): boolean {
+ return (
+ recognizer.claimsTouches &&
+ internals(recognizer).acceptsTouchInput &&
+ internals(recognizer).family !== 'tap'
+ );
+}
+
+/**
+ * A hover recognizer, told apart by its family rather than its class, so a
+ * page without one never ships HoverGestureRecognizer.
+ */
+function isHoverRecognizer(
+ recognizer: GestureRecognizer,
+): recognizer is HoverGestureRecognizer {
+ return internals(recognizer).family === 'hover';
+}
+
+/**
+ * A pan (or a pan subclass), told apart by its family rather than its class,
+ * so a page that constructs no pan never ships PanGestureRecognizer.
+ */
+function isPanRecognizer(
+ recognizer: GestureRecognizer,
+): recognizer is PanGestureRecognizer {
+ return internals(recognizer).family === 'pan';
+}
+
+type StylusTouch = Touch & {touchType?: string};
+
+function hasStylusTouch(event: TouchEvent): boolean {
+ return touchesOf(event.changedTouches).some(
+ touch => (touch as StylusTouch).touchType === 'stylus',
+ );
+}
+
+interface PlacedContact {
+ readonly x: number;
+ readonly y: number;
+ readonly target: unknown;
+}
+
+/** A touchstart and a pointerdown describe one finger: same target, same spot. */
+function isSamePlace(a: PlacedContact, b: PlacedContact): boolean {
+ return (
+ a.target === b.target &&
+ Math.abs(a.x - b.x) <= 1 &&
+ Math.abs(a.y - b.y) <= 1
+ );
+}
+
+function isPointerKind(type: string): type is PointerKind {
+ return type === 'touch' || type === 'mouse' || type === 'pen';
+}
+
+/**
+ * What an event, or one of its touches, landed on. Inside an open shadow root
+ * the document's listeners see the shadow host, in `event.target` and in a
+ * touch's `target` alike, while the composed path starts at the element
+ * itself. A touch whose target is not the event's (another finger) keeps its
+ * own.
+ */
+function landedOn(
+ event: Event,
+ retargeted: EventTarget | null = event.target,
+): EventTarget | null {
+ if (retargeted !== event.target) {
+ return retargeted;
+ }
+ return event.composedPath()[0] ?? retargeted;
+}
+
+/**
+ * Whether `node` is `ancestor` or below it along the flat tree: across an open
+ * shadow root to its host, which `Node.contains` does not follow.
+ */
+function containsAlongFlatTree(ancestor: Node, node: Node): boolean {
+ for (let at: Node | null = node; at != null; at = parentAcrossShadow(at)) {
+ if (at === ancestor) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * An event's time on the arena's clock (`performance.now()`). Some engines
+ * and DOM emulators stamp events in epoch milliseconds instead.
+ */
+function eventTime(event: Event): number {
+ const time = event.timeStamp;
+ if (
+ time > 1e12 &&
+ typeof performance !== 'undefined' &&
+ performance.timeOrigin > 0
+ ) {
+ return time - performance.timeOrigin;
+ }
+ return time;
+}
+
+/** A touch list as an array; a synthetic event may carry none. */
+function touchesOf(list: TouchList | null | undefined): Touch[] {
+ return list == null ? [] : Array.from(list);
+}
+
+export class GestureHost {
+ readonly arena: GestureArena;
+ private readonly doc: Document;
+ private readonly registry = new Map();
+ /** Stops following the animator's moving elements (iOS WebKit only). */
+ private stopWatchingMovers: (() => void) | null = null;
+ /** Whether the document carries the blocking touchmove for a mover. */
+ private isBlockingForMovers = false;
+ /** Elements whose caller touch-action waits for a read after paint. */
+ private readonly pendingCallerReads = new Set();
+ private isCallerReadScheduled = false;
+ /** The selection lock each contact holds, by host contact id. */
+ private readonly lockedBy = new Map<
+ number,
+ (options?: DragSelectionReleaseOptions) => void
+ >();
+ private readonly presentedHitTesting: 'auto' | 'always' | 'never';
+ /**
+ * Contacts sent to what is drawn instead of what the engine hit, by host
+ * contact id, with where they landed (for their click).
+ */
+ private readonly retargeted = new Map<
+ number,
+ {x: number; y: number; target: EventTarget}
+ >();
+ /** Touch.identifier → arena id, for live fingers. */
+ private readonly touchIds = new Map();
+ /**
+ * Fingers cancelled because the app lost focus, kept until the browser
+ * ends them: a browser that keeps such a touch alive must not let its
+ * lift click (`spec:AST-094` FR28: nothing acts).
+ */
+ private readonly cancelledFingers = new Set();
+ /** Touch.identifier → the element the finger landed on. */
+ private readonly touchTargets = new Map();
+ /** Finger targets with listeners of their own, and how many fingers each. */
+ /**
+ * Finger targets with listeners of their own: how many fingers each, and
+ * how many of those a recognizer could claim while they move (only those
+ * need the blocking `touchmove` listener).
+ */
+ private readonly watched = new Map<
+ EventTarget,
+ {count: number; blocking: number; claiming: number}
+ >();
+ /** Fingers whose target listener is the blocking one. */
+ private readonly blockingFingers = new Set();
+ /**
+ * Fingers a participant could claim (a recognizer that claims touches, or
+ * a press consumer): only their target's `touchend` may need cancelling,
+ * so a touch only watchers follow gets a passive one (`spec:AST-094`
+ * FR20).
+ */
+ private readonly claimingFingers = new Set();
+ /** Touch events already handled (the document's and the target's listeners both see one). */
+ private readonly processed = new WeakSet();
+ /** pointerId → arena id, for live mouse and pen contacts. */
+ private readonly pointerIds = new Map();
+ /**
+ * A pen whose lift a recognizer claimed, until its stylus touchend: an
+ * iPad sends the pen's pointer events first (FR21, FR43).
+ */
+ private isPenLiftClaimed = false;
+ /** Fingers whose first uncancelable `touchmove` was already judged. */
+ private readonly judgedUncancelable = new Set();
+ /**
+ * A finger's pointerId → its arena id, once its pointerdown and its
+ * touchstart are paired. Chromium sends no `touchmove` until a finger has
+ * travelled its slop (about 16 px), but sends every `pointermove`, so a
+ * paired finger is sampled from its pointer events (`pointerFed`); and its
+ * `pointercancel` is the browser's claim of exactly that finger.
+ */
+ private readonly fingerPointers = new Map();
+ /** Finger pointerdowns still waiting for their touchstart. */
+ private readonly pendingPointers = new Map();
+ /** Fingers still waiting for their pointerdown, by arena id. */
+ private readonly unpairedTouches = new Map();
+ /** The last sample fed per contact: a contact's time never runs back. */
+ private readonly isPrimaryPointerCoarse: () => boolean;
+ /** Whether the host follows `(pointer: coarse)` (no read was passed). */
+ private readonly followsPointer: boolean;
+ private readonly lastFed = new Map();
+ /**
+ * Fingers whose samples come from their pointer events. A finger the
+ * browser reports on both channels is sampled from ONE: the pointer
+ * channel, where its pointerdown paired with its touchstart; touch events
+ * sample only a finger no pointer event reported (no Pointer Events). The
+ * other channel keeps its control duties (cancelling, claiming, the
+ * browser's takeover) but never feeds a sample, so the two copies of one
+ * move cannot both reach a velocity, however far apart they arrive.
+ */
+ private readonly pointerFed = new Set();
+ private readonly hovered = new Map>();
+ /** When a finger was last down or lifted, and where it landed. */
+ private lastTouch: {readonly target: Node; readonly time: number} | null =
+ null;
+ /** The last scroll a finger drove, and when. */
+ /** Scrollers a recognizer is scrolling itself this frame (ownScroll). */
+ private readonly ownedScrolls = new Map();
+ /** Numbers each ownScroll call: only the newest one's timer clears it. */
+ private ownScrollCalls = 0;
+ private lastScroll: {readonly scroller: Node; readonly time: number} | null =
+ null;
+ /** Predicates that hold a touch's target away from every participant. */
+ private readonly touchGates = new Set<(target: Node) => boolean>();
+ /** Who keeps the listeners installed without a registration (retain). */
+ private retainers = 0;
+ /** Told after every touch event that changed the fingers on the screen. */
+ private readonly fingerListeners = new Set<() => void>();
+ /**
+ * Where each finger now on the screen landed, from the last touch event's
+ * `touches` (every finger, whether or not a recognizer tracks it).
+ */
+ private fingerTargets: ReadonlyArray = [];
+ private nextId = 1;
+ private isInstalled = false;
+ /**
+ * Whether the primary pointer is coarse (`spec:AST-094` FR19), read when
+ * something first attaches and kept current while anything is attached.
+ */
+ private isCoarse = false;
+ private stopWatchingPointer: (() => void) | null = null;
+ /** The input stream subscriptions while installed. */
+ private unsubscribers: (() => void)[] = [];
+ /**
+ * Where the primary pointer is fine, each element that uses a gesture
+ * listens for a press itself; the page's listeners last that press (FR24).
+ */
+ private readonly armedElements = new Map void>();
+ /**
+ * Where the primary pointer is fine, the elements whose enabled hover
+ * recognizer listens on the element itself (syncHoverListeners).
+ */
+ private readonly hoverElements = new Map void>();
+ private isArmedPress = false;
+ /**
+ * Whether the installed listeners include the cancelable ones (the
+ * document's touchend, contextmenu and the window's click): only while a
+ * participant that can claim is attached (`spec:AST-094` FR20).
+ */
+ private isClaimingInstalled = false;
+ /** The input stream subscriptions syncClaimingListeners keeps. */
+ private claimingUnsubscribers: (() => void)[] = [];
+ private readonly inputMode: 'auto' | 'pointer-events';
+ private usesTouchEvents = true;
+
+ constructor(doc: Document, options: GestureHostOptions = {}) {
+ this.doc = doc;
+ this.inputMode = options.inputMode ?? 'auto';
+ this.presentedHitTesting = options.presentedHitTesting ?? 'auto';
+ const environment: GestureEnvironment = {
+ viewportSize: () => ({
+ width: doc.defaultView?.innerWidth ?? 0,
+ height: doc.defaultView?.innerHeight ?? 0,
+ }),
+ // The writing direction through core's one helper.
+ isRtl: view =>
+ view instanceof Element && isRtlElement(view as HTMLElement),
+ // A screen-edge pan tells its edge zone from a wider view by its size
+ // (spec:AST-094 FR18).
+ viewSize: view => {
+ if (!(view instanceof Element)) {
+ return null;
+ }
+ const {width, height} = view.getBoundingClientRect();
+ return {width, height};
+ },
+ };
+ this.arena = new GestureArena({
+ clock: options.clock ?? browserClock,
+ environment,
+ onDeferredClick: (touch, allowed) => this.replayClick(touch, allowed),
+ onTransition: options.onTransition,
+ onClaim: ids => this.lockClaimedDrags(ids),
+ });
+ const view = doc.defaultView;
+ // A test may pass its own read; otherwise the one coarse-pointer gate,
+ // followed as it changes.
+ this.followsPointer = options.isPrimaryPointerCoarse == null;
+ this.isPrimaryPointerCoarse =
+ options.isPrimaryPointerCoarse ?? (() => isCoarsePointer(view));
+ }
+
+ // --- Attachment ------------------------------------------------------------
+
+ /** Attach a recognizer to an element. Returns the detach function. */
+ attach(
+ element: Element,
+ recognizer: GestureRecognizer,
+ options: AttachOptions = {},
+ ): () => void {
+ const registration = this.registrationFor(element);
+ registration.manageStyle = options.manageStyle ?? registration.manageStyle;
+ // An action group while any attachment asks, whatever their order.
+ const asksForGroup = options.actionGroup === true;
+ if (asksForGroup) {
+ registration.actionGroupAsks += 1;
+ registration.actionGroup = true;
+ }
+ recognizer.view = element;
+ if (isHoverRecognizer(recognizer)) {
+ registration.hovers.push(recognizer);
+ } else {
+ registration.recognizers.push(recognizer);
+ }
+ recognizer.onEnabledChange = () => {
+ this.refresh(element, registration);
+ this.syncListeners();
+ };
+ this.refresh(element, registration);
+ this.syncListeners();
+ return () => {
+ recognizer.onEnabledChange = null;
+ if (asksForGroup) {
+ registration.actionGroupAsks -= 1;
+ registration.actionGroup = registration.actionGroupAsks > 0;
+ }
+ const index = registration.recognizers.indexOf(recognizer);
+ if (index >= 0) {
+ registration.recognizers.splice(index, 1);
+ }
+ if (isHoverRecognizer(recognizer)) {
+ const hoverIndex = registration.hovers.indexOf(recognizer);
+ if (hoverIndex >= 0) {
+ registration.hovers.splice(hoverIndex, 1);
+ }
+ recognizer.hoverExited();
+ }
+ // A recognizer whose view goes away mid-gesture is cancelled, as in
+ // UIKit when a view leaves the window.
+ if (recognizer.state !== 'possible' || recognizer.isInvolved()) {
+ recognizer.cancel();
+ }
+ this.refresh(element, registration);
+ this.cleanup(element, registration);
+ // A registration that stays may have lost its last enabled member.
+ this.syncListeners();
+ };
+ }
+
+ /** Attach the view-level touch consumer of an element (a pressable control). */
+ attachConsumer(
+ element: Element,
+ consumer: TouchConsumer,
+ _options: Pick = {},
+ ): () => void {
+ const registration = this.registrationFor(element);
+ registration.consumer = consumer;
+ this.refresh(element, registration);
+ this.syncListeners();
+ return () => {
+ if (registration.consumer === consumer) {
+ registration.consumer = null;
+ }
+ // A keepsTouch consumer's blocking touchmove goes with it, even while
+ // the element keeps its recognizers.
+ this.refresh(element, registration);
+ this.cleanup(element, registration);
+ // A registration that stays may have lost its last enabled member.
+ this.syncListeners();
+ };
+ }
+
+ /**
+ * @internal The document this host serves.
+ */
+ get document(): Document {
+ return this.doc;
+ }
+
+ /**
+ * @internal Whether a recognizer owns the touch with this Touch Events
+ * `identifier`, so the browser must not pan it (pageHold.ts asks).
+ */
+ isTouchClaimed(identifier: number): boolean {
+ const id = this.touchIds.get(identifier);
+ return id != null && this.arena.isClaimed(id);
+ }
+
+ /**
+ * An element's box where it is drawn: the box the engine reports, moved
+ * by the compositor animations of it and its ancestors on engines that
+ * report and hit-test those where they started (iOS WebKit). Elsewhere
+ * `getBoundingClientRect` already reports the drawn box. Press trackers
+ * read their bounds through it.
+ */
+ presentedRect(element: Element): PressBounds {
+ const box = element.getBoundingClientRect();
+ if (!this.hitTestsLayout()) {
+ return box;
+ }
+ const {x, y} = presentedOffsetOf(element);
+ if (x === 0 && y === 0) {
+ return box;
+ }
+ return {
+ left: box.left + x,
+ top: box.top + y,
+ right: box.right + x,
+ bottom: box.bottom + y,
+ };
+ }
+
+ /** Whether this engine hit-tests a compositor-moved element at layout. */
+ private hitTestsLayout(): boolean {
+ return (
+ this.presentedHitTesting === 'always' ||
+ (this.presentedHitTesting === 'auto' && isIosWebKit(this.doc.defaultView))
+ );
+ }
+
+ /**
+ * Where a contact lands among what is drawn. iOS WebKit hit-tests an
+ * element that a compositor animation moves where the animation started:
+ * a finger on a settling sheet lands on whatever sat under the sheet's
+ * start (the scrim, which closes it), and a finger where the sheet was
+ * lands on the sheet. UIKit keeps an interruptible animation hit-testable
+ * where it is drawn (UIViewPropertyAnimator).
+ */
+ private presentedTarget(
+ native: EventTarget | null,
+ x: number,
+ y: number,
+ ): EventTarget | null {
+ if (!this.hitTestsLayout()) {
+ return native;
+ }
+ const movers = presentedElements().filter(element => {
+ if (!element.isConnected || element.ownerDocument !== this.doc) {
+ return false;
+ }
+ const {x: dx, y: dy} = presentedOffsetOf(element);
+ return Math.abs(dx) >= 0.5 || Math.abs(dy) >= 0.5;
+ });
+ if (movers.length === 0) {
+ return native;
+ }
+ // One style write per mover makes the engine hit-test it where it is
+ // drawn now; the hit test below flushes them. The engine then gets
+ // z-order, pointer-events and clipping right, which no box math would.
+ resolvePresented(movers);
+ let hit = this.doc.elementFromPoint(x, y);
+ // The document answers a shadow host for anything inside its open root:
+ // descend to what is drawn there.
+ while (hit?.shadowRoot != null) {
+ const inner = hit.shadowRoot.elementFromPoint(x, y);
+ if (inner == null || inner === hit) {
+ break;
+ }
+ hit = inner;
+ }
+ if (hit == null || hit === native) {
+ return native;
+ }
+ const found = hit;
+ const node = native instanceof Node ? native : null;
+ const isAboutMover = movers.some(
+ mover =>
+ containsAlongFlatTree(mover, found) ||
+ (node != null && containsAlongFlatTree(mover, node)),
+ );
+ return isAboutMover ? found : native;
+ }
+
+ /** The browser took these touches (a scroll): no click of ours for them. */
+ private browserTook(ids: ReadonlyArray, time: number): void {
+ for (const id of ids) {
+ this.retargeted.delete(id);
+ }
+ this.arena.browserTookOver(ids, time);
+ }
+
+ /**
+ * The click a retargeted tap earned, on what was drawn under the finger.
+ * The engine's own click would hit the start position, so the caller
+ * cancels it (or the click guard drops it, a late one included) and this one
+ * is the tap's single activation.
+ */
+ private clickRetargeted(target: EventTarget, x: number, y: number): void {
+ const view = this.doc.defaultView;
+ if (!(target instanceof Element) || !target.isConnected || view == null) {
+ return;
+ }
+ armClickGuard(this.doc);
+ if (
+ target instanceof view.HTMLElement &&
+ target.matches('input, textarea, select, [contenteditable]')
+ ) {
+ target.focus();
+ }
+ withoutClickGuard(() =>
+ target.dispatchEvent(
+ new view.MouseEvent('click', {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ detail: 1,
+ clientX: x,
+ clientY: y,
+ }),
+ ),
+ );
+ }
+
+ /**
+ * Re-derive an element's styles and the host's listeners after its
+ * recognizers' options or its consumer's enabled state changed.
+ */
+ update(element: Element): void {
+ if (!this.followsPointer) {
+ // A read passed in (a test's) is not followed: an update reads it again.
+ this.isCoarse = this.isPrimaryPointerCoarse();
+ }
+ const registration = this.registry.get(element);
+ if (registration != null) {
+ this.refresh(element, registration);
+ }
+ this.syncListeners();
+ }
+
+ /**
+ * The Pointer Events `pointerId` of a contact the arena tracks: a mouse's
+ * or a pen's own, and for a finger driven by touch events the pointer its
+ * touch was paired with (null before the pairing, or without Pointer
+ * Events). It hands a held finger to code that follows pointers, as a
+ * menu does when a long press turns into a press-drag selection.
+ */
+ pointerIdOf(touch: GestureTouch | number): number | null {
+ const id = typeof touch === 'number' ? touch : touch.id;
+ for (const [pointerId, arenaId] of this.fingerPointers) {
+ if (arenaId === id) {
+ return pointerId;
+ }
+ }
+ for (const [pointerId, arenaId] of this.pointerIds) {
+ if (arenaId === id) {
+ return pointerId;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Whether a finger set a scroller containing `node` moving within the last
+ * `window` ms. A touch there now brakes the scroll rather than pressing
+ * whatever is under it: a native list highlights nothing for one.
+ */
+ isScrollBraking(
+ node: Node,
+ window: number = UIKIT_PRESS.scrollBrake.value,
+ ): boolean {
+ const last = this.lastScroll;
+ return (
+ last != null &&
+ this.arena.now() - last.time < window &&
+ containsAlongFlatTree(last.scroller, node)
+ );
+ }
+
+ /**
+ * @internal Keep the host's listeners installed until the returned
+ * function runs, with no recognizer attached (fingerScroll.ts reads the
+ * finger record meanwhile).
+ */
+ retain(): () => void {
+ this.retainers += 1;
+ // A watch is not an attach: no warning where the pointer is fine.
+ this.watchPointer();
+ this.syncListeners();
+ let isRetained = true;
+ return () => {
+ if (!isRetained) {
+ return;
+ }
+ isRetained = false;
+ this.retainers -= 1;
+ this.uninstallIfIdle();
+ };
+ }
+
+ /**
+ * @internal Call `listener` after each touch event the host hears, once
+ * the finger record is up to date. Returns the unsubscribe.
+ */
+ onFingersChange(listener: () => void): () => void {
+ this.fingerListeners.add(listener);
+ return () => {
+ this.fingerListeners.delete(listener);
+ };
+ }
+
+ /** @internal Whether a finger that landed inside `node` is down now. */
+ isFingerDownIn(node: Node): boolean {
+ // Along the flat tree: a finger on content inside an open shadow root is
+ // in the scroller around that root.
+ return this.fingerTargets.some(target =>
+ containsAlongFlatTree(node, target),
+ );
+ }
+
+ private registrationFor(element: Element): Registration {
+ this.watchPointer();
+ let registration = this.registry.get(element);
+ if (registration == null) {
+ registration = {
+ recognizers: [],
+ hovers: [],
+ consumer: null,
+ hasTouchMoveListener: false,
+ savedStyle: new Map(),
+ authorTouchAction: null,
+ manageStyle: true,
+ actionGroup: false,
+ actionGroupAsks: 0,
+ };
+ this.registry.set(element, registration);
+ }
+ return registration;
+ }
+
+ /** Whether anything attached could take input. */
+ private needsInput(): boolean {
+ return this.registry.size > 0 || this.retainers > 0;
+ }
+
+ private refresh(element: Element, registration: Registration): void {
+ if (registration.manageStyle && element instanceof HTMLElement) {
+ this.applyStyle(element, registration);
+ }
+ this.syncTouchMoveListener(element, registration);
+ }
+
+ /**
+ * The element's blocking `touchmove`. For every pointer a claiming
+ * recognizer keeps it: a finger's claim cancels its moves here (FR19), a
+ * listener FR24 counts outside the one-listener budget a fine pointer's
+ * element keeps for its press (syncArmed). A kept press needs it only
+ * while a press is held: a phone's before every touch (Safari honours a
+ * blocking listener only when registered before the touch began), a fine
+ * pointer's only during an armed press, whose `pointerdown` comes before
+ * the touch it brings (FR24).
+ */
+ private syncTouchMoveListener(
+ element: Element,
+ registration: Registration,
+ ): void {
+ const needsListener =
+ registration.recognizers.some(
+ recognizer => recognizer.isEnabled && claimsWhileMoving(recognizer),
+ ) ||
+ (registration.consumer?.keepsTouch != null &&
+ registration.consumer.isEnabled?.() !== false &&
+ (this.isCoarse || this.isArmedPress));
+ // The blocking listener sits on the element itself (FR19: a scroll that
+ // starts elsewhere never waits on it), but every element shares the
+ // host's one handler, which routes through the arena.
+ if (needsListener && !registration.hasTouchMoveListener) {
+ element.addEventListener('touchmove', this.onBlockingTouchMove, {
+ passive: false,
+ });
+ registration.hasTouchMoveListener = true;
+ } else if (!needsListener && registration.hasTouchMoveListener) {
+ element.removeEventListener('touchmove', this.onBlockingTouchMove);
+ registration.hasTouchMoveListener = false;
+ }
+ }
+
+ private applyStyle(element: HTMLElement, registration: Registration): void {
+ // A disabled recognizer leaves the browser everything (FR24).
+ // Every pointer gets it: a touchscreen laptop's finger as a phone's (FR43).
+ const active = registration.recognizers.filter(
+ recognizer => recognizer.isEnabled,
+ );
+ let style: GestureHostStyle =
+ active.length > 0 ? gestureHostStyle(active) : {};
+ // A control that keeps its touch (a stepper's hold-repeat) takes taps in
+ // quick succession: the browser's double-tap zoom goes, as for a tap
+ // recognizer (`spec:AST-094` FR18), for every pointer (FR43).
+ if (
+ style.touchAction == null &&
+ registration.consumer?.keepsTouch != null
+ ) {
+ style = {...style, touchAction: 'manipulation'};
+ }
+ for (const key of Object.keys(
+ STYLE_PROPERTIES,
+ ) as (keyof GestureHostStyle)[]) {
+ const property = STYLE_PROPERTIES[key];
+ let value = style[key];
+ if (key === 'touchAction') {
+ value = this.touchActionToWrite(element, registration, value);
+ }
+ if (value != null) {
+ if (!registration.savedStyle.has(property)) {
+ registration.savedStyle.set(
+ property,
+ element.style.getPropertyValue(property),
+ );
+ }
+ element.style.setProperty(property, value);
+ } else if (registration.savedStyle.has(property)) {
+ const saved = registration.savedStyle.get(property) ?? '';
+ registration.savedStyle.delete(property);
+ if (saved === '') {
+ element.style.removeProperty(property);
+ } else {
+ element.style.setProperty(property, saved);
+ }
+ }
+ }
+ }
+
+ /**
+ * The touch-action to write inline, if any. The author's value (a
+ * trigger's own `touch-action: none`) stays in force and the host never
+ * allows more (spec:AST-094 FR18, DEC-8):
+ * - an inline value is the style's own, read with no recalc, and the host
+ * writes what both allow, inline;
+ * - otherwise the element is marked with the derived value, under a rule
+ * any author rule outranks, as the recognizer attaches: a subtree that
+ * mounts with it is styled once, never restyled for it in a later frame.
+ * The next frame reads what is in force, styles clean, and the host
+ * writes inline only what both allow where that is narrower than an
+ * author rule's value.
+ */
+ private touchActionToWrite(
+ element: HTMLElement,
+ registration: Registration,
+ derived: string | undefined,
+ ): string | undefined {
+ if (derived == null) {
+ unmarkTouchAction(element);
+ registration.authorTouchAction = null;
+ return undefined;
+ }
+ const property = STYLE_PROPERTIES.touchAction;
+ const inline = registration.savedStyle.has(property)
+ ? (registration.savedStyle.get(property) ?? '')
+ : element.style.getPropertyValue(property);
+ if (inline !== '') {
+ unmarkTouchAction(element);
+ return intersectTouchAction(derived, inline);
+ }
+ if (!markTouchAction(element, derived)) {
+ // No document or shadow root to hold the rule (a detached element):
+ // nothing is styled there, and the next frame looks again.
+ this.readCallerTouchActionInNextFrame(element);
+ return undefined;
+ }
+ const read = registration.authorTouchAction;
+ if (read == null || read.derived !== derived) {
+ // The rule is in force until the read: the derived value, or the
+ // author's where the author sets one.
+ this.readCallerTouchActionInNextFrame(element);
+ return undefined;
+ }
+ if (read.author == null) {
+ return undefined;
+ }
+ const both = intersectTouchAction(derived, read.author);
+ return both === read.author ? undefined : both;
+ }
+
+ /**
+ * Read the touch-action in force on each marked element in the next frame,
+ * then apply its style again. Batched: one style read serves every pending
+ * element, and the writes follow the reads. The host has written no
+ * touch-action inline on them, so a value other than the mark is an author
+ * rule's.
+ */
+ private readCallerTouchActionInNextFrame(element: HTMLElement): void {
+ this.pendingCallerReads.add(element);
+ if (this.isCallerReadScheduled) {
+ return;
+ }
+ this.isCallerReadScheduled = true;
+ inNextFrame(this.doc.defaultView, () => {
+ this.isCallerReadScheduled = false;
+ const pending: [HTMLElement, Registration][] = [];
+ for (const candidate of this.pendingCallerReads) {
+ const registration = this.registry.get(candidate);
+ if (registration != null) {
+ pending.push([candidate, registration]);
+ }
+ }
+ this.pendingCallerReads.clear();
+ const view = this.doc.defaultView;
+ for (const [candidate, registration] of pending) {
+ const derived = candidate.getAttribute(TOUCH_ACTION_ATTRIBUTE);
+ if (derived == null || view == null) {
+ continue;
+ }
+ const inForce = view.getComputedStyle(candidate).touchAction || 'auto';
+ registration.authorTouchAction = {
+ derived,
+ author: inForce === derived ? null : inForce,
+ };
+ }
+ for (const [candidate, registration] of pending) {
+ this.applyStyle(candidate, registration);
+ }
+ });
+ }
+
+ private cleanup(element: Element, registration: Registration): void {
+ if (
+ registration.recognizers.length > 0 ||
+ registration.hovers.length > 0 ||
+ registration.consumer != null
+ ) {
+ // What stays may take nothing (only disabled recognizers): FR24.
+ this.syncListeners();
+ return;
+ }
+ if (registration.hasTouchMoveListener) {
+ element.removeEventListener('touchmove', this.onBlockingTouchMove);
+ }
+ this.registry.delete(element);
+ this.uninstallIfIdle();
+ }
+
+ /**
+ * Listen only while something could take a touch now (`spec:AST-094`
+ * FR24, FR43): where the primary pointer is coarse, an enabled recognizer
+ * or consumer, or a finger-scroll watch; where it is fine, only during a
+ * press an element armed (syncArmed). Otherwise no document listener is
+ * registered, so an idle page and a disabled control pay nothing.
+ */
+ private syncListeners(): void {
+ this.syncArmed();
+ if (this.shouldListen()) {
+ this.install();
+ this.syncClaimingListeners();
+ } else {
+ this.uninstall();
+ }
+ this.syncMoverBlocking();
+ }
+
+ /**
+ * Safari honours a blocking `touchmove` only where it was registered before
+ * the touch began, on the engine's own target, and on iOS WebKit a finger
+ * on an element a compositor animation moves lands, for the engine, on
+ * whatever sits under the motion's start (see presentedTarget). So while an
+ * element a recognizer that claims moving touches lives in (or around) is
+ * moving, the document carries the blocking listener (`spec:AST-094`
+ * FR19), and it comes off when the motion ends: only then does a scroll
+ * starting anywhere wait on the main thread.
+ */
+ private syncMoverBlocking(): void {
+ const isWatching = this.isInstalled && this.hitTestsLayout();
+ if (isWatching && this.stopWatchingMovers == null) {
+ this.stopWatchingMovers = watchPresentedElements(() =>
+ this.syncMoverBlocking(),
+ );
+ } else if (!isWatching && this.stopWatchingMovers != null) {
+ this.stopWatchingMovers();
+ this.stopWatchingMovers = null;
+ }
+ const needed =
+ isWatching &&
+ presentedElements().some(mover =>
+ this.hasClaimingRecognizerAround(mover),
+ );
+ if (needed === this.isBlockingForMovers) {
+ return;
+ }
+ this.isBlockingForMovers = needed;
+ if (needed) {
+ this.doc.addEventListener('touchmove', this.onBlockingTouchMove, {
+ passive: false,
+ });
+ } else {
+ this.doc.removeEventListener('touchmove', this.onBlockingTouchMove);
+ }
+ }
+
+ /** Whether a recognizer that claims moving touches lives in or around `mover`. */
+ private hasClaimingRecognizerAround(mover: Element): boolean {
+ if (!mover.isConnected || mover.ownerDocument !== this.doc) {
+ return false;
+ }
+ for (const [element, registration] of this.registry) {
+ if (
+ (mover.contains(element) || element.contains(mover)) &&
+ registration.recognizers.some(
+ recognizer => recognizer.isEnabled && claimsWhileMoving(recognizer),
+ )
+ ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Where the primary pointer is fine, each element with an enabled
+ * recognizer or press consumer listens for a press itself: one passive
+ * `pointerdown` listener on the element. The host's page listeners come at
+ * that press and go once its contacts end, so an idle page has none
+ * (`spec:AST-094` FR24, FR43). A finger on a touchscreen whose primary
+ * pointer is fine sends its `pointerdown` before its `touchstart`, so the
+ * host hears both.
+ */
+ private syncArmed(): void {
+ const wanted = new Set();
+ if (!this.isCoarse) {
+ for (const [element, registration] of this.registry) {
+ const consumer = registration.consumer;
+ if (
+ registration.recognizers.some(recognizer => recognizer.isEnabled) ||
+ (consumer != null && consumer.isEnabled?.() !== false)
+ ) {
+ wanted.add(element);
+ }
+ }
+ }
+ for (const [element, release] of this.armedElements) {
+ if (!wanted.has(element)) {
+ release();
+ this.armedElements.delete(element);
+ }
+ }
+ for (const element of wanted) {
+ if (this.armedElements.has(element)) {
+ continue;
+ }
+ element.addEventListener(
+ 'pointerdown',
+ this.onArmedPress,
+ PASSIVE_CAPTURE,
+ );
+ this.armedElements.set(element, () => {
+ element.removeEventListener(
+ 'pointerdown',
+ this.onArmedPress,
+ PASSIVE_CAPTURE,
+ );
+ });
+ }
+ if (wanted.size === 0 && this.isArmedPress) {
+ this.isArmedPress = false;
+ this.syncKeptPresses();
+ }
+ this.syncHoverListeners();
+ }
+
+ /**
+ * Where the primary pointer is fine, a mouse or a pen hovering an element
+ * with an enabled hover recognizer reaches it through two passive
+ * listeners on that element, `pointermove` and `pointerout`, and no page
+ * listener (`spec:AST-094` FR24, FR43). Where it is coarse, the page's
+ * listeners carry hover.
+ */
+ private syncHoverListeners(): void {
+ const wanted = new Set();
+ if (!this.isCoarse) {
+ for (const [element, registration] of this.registry) {
+ if (registration.hovers.some(hover => hover.isEnabled)) {
+ wanted.add(element);
+ }
+ }
+ }
+ for (const [element, release] of this.hoverElements) {
+ if (!wanted.has(element)) {
+ release();
+ this.hoverElements.delete(element);
+ }
+ }
+ for (const element of wanted) {
+ if (this.hoverElements.has(element)) {
+ continue;
+ }
+ element.addEventListener('pointermove', this.onElementHover, PASSIVE);
+ element.addEventListener('pointerout', this.onElementHoverOut, PASSIVE);
+ this.hoverElements.set(element, () => {
+ element.removeEventListener('pointermove', this.onElementHover);
+ element.removeEventListener('pointerout', this.onElementHoverOut);
+ });
+ }
+ }
+
+ /** A hovering mouse or pen moved over a hover element (fine pointer). */
+ private readonly onElementHover = (event: Event): void => {
+ const move = event as PointerEvent;
+ // While the page listeners are on, they carry hover.
+ if (
+ !this.isInstalled &&
+ move.pointerType !== 'touch' &&
+ move.buttons === 0
+ ) {
+ this.updateHover(move);
+ }
+ };
+
+ /** A hovering mouse or pen left a hover element, or one inside it (fine pointer). */
+ private readonly onElementHoverOut = (event: Event): void => {
+ const out = event as PointerEvent;
+ if (this.isInstalled || out.pointerType === 'touch') {
+ return;
+ }
+ if (out.relatedTarget == null) {
+ this.exitHover(out.pointerId);
+ } else {
+ // What it moved onto decides which hovers it is still over.
+ this.updateHover(out, out.relatedTarget);
+ }
+ };
+
+ /** A press on an armed element, a mouse's primary button, a pen or a finger: listen for this press. */
+ private readonly onArmedPress = (event: Event): void => {
+ const press = event as PointerEvent;
+ if (
+ this.isInstalled ||
+ !isPointerKind(press.pointerType) ||
+ (press.pointerType !== 'touch' && !isPrimaryClick(press))
+ ) {
+ return;
+ }
+ this.isArmedPress = true;
+ this.syncKeptPresses();
+ this.install();
+ // A finger's or a pen's press brings the cancelable touchend and the
+ // contextmenu canceller, as wherever the host installs, so a claimed
+ // lift clicks nothing and a claimed hold opens no browser menu (FR21). A
+ // mouse's press needs neither.
+ if (press.pointerType !== 'mouse') {
+ this.syncClaimingListeners();
+ }
+ // The window's capture phase is over for this event: hand it on.
+ this.onPointerDown(press);
+ };
+
+ /** A kept press's blocking `touchmove` follows the armed press (syncTouchMoveListener). */
+ private syncKeptPresses(): void {
+ for (const [element, registration] of this.registry) {
+ if (registration.consumer?.keepsTouch != null) {
+ this.syncTouchMoveListener(element, registration);
+ }
+ }
+ }
+
+ /** After a contact ends: an armed press that is over stops listening. */
+ private releaseArmedPress(): void {
+ if (!this.isArmedPress) {
+ return;
+ }
+ queueMicrotask(() => {
+ if (this.isArmedPress && this.arena.touches.length === 0) {
+ this.isArmedPress = false;
+ this.syncKeptPresses();
+ this.syncListeners();
+ }
+ });
+ }
+
+ /** With nothing attached, stop listening and stop following the pointer. */
+ private uninstallIfIdle(): void {
+ this.syncListeners();
+ if (this.registry.size === 0 && this.retainers === 0) {
+ this.uninstall();
+ this.stopWatchingPointer?.();
+ this.stopWatchingPointer = null;
+ }
+ }
+
+ /** Read the primary pointer, and follow it while anything is attached. */
+ private watchPointer(): void {
+ if (this.stopWatchingPointer != null) {
+ return;
+ }
+ const view = this.doc.defaultView;
+ this.isCoarse = this.isPrimaryPointerCoarse();
+ this.stopWatchingPointer = this.followsPointer
+ ? watchCoarsePointer(view, isCoarse => this.pointerChanged(isCoarse))
+ : () => {};
+ }
+
+ /**
+ * The primary pointer turned coarse or fine (a tablet docking a mouse):
+ * install or remove everything, as if each attach happened now.
+ */
+ private pointerChanged(isCoarse: boolean): void {
+ if (isCoarse === this.isCoarse) {
+ return;
+ }
+ this.isCoarse = isCoarse;
+ if (!isCoarse) {
+ this.cancelEverything(this.arena.now());
+ }
+ for (const [element, registration] of this.registry) {
+ this.refresh(element, registration);
+ }
+ this.syncListeners();
+ }
+
+ /**
+ * Whether an enabled participant could claim a touch or cancel a lift: a
+ * consumer, or a recognizer that claims touches. With only watchers
+ * attached, the host's listeners are all passive (`spec:AST-094` FR20).
+ */
+ private hasClaimer(): boolean {
+ for (const registration of this.registry.values()) {
+ const consumer = registration.consumer;
+ if (
+ (consumer != null && consumer.isEnabled?.() !== false) ||
+ registration.recognizers.some(
+ recognizer => recognizer.isEnabled && recognizer.claimsTouches,
+ )
+ ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Keeps the touchend cancelable, and the contextmenu subscription on,
+ * only while a claimer is attached; with only watchers the touchend is
+ * passive and the contextmenu is off. Clicks are the click guard's
+ * (utils/clickGuard.ts), whose one listener a window gets only once a
+ * gesture first arms it.
+ */
+ private syncClaimingListeners(): void {
+ if (!this.isInstalled) {
+ return;
+ }
+ const isClaiming = this.hasClaimer();
+ if (isClaiming === this.isClaimingInstalled) {
+ return;
+ }
+ const view = this.doc.defaultView;
+ for (const unsubscribe of this.claimingUnsubscribers.splice(0)) {
+ unsubscribe();
+ }
+ if (view != null) {
+ if (this.inputMode === 'auto') {
+ this.claimingUnsubscribers.push(
+ subscribeInput(view, 'touchend', this.onTouchEnd, {
+ cancels: isClaiming,
+ }),
+ );
+ }
+ if (isClaiming) {
+ this.claimingUnsubscribers.push(
+ subscribeInput(view, 'contextmenu', this.onContextMenu, {
+ cancels: true,
+ }),
+ );
+ }
+ }
+ this.isClaimingInstalled = isClaiming;
+ }
+
+ private shouldListen(): boolean {
+ if (!this.needsInput()) {
+ return false;
+ }
+ // A retainer (a finger-scroll watch) listens only where the pointer is
+ // coarse: it follows a finger's scroll, which a fine pointer's page
+ // leaves to the browser (FR24).
+ if (this.isCoarse && this.retainers > 0) {
+ return true;
+ }
+ let isUsed = false;
+ for (const registration of this.registry.values()) {
+ const consumer = registration.consumer;
+ if (
+ (consumer != null && consumer.isEnabled?.() !== false) ||
+ registration.recognizers.some(recognizer => recognizer.isEnabled) ||
+ (this.isCoarse && registration.hovers.some(r => r.isEnabled))
+ ) {
+ isUsed = true;
+ break;
+ }
+ }
+ // Where the primary pointer is fine, only while a press an element armed
+ // is held (syncArmed, FR24, FR43).
+ return isUsed && (this.isCoarse || this.isArmedPress);
+ }
+
+ // --- Listeners -------------------------------------------------------------
+
+ private install(): void {
+ this.watchPointer();
+ if (!this.needsInput()) {
+ return;
+ }
+ if (this.isInstalled) {
+ return;
+ }
+ const view = this.doc.defaultView;
+ this.isInstalled = true;
+ this.usesTouchEvents =
+ this.inputMode === 'auto' && view != null && 'ontouchstart' in view;
+ if (view == null) {
+ // A document without a window receives no input.
+ return;
+ }
+ // The window's capture phase, through the shared input stream
+ // (utils/inputStream.ts): ahead of any document listener, so an app's
+ // window-capture stopPropagation, or a document-capture
+ // stopImmediatePropagation registered first, no longer hides a touch
+ // from the recognizers. A recognizer that must claim mid-gesture still
+ // needs the touch to reach its element, where its blocking touchmove
+ // sits. Only the listeners that cancel are non-passive, and only while a
+ // claimer is attached (syncClaimingListeners); none of them is a
+ // touchstart or touchmove, so none of them holds a scroll.
+ this.unsubscribers = [
+ // Pointer Events only: a finger comes from its pointer events alone. A
+ // browser that has Touch Events still sends them for the same finger,
+ // and reading both would track it twice (a long press needs one touch).
+ ...(this.inputMode === 'auto'
+ ? [
+ subscribeInput(view, 'touchstart', this.onTouchStart),
+ subscribeInput(view, 'touchmove', this.onTouchMove),
+ subscribeInput(view, 'touchcancel', this.onTouchCancel),
+ ]
+ : []),
+ subscribeInput(view, 'pointerdown', this.onPointerDown),
+ subscribeInput(view, 'pointermove', this.onPointerMove),
+ subscribeInput(view, 'pointerup', this.onPointerUp),
+ subscribeInput(view, 'pointercancel', this.onPointerCancel),
+ subscribeInput(view, 'pointerout', this.onPointerOut),
+ subscribeInput(view, 'scroll', this.onScroll),
+ subscribeInput(view, 'visibilitychange', this.onVisibilityChange),
+ // The window's own blur; an element's blur (a Node's) reaches the
+ // window's capture phase too and means nothing here.
+ subscribeInput(view, 'blur', event => {
+ if (!(event.target instanceof view.Node)) {
+ this.onBlur();
+ }
+ }),
+ ];
+ // Passive until a claimer is attached (syncClaimingListeners).
+ this.isClaimingInstalled = false;
+ this.claimingUnsubscribers =
+ this.inputMode === 'auto'
+ ? [subscribeInput(view, 'touchend', this.onTouchEnd)]
+ : [];
+ }
+
+ private uninstall(): void {
+ if (!this.isInstalled) {
+ return;
+ }
+ this.isInstalled = false;
+ // Unheard from here on: no fingers to keep.
+ this.fingerTargets = [];
+ this.isClaimingInstalled = false;
+ for (const unsubscribe of [
+ ...this.unsubscribers.splice(0),
+ ...this.claimingUnsubscribers.splice(0),
+ ]) {
+ unsubscribe();
+ }
+ this.cancelEverything(this.arena.now());
+ this.lastTouch = null;
+ this.lastScroll = null;
+ }
+
+ // --- Hit path --------------------------------------------------------------
+
+ /**
+ * The recognizers on the path from `target` to the document, innermost
+ * first, and (for a contact) its consumer: the innermost element with an
+ * attached consumer.
+ */
+ private participantsAt(
+ target: unknown,
+ pointerType: PointerKind | null,
+ ): {
+ recognizers: GestureRecognizer[];
+ consumer: TouchConsumer | null;
+ consumerElement: Element | null;
+ hovers: HoverGestureRecognizer[];
+ } {
+ const recognizers: GestureRecognizer[] = [];
+ const hovers: HoverGestureRecognizer[] = [];
+ let consumer: TouchConsumer | null = null;
+ let consumerElement: Element | null = null;
+ if (
+ target instanceof Node &&
+ [...this.touchGates].some(gate => gate(target))
+ ) {
+ return {recognizers, consumer, consumerElement, hovers};
+ }
+ let hasConsumerOwner = pointerType == null;
+ for (
+ let node: Node | null = target instanceof Node ? target : null;
+ node != null;
+ node = parentAcrossShadow(node)
+ ) {
+ if (!(node instanceof Element)) {
+ continue;
+ }
+ const registration = this.registry.get(node);
+ if (registration != null) {
+ // Every pointer (`spec:AST-094` FR43), hover included.
+ recognizers.push(...registration.recognizers);
+ hovers.push(...registration.hovers);
+ }
+ if (hasConsumerOwner || pointerType == null) {
+ continue;
+ }
+ // A press consumer takes every pointer's press, a mouse's too
+ // (`spec:AST-094` FR43, DEC-21).
+ if (registration?.consumer != null) {
+ consumer = registration.consumer;
+ consumerElement = node;
+ hasConsumerOwner = true;
+ continue;
+ }
+ }
+ return {recognizers, consumer, consumerElement, hovers};
+ }
+
+ /**
+ * Turn text selection and the touch callout off, for as long as this
+ * finger or pen is down, only on the elements whose hold does something:
+ * a long press's (a menu, a hold-repeat), those of recognizers created
+ * with a `selectionLock` (a slider's thumb, a switch's drag, a tab bar's
+ * zero-delay lens) and that of a press tracker created with one (a
+ * stepper's hold-repeat). Anything whose hold does nothing, a plain
+ * press-tracked control or row included, keeps the browser's long-press
+ * selection (`spec:AST-094` FR41). Restored when the contact ends. Callers
+ * lock for a finger or a pen under any primary pointer, never for a mouse
+ * (locksSelectionFor), and only once the arena has admitted the contact:
+ * `recognizers` and `consumer` are the ones that took it.
+ */
+ private lockSelection(
+ id: number,
+ recognizers: ReadonlyArray,
+ target: EventTarget | null,
+ consumer: TouchConsumer | null,
+ consumerElement: Element | null,
+ ): void {
+ // Each recognizer whose hold does something brings its lock; the host
+ // never imports one (a component without such a hold does not bundle
+ // it). Only one that took this contact locks for it: a recognizer that
+ // declined it (its pointer, or its own shouldReceiveTouch) has no hold
+ // here.
+ const byLock = new Map>();
+ for (const recognizer of recognizers) {
+ const lock = recognizer.selectionLock;
+ if (lock != null && recognizer.view instanceof Element) {
+ const elements = byLock.get(lock) ?? new Set();
+ elements.add(recognizer.view);
+ byLock.set(lock, elements);
+ }
+ }
+ const consumerLock = consumer?.selectionLock;
+ if (consumerLock != null && consumerElement != null) {
+ const elements = byLock.get(consumerLock) ?? new Set();
+ elements.add(consumerElement);
+ byLock.set(consumerLock, elements);
+ }
+ if (byLock.size > 0) {
+ const undo = [...byLock].map(([lock, elements]) =>
+ lock(elements, target),
+ );
+ this.lockedBy.set(id, () => {
+ for (const release of undo) {
+ release();
+ }
+ });
+ }
+ }
+
+ /**
+ * A mouse or a pen that a recognizer claims for a continuous gesture (a
+ * pan's drag) takes no text selection from then on (`spec:AST-094` FR43,
+ * FR41): a drag that moved before the claim started selecting, so that
+ * selection goes, and the claiming recognizers' elements refuse one until
+ * the pointer lifts, as a held finger's do. A press that claims at its own
+ * pointerdown, before any movement (an action group's press), has
+ * selected nothing: the page's selection stays, for the group's Copy or
+ * Cut, while a new one is still refused. A tap claims only at its lift,
+ * so a click keeps the selection it made. A finger keeps the touch-down
+ * lock alone.
+ */
+ private lockClaimedDrags(ids: ReadonlyArray): void {
+ for (const id of ids) {
+ if (this.lockedBy.has(id)) {
+ continue;
+ }
+ const touch = this.arena.touches.find(each => each.id === id);
+ if (touch == null || touch.pointerType === 'touch') {
+ continue;
+ }
+ const elements = new Set();
+ for (const recognizer of this.arena.participantsOf(id)) {
+ if (
+ (recognizer.state === 'began' || recognizer.state === 'changed') &&
+ recognizer.view instanceof Element
+ ) {
+ elements.add(recognizer.view);
+ }
+ }
+ if (elements.size > 0) {
+ this.lockedBy.set(
+ id,
+ lockDragSelection(elements, this.doc.defaultView, {
+ keepsSelection:
+ touch.x === touch.startX && touch.y === touch.startY,
+ }),
+ );
+ }
+ }
+ }
+
+ /**
+ * Undo `lockSelection` for a contact that ended, at once for a cancel;
+ * idempotent.
+ */
+ private unlockSelection(id: number, isCancel = false): void {
+ this.lockedBy.get(id)?.({isCancel});
+ this.lockedBy.delete(id);
+ }
+
+ // --- Touch events ----------------------------------------------------------
+
+ /**
+ * Hold touches away from every participant while `gate(target)` answers
+ * true (a presentation running in a layer: its content takes no touch
+ * until it settles). Returns the release.
+ */
+ addTouchGate(gate: (target: Node) => boolean): () => void {
+ this.touchGates.add(gate);
+ return () => {
+ this.touchGates.delete(gate);
+ };
+ }
+
+ /**
+ * How many fingers are on the screen: the length of `touches` in the last
+ * touch event the host's listeners heard (`touchstart`, `touchmove`,
+ * `touchend`, `touchcancel`), whatever its target, so a finger on an
+ * element with no recognizer counts too. 0 before any, and while no
+ * listener is installed. A recognizer that must not begin under a second
+ * finger reads it in `shouldBegin`.
+ */
+ fingersDown(): number {
+ return this.fingerTargets.length;
+ }
+
+ /** Whether this event is new to the host (and mark it handled). */
+ private takeEvent(event: Event): boolean {
+ if (this.processed.has(event)) {
+ return false;
+ }
+ this.processed.add(event);
+ return true;
+ }
+
+ private readonly onTouchStart = (event: TouchEvent): void => {
+ this.noteFingersDown(event);
+ // A second finger leaves the two to the page's pinch, whichever events
+ // the host reads fingers from (FR10).
+ if (this.fingerTargets.length > 1) {
+ this.failUnclaimedLoneFingerPans();
+ }
+ // Where the host reads a finger from its pointer events (no ontouchstart,
+ // as after touch emulation turns on), its Touch Events would track it twice.
+ if (!this.usesTouchEvents || !this.takeEvent(event)) {
+ return;
+ }
+ const time = eventTime(event);
+ const inputs: ArenaBeganInput[] = [];
+ const untracked: TouchBeganInput[] = [];
+ const stale: number[] = [];
+ // Locked once the arena knows which recognizers took each finger.
+ const toLock: {
+ id: number;
+ target: EventTarget | null;
+ consumerElement: Element | null;
+ }[] = [];
+ for (const touch of touchesOf(event.changedTouches)) {
+ if ((touch as StylusTouch).touchType === 'stylus') {
+ continue;
+ }
+ // A finger number still tracked here began again: the touch it named
+ // ended where no listener of ours could hear it.
+ this.cancelledFingers.delete(touch.identifier);
+ const previous = this.forgetFinger(touch.identifier);
+ if (previous != null) {
+ stale.push(previous);
+ }
+ const native =
+ touch.target instanceof Node
+ ? landedOn(event, touch.target)
+ : landedOn(event);
+ const target = this.presentedTarget(native, touch.clientX, touch.clientY);
+ const {recognizers, consumer, consumerElement} = this.participantsAt(
+ target,
+ 'touch',
+ );
+ if (recognizers.length === 0 && consumer == null) {
+ // Nothing here takes part: the touch stays the page's, but a
+ // recognizer tracking another finger hears it began (a second
+ // finger beside an open menu ends its press, spec:AST-094 FR28).
+ untracked.push({
+ id: this.nextId++,
+ x: touch.clientX,
+ y: touch.clientY,
+ time,
+ pointerType: 'touch',
+ target,
+ });
+ continue;
+ }
+ const id = this.nextId++;
+ this.touchIds.set(touch.identifier, id);
+ if (locksSelectionFor('touch')) {
+ toLock.push({id, target, consumerElement});
+ }
+ // The finger's events keep going to what the engine hit.
+ this.watchTarget(
+ touch.identifier,
+ native,
+ recognizers.some(claimsWhileMoving) || consumer?.keepsTouch != null,
+ consumer != null ||
+ recognizers.some(recognizer => recognizer.claimsTouches),
+ );
+ this.lastFed.set(id, {x: touch.clientX, y: touch.clientY, time});
+ this.pairTouch(id, {x: touch.clientX, y: touch.clientY, target: native});
+ if (target !== native && target != null) {
+ this.retargeted.set(id, {x: touch.clientX, y: touch.clientY, target});
+ }
+ inputs.push({
+ id,
+ x: touch.clientX,
+ y: touch.clientY,
+ time,
+ pointerType: 'touch',
+ target,
+ scroll: createNativeScrollProbe(target),
+ recognizers,
+ consumer,
+ });
+ }
+ if (stale.length > 0) {
+ this.arena.cancelled(stale, time);
+ }
+ this.arena.began(inputs);
+ for (const {id, target, consumerElement} of toLock) {
+ this.lockSelection(
+ id,
+ this.arena.participantsOf(id),
+ target,
+ this.arena.consumerOf(id) ?? null,
+ consumerElement,
+ );
+ }
+ for (const input of untracked) {
+ this.arena.untrackedBegan(input);
+ }
+ // Again once the new fingers are delivered: a pinch's second finger may
+ // have landed on another one-finger pan, the next row (FR10).
+ if (this.fingerTargets.length > 1) {
+ this.failUnclaimedLoneFingerPans();
+ }
+ // Stamped after the consumers judged the touch: the brake asks whether
+ // an EARLIER finger is still driving a scroll.
+ const landed = landedOn(event);
+ if (landed instanceof Node) {
+ this.lastTouch = {target: landed, time};
+ }
+ };
+
+ private readonly onTouchMove = (event: TouchEvent): void => {
+ this.noteFingersDown(event);
+ // Where the host reads a finger from its pointer events (no ontouchstart,
+ // as after touch emulation turns on), its Touch Events would track it twice.
+ if (!this.usesTouchEvents || !this.takeEvent(event)) {
+ return;
+ }
+ this.dropFingersNotDown(event);
+ const time = eventTime(event);
+ const ids: number[] = [];
+ const inputs: TouchInput[] = [];
+ for (const touch of touchesOf(event.changedTouches)) {
+ const id = this.touchIds.get(touch.identifier);
+ if (id == null) {
+ continue;
+ }
+ ids.push(id);
+ inputs.push({id, x: touch.clientX, y: touch.clientY, time});
+ }
+ if (inputs.length === 0) {
+ return;
+ }
+ if (!event.cancelable) {
+ // The browser no longer asks the page about these fingers. WebKit
+ // stops asking when it takes the touch to pan (its pointercancel comes
+ // with it); Chromium also stops after one unconsumed touchmove, even
+ // when `touch-action` keeps it from panning the way the finger went (a
+ // horizontal drag under pan-y). So a finger's FIRST uncancelable move
+ // counts as the browser's claim only if the browser could pan along
+ // the finger's main axis since touch-down; later ones add nothing, and
+ // pointercancel or a scroll still report any real pan.
+ const placed = new Map(inputs.map(input => [input.id, input]));
+ const taken = ids.filter(id => {
+ const touch = this.arena.getTouch(id);
+ if (
+ touch == null ||
+ touch.takenByBrowser ||
+ this.arena.isClaimed(id) ||
+ touch.scroll?.allowsNativePan === false ||
+ this.judgedUncancelable.has(id)
+ ) {
+ return false;
+ }
+ this.judgedUncancelable.add(id);
+ const input = placed.get(id);
+ if (input == null || touch.scroll == null) {
+ return true;
+ }
+ const dx = input.x - touch.startX;
+ const dy = input.y - touch.startY;
+ return Math.abs(dx) >= Math.abs(dy)
+ ? touch.scroll.canScroll(dx, 0)
+ : touch.scroll.canScroll(0, dy);
+ });
+ if (taken.length > 0) {
+ this.browserTook(taken, time);
+ }
+ }
+ // A finger its pointer events sample is fed from there, never from here.
+ this.feedMoves(inputs.filter(input => !this.pointerFed.has(input.id)));
+ };
+
+ /** The one blocking `touchmove` handler every claiming element shares. */
+ private readonly onBlockingTouchMove = (event: Event): void => {
+ this.cancelIfClaimed(event as TouchEvent);
+ };
+
+ /**
+ * Cancel a `touchmove` while a recognizer owns one of its fingers. A touch
+ * no recognizer owns keeps its moves (`spec:AST-094` FR19): nothing is
+ * cancelled before a recognizer begins, or for a touch it does not track,
+ * unless a recognizer holds that finger already (a scroll-edge pan pulling
+ * away from an edge every scroller under it is at; FR19, DEC-18).
+ */
+ /**
+ * Under `inputMode: 'pointer-events'` a finger comes from its pointer
+ * events, so its touches carry no id of ours: the live finger it is, the
+ * nearest pointer-fed finger on the same target. Its touchmoves can then
+ * be cancelled once a recognizer claims it (`spec:AST-094` FR19, FR21).
+ */
+ private pointerFingerAt(touch: Touch): number | undefined {
+ let best: GestureTouch | null = null;
+ let bestDistance = Number.POSITIVE_INFINITY;
+ for (const finger of this.arena.touches) {
+ if (
+ finger.pointerType !== 'touch' ||
+ finger.phase === 'ended' ||
+ finger.phase === 'cancelled' ||
+ !(finger.target instanceof Node) ||
+ !(touch.target instanceof Node) ||
+ // Along the flat tree, both ways: the finger's target can be inside
+ // an open shadow root while its touches reach the host retargeted.
+ !(
+ containsAlongFlatTree(finger.target, touch.target) ||
+ containsAlongFlatTree(touch.target, finger.target)
+ )
+ ) {
+ continue;
+ }
+ const d = Math.hypot(finger.x - touch.clientX, finger.y - touch.clientY);
+ if (d < bestDistance) {
+ best = finger;
+ bestDistance = d;
+ }
+ }
+ return best?.id;
+ }
+
+ private cancelIfClaimed(event: TouchEvent): void {
+ if (!event.cancelable || event.defaultPrevented) {
+ return;
+ }
+ // Only the touches this move reports: another finger a recognizer owns
+ // does not make this one its own.
+ const moved = touchesOf(event.changedTouches);
+ for (const touch of moved) {
+ const id =
+ this.touchIds.get(touch.identifier) ??
+ // Where fingers come from pointer events (inputMode 'pointer-events',
+ // or 'auto' with no ontouchstart), the touch is matched to its pointer.
+ (this.usesTouchEvents ? undefined : this.pointerFingerAt(touch));
+ if (
+ id != null &&
+ (this.arena.isClaimed(id) ||
+ // A kept press keeps its finger under any pointer (FR11, DEC-21).
+ this.arena.consumerOf(id)?.keepsTouch?.(id) === true ||
+ // A recognizer that holds the finger before it begins: a
+ // scroll-edge pan pulling away from an edge (FR19, DEC-18), only
+ // while this is the one finger on the screen: with a second one
+ // down anywhere, even off the recognizer's element, the moves stay
+ // the browser's, for its pinch (FR10).
+ (event.touches.length === 1 &&
+ this.arena
+ .participantsOf(id)
+ .some(recognizer => internals(recognizer).keepsTouch(id))))
+ ) {
+ event.preventDefault();
+ return;
+ }
+ }
+ if (hasStylusTouch(event)) {
+ for (const id of this.pointerIds.values()) {
+ if (this.arena.isClaimed(id)) {
+ // A pen whose pointer a recognizer owns: its stylus touches too,
+ // never a finger no recognizer owns.
+ event.preventDefault();
+ return;
+ }
+ }
+ }
+ }
+
+ private readonly onTouchEnd = (event: TouchEvent): void => {
+ this.releaseArmedPress();
+ this.noteFingersDown(event);
+ if (hasStylusTouch(event)) {
+ // A pen's lift is its pointer's: the stylus touchend only refuses the
+ // compatibility mouse events and click when a gesture took the pen.
+ const isClaimed =
+ this.isPenLiftClaimed ||
+ [...this.pointerIds.values()].some(id => this.arena.isClaimed(id));
+ this.isPenLiftClaimed = false;
+ if (isClaimed && event.cancelable) {
+ event.preventDefault();
+ }
+ }
+ // Where the host reads a finger from its pointer events (no ontouchstart,
+ // as after touch emulation turns on), its Touch Events would track it twice.
+ if (!this.usesTouchEvents || !this.takeEvent(event)) {
+ return;
+ }
+ this.dropFingersNotDown(event);
+ const time = eventTime(event);
+ const inputs: TouchInput[] = [];
+ const retargetedEnds: {
+ id: number;
+ landed: {x: number; y: number; target: EventTarget};
+ x: number;
+ y: number;
+ }[] = [];
+ let isRefused = false;
+ for (const touch of touchesOf(event.changedTouches)) {
+ if (this.cancelledFingers.delete(touch.identifier)) {
+ isRefused = true;
+ continue;
+ }
+ const tracked = this.touchIds.get(touch.identifier);
+ const landed = tracked == null ? undefined : this.retargeted.get(tracked);
+ const id = this.forgetFinger(touch.identifier);
+ if (id == null) {
+ continue;
+ }
+ inputs.push({id, x: touch.clientX, y: touch.clientY, time});
+ if (landed != null) {
+ retargetedEnds.push({id, landed, x: touch.clientX, y: touch.clientY});
+ }
+ }
+ // The lift is when momentum starts: the clock the brake wants most.
+ const landed = landedOn(event);
+ if (landed instanceof Node) {
+ this.lastTouch = {target: landed, time};
+ }
+ const dispositions =
+ inputs.length > 0
+ ? this.arena.ended(inputs)
+ : new Map();
+ let cancelsClick = isRefused || needsSuppression(dispositions);
+ // A tap sent to what is drawn: the engine's click would hit the layout
+ // position, so it is cancelled and the tap clicks what it touched.
+ for (const end of retargetedEnds) {
+ if (this.clickRetargetedTap(end.landed, end, dispositions.get(end.id))) {
+ cancelsClick = true;
+ }
+ }
+ if (cancelsClick) {
+ if (event.cancelable) {
+ event.preventDefault();
+ }
+ armClickGuard(this.doc);
+ }
+ };
+
+ /**
+ * Clicks what a retargeted contact touched if it ended as a tap the
+ * engine would click: nothing claimed it, the browser did not take it
+ * (a taken contact is no longer retargeted), and the finger stayed within
+ * a tap's movement. Returns whether it clicked.
+ */
+ private clickRetargetedTap(
+ landed: {x: number; y: number; target: EventTarget} | undefined,
+ end: {x: number; y: number},
+ disposition: ClickDisposition | undefined,
+ ): boolean {
+ if (landed == null || disposition !== 'allow') {
+ return false;
+ }
+ const moved = Math.hypot(end.x - landed.x, end.y - landed.y);
+ if (moved > UIKIT_TAP.allowableMovement.value) {
+ return false;
+ }
+ this.clickRetargeted(landed.target, end.x, end.y);
+ return true;
+ }
+
+ private readonly onTouchCancel = (event: TouchEvent): void => {
+ this.releaseArmedPress();
+ this.noteFingersDown(event);
+ // Where the host reads a finger from its pointer events (no ontouchstart,
+ // as after touch emulation turns on), its Touch Events would track it twice.
+ if (!this.usesTouchEvents || !this.takeEvent(event)) {
+ return;
+ }
+ const listed = touchesOf(event.changedTouches);
+ // A cancel that names no finger is the system taking them all.
+ const identifiers =
+ listed.length > 0
+ ? listed.map(touch => touch.identifier)
+ : [...this.touchIds.keys()];
+ if (listed.length === 0) {
+ this.cancelledFingers.clear();
+ }
+ for (const identifier of identifiers) {
+ this.cancelledFingers.delete(identifier);
+ }
+ const ids: number[] = [];
+ for (const identifier of identifiers) {
+ const id = this.forgetFinger(identifier);
+ if (id != null) {
+ ids.push(id);
+ }
+ }
+ if (ids.length > 0) {
+ this.arena.cancelled(ids, eventTime(event));
+ }
+ };
+
+ /** The target's own listeners, for a target that left the document. */
+ private readonly onTargetTouchMove = (event: Event): void => {
+ this.onTouchMove(event as TouchEvent);
+ this.cancelIfClaimed(event as TouchEvent);
+ };
+
+ /** The same for a finger nothing could claim while it moves: never blocks. */
+ private readonly onTargetTouchMovePassive = (event: Event): void => {
+ this.onTouchMove(event as TouchEvent);
+ };
+
+ private readonly onTargetTouchEnd = (event: Event): void => {
+ this.onTouchEnd(event as TouchEvent);
+ };
+
+ /** The same, registered passively for a touch only watchers follow. */
+ private readonly onTargetTouchEndPassive = (event: Event): void => {
+ this.onTouchEnd(event as TouchEvent);
+ };
+
+ private readonly onTargetTouchCancel = (event: Event): void => {
+ this.onTouchCancel(event as TouchEvent);
+ };
+
+ /**
+ * Cancel tracked fingers the event says are no longer down. `touches`
+ * lists every finger on the screen; one missing from it (and not lifting
+ * in this very event) ended where we could not see it.
+ */
+ private dropFingersNotDown(event: TouchEvent): void {
+ if (
+ event.touches == null ||
+ (this.touchIds.size === 0 && this.cancelledFingers.size === 0)
+ ) {
+ return;
+ }
+ const down = new Set();
+ for (const touch of touchesOf(event.touches)) {
+ down.add(touch.identifier);
+ }
+ for (const touch of touchesOf(event.changedTouches)) {
+ down.add(touch.identifier);
+ }
+ for (const identifier of [...this.cancelledFingers]) {
+ if (!down.has(identifier)) {
+ this.cancelledFingers.delete(identifier);
+ }
+ }
+ const gone = [...this.touchIds.keys()].filter(
+ identifier => !down.has(identifier),
+ );
+ const ids: number[] = [];
+ for (const identifier of gone) {
+ const id = this.forgetFinger(identifier);
+ if (id != null) {
+ ids.push(id);
+ }
+ }
+ if (ids.length > 0) {
+ this.arena.cancelled(ids, eventTime(event));
+ }
+ }
+
+ private watchTarget(
+ identifier: number,
+ target: EventTarget | null,
+ isBlocking: boolean,
+ isClaiming: boolean,
+ ): void {
+ if (target == null || typeof target.addEventListener !== 'function') {
+ return;
+ }
+ this.touchTargets.set(identifier, target);
+ const state = this.watched.get(target);
+ if (state == null) {
+ this.watched.set(target, {
+ count: 1,
+ blocking: isBlocking ? 1 : 0,
+ claiming: isClaiming ? 1 : 0,
+ });
+ if (isBlocking) {
+ this.blockingFingers.add(identifier);
+ }
+ if (isClaiming) {
+ this.claimingFingers.add(identifier);
+ }
+ target.addEventListener(
+ 'touchmove',
+ isBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive,
+ {passive: !isBlocking},
+ );
+ target.addEventListener(
+ 'touchend',
+ isClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive,
+ {passive: !isClaiming},
+ );
+ target.addEventListener('touchcancel', this.onTargetTouchCancel, {
+ passive: true,
+ });
+ return;
+ }
+ const wasBlocking = state.blocking > 0;
+ const wasClaiming = state.claiming > 0;
+ state.count += 1;
+ if (isBlocking) {
+ state.blocking += 1;
+ this.blockingFingers.add(identifier);
+ }
+ if (isClaiming) {
+ state.claiming += 1;
+ this.claimingFingers.add(identifier);
+ }
+ this.swapTargetTouchMove(target, wasBlocking, state.blocking > 0);
+ this.swapTargetTouchEnd(target, wasClaiming, state.claiming > 0);
+ }
+
+ private unwatchTarget(identifier: number): void {
+ const target = this.touchTargets.get(identifier);
+ if (target == null) {
+ return;
+ }
+ this.touchTargets.delete(identifier);
+ const state = this.watched.get(target);
+ if (state == null) {
+ return;
+ }
+ const wasBlocking = state.blocking > 0;
+ const wasClaiming = state.claiming > 0;
+ state.count -= 1;
+ if (this.blockingFingers.delete(identifier)) {
+ state.blocking -= 1;
+ }
+ if (this.claimingFingers.delete(identifier)) {
+ state.claiming -= 1;
+ }
+ if (state.count > 0) {
+ this.swapTargetTouchMove(target, wasBlocking, state.blocking > 0);
+ this.swapTargetTouchEnd(target, wasClaiming, state.claiming > 0);
+ return;
+ }
+ this.watched.delete(target);
+ target.removeEventListener(
+ 'touchmove',
+ wasBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive,
+ );
+ target.removeEventListener(
+ 'touchend',
+ wasClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive,
+ );
+ target.removeEventListener('touchcancel', this.onTargetTouchCancel);
+ }
+
+ /**
+ * Keeps one `touchmove` listener on a watched target: the blocking one
+ * while a finger on it could be claimed as it moves, a passive one
+ * otherwise, so a scroll starting there never waits on the main thread
+ * (`spec:AST-094` FR19).
+ */
+ private swapTargetTouchMove(
+ target: EventTarget,
+ wasBlocking: boolean,
+ isBlocking: boolean,
+ ): void {
+ if (wasBlocking === isBlocking) {
+ return;
+ }
+ target.removeEventListener(
+ 'touchmove',
+ wasBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive,
+ );
+ target.addEventListener(
+ 'touchmove',
+ isBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive,
+ {passive: !isBlocking},
+ );
+ }
+
+ /**
+ * Keeps one `touchend` listener on a watched target: the cancelable one
+ * while a finger on it has a participant that could claim (a lift whose
+ * click must not happen cancels its `touchend`), a passive one while only
+ * watchers follow its fingers (`spec:AST-094` FR20).
+ */
+ private swapTargetTouchEnd(
+ target: EventTarget,
+ wasClaiming: boolean,
+ isClaiming: boolean,
+ ): void {
+ if (wasClaiming === isClaiming) {
+ return;
+ }
+ target.removeEventListener(
+ 'touchend',
+ wasClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive,
+ );
+ target.addEventListener(
+ 'touchend',
+ isClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive,
+ {passive: !isClaiming},
+ );
+ }
+
+ /** Stop tracking a finger by its Touch.identifier; returns its arena id. */
+ private forgetFinger(identifier: number): number | undefined {
+ const id = this.touchIds.get(identifier);
+ this.touchIds.delete(identifier);
+ this.unwatchTarget(identifier);
+ if (id != null) {
+ this.forgetContact(id);
+ }
+ return id;
+ }
+
+ private pairTouch(id: number, place: PlacedContact): void {
+ for (const [pointerId, pending] of this.pendingPointers) {
+ if (isSamePlace(pending, place)) {
+ this.pendingPointers.delete(pointerId);
+ this.fingerPointers.set(pointerId, id);
+ this.pointerFed.add(id);
+ return;
+ }
+ }
+ this.unpairedTouches.set(id, place);
+ }
+
+ private pairPointer(pointerId: number, place: PlacedContact): void {
+ for (const [id, unpaired] of this.unpairedTouches) {
+ if (isSamePlace(unpaired, place)) {
+ this.unpairedTouches.delete(id);
+ this.fingerPointers.set(pointerId, id);
+ this.pointerFed.add(id);
+ return;
+ }
+ }
+ this.pendingPointers.set(pointerId, place);
+ }
+
+ /** Feed moves from a contact's sampling channel, in time order. */
+ private feedMoves(inputs: ReadonlyArray): void {
+ const fresh = inputs.filter(input => {
+ const last = this.lastFed.get(input.id);
+ if (last != null && input.time < last.time) {
+ return false;
+ }
+ this.lastFed.set(input.id, {x: input.x, y: input.y, time: input.time});
+ return true;
+ });
+ if (fresh.length > 0) {
+ this.arena.moved(fresh);
+ }
+ }
+
+ /** Forget a contact's pairing and echo state. */
+ private forgetContact(id: number): void {
+ this.unlockSelection(id);
+ this.retargeted.delete(id);
+ this.lastFed.delete(id);
+ this.pointerFed.delete(id);
+ this.judgedUncancelable.delete(id);
+ this.unpairedTouches.delete(id);
+ for (const [pointerId, paired] of this.fingerPointers) {
+ if (paired === id) {
+ this.fingerPointers.delete(pointerId);
+ }
+ }
+ }
+
+ // --- Pointer events --------------------------------------------------------
+
+ private readonly onPointerDown = (event: PointerEvent): void => {
+ // A new press: whatever click is still owed to the last one is not this.
+ disarmClickGuard(this.doc);
+ if (!isPointerKind(event.pointerType)) {
+ return;
+ }
+ this.exitHover(event.pointerId);
+ if (event.pointerType === 'pen') {
+ // A new pen press: no earlier lift's stylus touchend is still owed.
+ this.isPenLiftClaimed = false;
+ }
+ if (event.pointerType === 'touch' && this.usesTouchEvents) {
+ this.pairPointer(event.pointerId, {
+ x: event.clientX,
+ y: event.clientY,
+ target: landedOn(event),
+ });
+ return;
+ }
+ if (
+ event.pointerType === 'touch' &&
+ this.arena.touches.some(
+ touch =>
+ touch.pointerType === 'touch' &&
+ touch.phase !== 'ended' &&
+ touch.phase !== 'cancelled',
+ )
+ ) {
+ // Fingers from pointer events: a second one, wherever it lands,
+ // leaves the two to the page's pinch, as onTouchStart does (FR10).
+ this.failUnclaimedLoneFingerPans();
+ }
+ // A right click, a pen's barrel button, or a ctrl-click on macOS (its
+ // context click) starts no press (FR43).
+ if (event.pointerType !== 'touch' && !isPrimaryClick(event)) {
+ return;
+ }
+ const landed = landedOn(event);
+ const target = this.presentedTarget(landed, event.clientX, event.clientY);
+ const {recognizers, consumer, consumerElement} = this.participantsAt(
+ target,
+ event.pointerType,
+ );
+ if (recognizers.length === 0 && consumer == null) {
+ return;
+ }
+ const id = this.nextId++;
+ this.pointerIds.set(event.pointerId, id);
+ if (target !== landed && target != null) {
+ this.retargeted.set(id, {x: event.clientX, y: event.clientY, target});
+ }
+ this.arena.began([
+ {
+ id,
+ x: event.clientX,
+ y: event.clientY,
+ time: eventTime(event),
+ pointerType: event.pointerType,
+ target,
+ scroll: createNativeScrollProbe(target),
+ recognizers,
+ consumer,
+ },
+ ]);
+ if (
+ event.pointerType === 'touch' &&
+ this.arena.touches.filter(
+ touch =>
+ touch.pointerType === 'touch' &&
+ touch.phase !== 'ended' &&
+ touch.phase !== 'cancelled',
+ ).length > 1
+ ) {
+ // Again once this finger is delivered: it may have landed on another
+ // one-finger pan (FR10).
+ this.failUnclaimedLoneFingerPans();
+ }
+ if (locksSelectionFor(event.pointerType)) {
+ // The presented target: what the pen is on, where the engine's own
+ // hit test may still see the motion's start.
+ this.lockSelection(
+ id,
+ this.arena.participantsOf(id),
+ target,
+ this.arena.consumerOf(id) ?? null,
+ consumerElement,
+ );
+ }
+ };
+
+ private readonly onPointerMove = (event: PointerEvent): void => {
+ if (event.pointerType === 'touch' && this.usesTouchEvents) {
+ const id = this.fingerPointers.get(event.pointerId);
+ if (id != null) {
+ this.feedMoves([
+ {
+ id,
+ x: event.clientX,
+ y: event.clientY,
+ time: eventTime(event),
+ history: coalescedHistory(event),
+ },
+ ]);
+ }
+ return;
+ }
+ const id = this.pointerIds.get(event.pointerId);
+ if (id != null) {
+ this.arena.moved([
+ {
+ id,
+ x: event.clientX,
+ y: event.clientY,
+ time: eventTime(event),
+ history: coalescedHistory(event),
+ },
+ ]);
+ return;
+ }
+ if (event.pointerType !== 'touch' && event.buttons === 0) {
+ this.updateHover(event);
+ }
+ };
+
+ private readonly onPointerUp = (event: PointerEvent): void => {
+ this.releaseArmedPress();
+ if (event.pointerType === 'touch' && this.usesTouchEvents) {
+ // The lift itself is the touchend's: it alone can cancel the click.
+ this.fingerPointers.delete(event.pointerId);
+ this.pendingPointers.delete(event.pointerId);
+ return;
+ }
+ const id = this.pointerIds.get(event.pointerId);
+ if (id == null) {
+ return;
+ }
+ this.pointerIds.delete(event.pointerId);
+ this.unlockSelection(id);
+ const dispositions = this.arena.ended([
+ {id, x: event.clientX, y: event.clientY, time: eventTime(event)},
+ ]);
+ // A retargeted tap's own click arms the click guard for the engine's.
+ const landed = this.retargeted.get(id);
+ this.retargeted.delete(id);
+ const clicked = this.clickRetargetedTap(
+ landed,
+ {x: event.clientX, y: event.clientY},
+ dispositions.get(id),
+ );
+ if (!clicked && needsSuppression(dispositions)) {
+ armClickGuard(this.doc);
+ }
+ if (event.pointerType === 'pen' && this.usesTouchEvents) {
+ this.isPenLiftClaimed = clicked || needsSuppression(dispositions);
+ }
+ };
+
+ private readonly onPointerCancel = (event: PointerEvent): void => {
+ this.releaseArmedPress();
+ if (event.pointerType === 'touch' && this.usesTouchEvents) {
+ const id =
+ this.fingerPointers.get(event.pointerId) ??
+ this.touchForCancelledPointer(event);
+ this.fingerPointers.delete(event.pointerId);
+ this.pendingPointers.delete(event.pointerId);
+ if (id != null) {
+ // The pointer channel ends here while the finger keeps moving (the
+ // browser scrolls with it): its touch events, now the only channel,
+ // sample it, as a watcher following the scroll to the lift needs.
+ this.pointerFed.delete(id);
+ this.browserTook([id], eventTime(event));
+ }
+ return;
+ }
+ const id = this.pointerIds.get(event.pointerId);
+ if (id != null) {
+ this.pointerIds.delete(event.pointerId);
+ this.unlockSelection(id, true);
+ this.retargeted.delete(id);
+ this.arena.cancelled([id], eventTime(event));
+ }
+ };
+
+ /**
+ * Which live finger a touch `pointercancel` is about. The pointer keeps
+ * the target the finger landed on (implicit capture), as the Touch does;
+ * among fingers on that target, the nearest. WebKit also numbers the
+ * pointer like the Touch, which settles what position cannot.
+ */
+ private touchForCancelledPointer(event: PointerEvent): number | null {
+ let best: GestureTouch | null = null;
+ let bestDistance = Number.POSITIVE_INFINITY;
+ for (const touch of this.arena.touches) {
+ if (
+ touch.pointerType !== 'touch' ||
+ touch.phase === 'ended' ||
+ touch.phase === 'cancelled' ||
+ touch.target !== landedOn(event)
+ ) {
+ continue;
+ }
+ const d = Math.hypot(touch.x - event.clientX, touch.y - event.clientY);
+ if (d < bestDistance) {
+ best = touch;
+ bestDistance = d;
+ }
+ }
+ if (best != null) {
+ return best.id;
+ }
+ return this.touchIds.get(event.pointerId) ?? null;
+ }
+
+ // --- Hover -------------------------------------------------------------------
+
+ private updateHover(
+ event: PointerEvent,
+ target: unknown = landedOn(event),
+ ): void {
+ if (!isPointerKind(event.pointerType)) {
+ return;
+ }
+ const {hovers} = this.participantsAt(target, null);
+ const next = new Set(hovers.filter(hover => hover.isEnabled));
+ const previous = this.hovered.get(event.pointerId) ?? new Set();
+ if (next.size === 0 && previous.size === 0) {
+ return;
+ }
+ const point = {x: event.clientX, y: event.clientY};
+ for (const hover of previous) {
+ if (!next.has(hover)) {
+ hover.hoverExited(point);
+ }
+ }
+ for (const hover of next) {
+ if (previous.has(hover)) {
+ hover.hoverMoved(point);
+ } else {
+ hover.hoverEntered(point, event.pointerType);
+ }
+ }
+ if (next.size === 0) {
+ this.hovered.delete(event.pointerId);
+ } else {
+ this.hovered.set(event.pointerId, next);
+ }
+ }
+
+ private exitHover(pointerId: number): void {
+ const hovers = this.hovered.get(pointerId);
+ if (hovers == null) {
+ return;
+ }
+ this.hovered.delete(pointerId);
+ for (const hover of hovers) {
+ hover.hoverExited();
+ }
+ }
+
+ private readonly onPointerOut = (event: PointerEvent): void => {
+ if (event.relatedTarget == null) {
+ this.exitHover(event.pointerId);
+ }
+ };
+
+ // --- Clicks, menus, scrolls, focus -----------------------------------------
+
+ private replayClick(touch: GestureTouch, allowed: boolean): void {
+ if (!allowed) {
+ return;
+ }
+ // The browser's own click was swallowed while recognizers decided; they
+ // all failed, so the click the touch earned is delivered now.
+ disarmClickGuard(this.doc);
+ const target = touch.target;
+ const view = this.doc.defaultView;
+ if (!(target instanceof Element) || !target.isConnected || view == null) {
+ return;
+ }
+ withoutClickGuard(() =>
+ target.dispatchEvent(
+ new view.MouseEvent('click', {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ detail: 1,
+ clientX: touch.x,
+ clientY: touch.y,
+ }),
+ ),
+ );
+ }
+
+ private readonly onContextMenu = (event: MouseEvent): void => {
+ for (const touch of this.arena.touches) {
+ if (this.arena.isClaimed(touch.id)) {
+ event.preventDefault();
+ return;
+ }
+ }
+ };
+
+ /**
+ * A recognizer is about to scroll `scroller` itself (a menu's edge
+ * autoscroll, spec:AST-094 FR30): the scroll that write fires is not a
+ * finger's and not the browser taking one. It passes until the end of the
+ * next frame, when that event has fired; a real pan in that window still
+ * reports itself through pointercancel or touchcancel.
+ */
+ ownScroll(scroller: Node): void {
+ const call = ++this.ownScrollCalls;
+ this.ownedScrolls.set(scroller, call);
+ const view = this.doc.defaultView;
+ if (view == null) {
+ return;
+ }
+ view.requestAnimationFrame(() => {
+ view.requestAnimationFrame(() => {
+ // A newer call keeps it until its own frame has passed.
+ if (this.ownedScrolls.get(scroller) === call) {
+ this.ownedScrolls.delete(scroller);
+ }
+ });
+ });
+ }
+
+ /**
+ * @internal Whether a recognizer is scrolling `scroller` itself now
+ * (ownScroll): that scroll is not a finger's (fingerScroll.ts reads it).
+ */
+ isOwnScroll(scroller: Node): boolean {
+ return this.ownedScrolls.has(scroller);
+ }
+
+ private readonly onScroll = (event: Event): void => {
+ const time = eventTime(event);
+ const scroller = event.target === this.doc ? this.doc : event.target;
+ if (!(scroller instanceof Node) || this.ownedScrolls.has(scroller)) {
+ return;
+ }
+ // A scroll is a finger's only if that finger was inside this scroller,
+ // recently; anything else is the program moving something.
+ const last = this.lastTouch;
+ if (
+ last != null &&
+ time - last.time < UIKIT_PRESS.momentumMemory.value &&
+ containsAlongFlatTree(scroller, last.target)
+ ) {
+ this.lastScroll = {scroller, time};
+ }
+ const taken: number[] = [];
+ for (const touch of this.arena.touches) {
+ if (
+ touch.takenByBrowser ||
+ touch.phase === 'ended' ||
+ touch.phase === 'cancelled' ||
+ touch.pointerType !== 'touch' ||
+ this.arena.isClaimed(touch.id)
+ ) {
+ continue;
+ }
+ if (
+ touch.target instanceof Node &&
+ containsAlongFlatTree(scroller, touch.target) &&
+ // Only a pan this finger drove takes the touch: the browser starts
+ // one only once the finger has travelled its slop. A scroll under a
+ // finger that has not (scrollIntoView, a transcript pinned to its
+ // end, scroll anchoring) is the page's own: the touch, and the click
+ // it earns, stay where they are.
+ Math.hypot(touch.x - touch.startX, touch.y - touch.startY) >=
+ UIKIT_PRESS.slop.value
+ ) {
+ taken.push(touch.id);
+ }
+ }
+ if (taken.length > 0) {
+ this.browserTook(taken, time);
+ }
+ };
+
+ private noteFingersDown(event: TouchEvent): void {
+ const fingers: Node[] = [];
+ for (const touch of touchesOf(event.touches)) {
+ if ((touch as StylusTouch).touchType !== 'stylus') {
+ if (touch.target instanceof Node) {
+ fingers.push(touch.target);
+ }
+ }
+ }
+ this.fingerTargets = fingers;
+ for (const listener of [...this.fingerListeners]) {
+ listener();
+ }
+ }
+
+ /**
+ * A second finger landed somewhere (wherever: often on a plain row): a
+ * pan that takes one finger only and has not claimed its touch fails, so
+ * the two fingers stay the browser's pinch (WCAG 1.4.4) instead of the
+ * pan claiming them midway. A drag a recognizer has claimed keeps its
+ * finger: a second finger never cancels or redirects it (`spec:AST-094`
+ * FR10), and no claim is released here.
+ */
+ private failUnclaimedLoneFingerPans(): void {
+ for (const recognizer of [...this.arena.involvedRecognizers]) {
+ if (
+ isPanRecognizer(recognizer) &&
+ recognizer.state === 'possible' &&
+ recognizer.maximumNumberOfTouches === 1 &&
+ recognizer.trackedTouches.length > 0 &&
+ recognizer.trackedTouches.every(
+ touch =>
+ touch.pointerType === 'touch' && !this.arena.isClaimed(touch.id),
+ )
+ ) {
+ recognizer.cancel();
+ }
+ }
+ }
+
+ private readonly onVisibilityChange = (): void => {
+ if (this.doc.visibilityState === 'hidden') {
+ this.cancelEverything(this.arena.now());
+ // As at a blur: the press sends no further pointer event, so an armed
+ // press's page listeners go now (FR24).
+ this.releaseArmedPress();
+ }
+ };
+
+ private readonly onBlur = (): void => {
+ this.cancelEverything(this.arena.now());
+ // The press a blur ends sends no further pointer event: an armed press's
+ // page listeners go now, as at its lift (FR24).
+ this.releaseArmedPress();
+ };
+
+ /** The app lost focus: UIKit cancels every touch in flight. */
+ private cancelEverything(time: number): void {
+ const ids = [...this.pointerIds.values()];
+ for (const id of ids) {
+ this.unlockSelection(id, true);
+ }
+ for (const identifier of [...this.touchIds.keys()]) {
+ // The browser may keep the touch alive: its lift must not click.
+ this.cancelledFingers.add(identifier);
+ const id = this.forgetFinger(identifier);
+ if (id != null) {
+ ids.push(id);
+ }
+ }
+ this.pointerIds.clear();
+ this.retargeted.clear();
+ this.fingerPointers.clear();
+ this.pendingPointers.clear();
+ this.unpairedTouches.clear();
+ this.lastFed.clear();
+ this.pointerFed.clear();
+ if (ids.length > 0) {
+ this.arena.cancelled(ids, time);
+ }
+ for (const pointerId of [...this.hovered.keys()]) {
+ this.exitHover(pointerId);
+ }
+ }
+}
+
+function needsSuppression(
+ dispositions: Map,
+): boolean {
+ for (const disposition of dispositions.values()) {
+ if (disposition !== 'allow') {
+ return true;
+ }
+ }
+ return false;
+}
+
+function coalescedHistory(event: PointerEvent): TouchSample[] {
+ if (typeof event.getCoalescedEvents !== 'function') {
+ return [];
+ }
+ const coalesced = event.getCoalescedEvents();
+ return coalesced.slice(0, -1).map(sample => ({
+ x: sample.clientX,
+ y: sample.clientY,
+ time: eventTime(sample),
+ }));
+}
+
+/**
+ * iOS WebKit, by the one property only it implements (the engine check of
+ * utils/touchPresentation.ts, which this module cannot import: it brings
+ * React). It gates one behaviour, presented hit testing, an engine
+ * workaround (`spec:AST-013` FR6) with its review condition and regression
+ * fixture in motion/presentation.ts.
+ */
+function isIosWebKit(
+ view: (Window & {CSS?: Partial}) | null,
+): boolean {
+ return view?.CSS?.supports?.('-webkit-touch-callout', 'none') === true;
+}
+
+/** The gesture host of a document, created on first use. */
+export function getGestureHost(doc: Document): GestureHost {
+ let host = gestureHosts.get(doc);
+ if (host == null) {
+ host = new GestureHost(doc);
+ gestureHosts.set(doc, host);
+ }
+ return host;
+}
diff --git a/packages/core/src/gestures/GestureRecognizer.ts b/packages/core/src/gestures/GestureRecognizer.ts
index 349ebbbda9ce8..de03cbbf2a9a6 100644
--- a/packages/core/src/gestures/GestureRecognizer.ts
+++ b/packages/core/src/gestures/GestureRecognizer.ts
@@ -24,7 +24,8 @@
* event, so a listener that writes a transform writes it in the same frame
* as the input, with no React render in between.
*
- * SYNC: When modified, update GestureArena.ts.
+ * SYNC: When modified, update GestureArena.ts, recognizerInternals.ts and
+ * README.md.
*/
import type {GestureArena} from './GestureArena';
@@ -38,6 +39,16 @@ import type {
} from './types';
import {DEFAULT_ENVIRONMENT} from './types';
+/**
+ * Turns text selection and the touch callout off on `elements` (and on a
+ * non-text input `target` inside one) for one held press; returns the undo.
+ * utils/selectionLock.ts's `lockSelection` is the one implementation.
+ */
+export type SelectionLock = (
+ elements: Iterable,
+ target: EventTarget | null,
+) => () => void;
+
export interface GestureRecognizerOptions {
/** A name for debugging and test output. */
name?: string;
@@ -51,6 +62,18 @@ export interface GestureRecognizerOptions {
cancelsTouchesInView?: boolean;
/** Hold the view's touch-began until this recognizer fails. @default false */
delaysTouchesBegan?: boolean;
+ /**
+ * For a hold that does something (a slider's thumb, a switch's or a
+ * segmented control's drag): the lock that turns text selection and the
+ * touch callout off on this recognizer's element for each held press
+ * (`spec:AST-094` FR41). Pass `lockSelection` from utils/selectionLock.ts;
+ * the host calls it and never imports it, so a component without such a
+ * hold does not bundle it. Content under a sheet's or a pager's pan stays
+ * selectable, so there is none by default; a mouse keeps desktop selection
+ * either way. A long press brings its own when it has a hold (a
+ * `minimumPressDuration` above 0); `null` brings none.
+ */
+ selectionLock?: SelectionLock | null;
/**
* Hold the view's touch-ended (and the browser's click) while this
* recognizer may still recognize. @default true
@@ -60,6 +83,17 @@ export interface GestureRecognizerOptions {
allowedPointerTypes?: ReadonlyArray;
/** Ignore a touch of a different kind than the ones already tracked. @default true */
requiresExclusiveTouchType?: boolean;
+ /**
+ * Whether this recognizer takes its touches from the browser when it
+ * recognizes. `false` makes a watcher (a tab bar that minimizes while the
+ * page scrolls): it never claims, so it gets only passive listeners and
+ * never delays a scroll; it prevents no other recognizer and none prevents
+ * it (`spec:AST-094` FR15); it cancels no press or click under it; and it
+ * keeps tracking a touch the browser takes for a scroll,
+ * reporting through the native scroll and ending at the lift.
+ * @default true
+ */
+ claimsTouches?: boolean;
/** gestureRecognizerShouldBegin: return false to fail instead of beginning. */
shouldBegin?: (recognizer: R) => boolean;
/** gestureRecognizer(_:shouldReceive:): return false to never see this touch. */
@@ -118,7 +152,9 @@ export function isActive(state: GestureState): boolean {
return state === 'began' || state === 'changed';
}
-/** The browser behaviors `touch-action` can keep or remove. */
+/**
+ * The browser behaviors `touch-action` can keep or remove.
+ */
export interface NativeTouchBehavior {
readonly panX: boolean;
readonly panY: boolean;
@@ -150,6 +186,8 @@ export abstract class GestureRecognizer {
name: string;
isEnabled: boolean;
cancelsTouchesInView: boolean;
+ /** See GestureRecognizerOptions.selectionLock. */
+ readonly selectionLock: SelectionLock | null;
delaysTouchesBegan: boolean;
delaysTouchesEnded: boolean;
allowedPointerTypes: ReadonlySet | null;
@@ -161,9 +199,9 @@ export abstract class GestureRecognizer {
view: unknown = null;
/** @internal The arena delivering touches to this recognizer, if any. */
- arena: GestureArena | null = null;
+ protected arena: GestureArena | null = null;
/** @internal A recognition waiting for a failure requirement. */
- pending: 'began' | 'ended' | null = null;
+ protected pending: 'began' | 'ended' | null = null;
/**
* The delegate functions. Typed against the base so a subclass stays
@@ -182,7 +220,7 @@ export abstract class GestureRecognizer {
/** Touches currently tracked, by id. */
protected readonly touches = new Map();
/** @internal Every touch id received since the last reset. */
- readonly seenTouchIds = new Set();
+ protected readonly seenTouchIds = new Set();
private lastLocation: Point = {x: 0, y: 0};
constructor(options: GestureRecognizerOptions = {}) {
@@ -191,6 +229,7 @@ export abstract class GestureRecognizer {
this.name = options.name ?? this.constructor.name;
this.isEnabled = options.isEnabled ?? true;
this.cancelsTouchesInView = options.cancelsTouchesInView ?? true;
+ this.selectionLock = options.selectionLock ?? null;
this.delaysTouchesBegan = options.delaysTouchesBegan ?? false;
this.delaysTouchesEnded = options.delaysTouchesEnded ?? true;
this.allowedPointerTypes =
@@ -212,7 +251,7 @@ export abstract class GestureRecognizer {
* it. Every touch recognizer claims; hover has no touches to claim.
*/
get claimsTouches(): boolean {
- return true;
+ return this.options.claimsTouches ?? true;
}
get state(): GestureState {
@@ -229,7 +268,7 @@ export abstract class GestureRecognizer {
}
/** @internal Whether this recognizer listens to touch at all. */
- get acceptsTouchInput(): boolean {
+ protected get acceptsTouchInput(): boolean {
return (
this.allowedPointerTypes == null || this.allowedPointerTypes.has('touch')
);
@@ -286,7 +325,7 @@ export abstract class GestureRecognizer {
}
/** @internal Whether this recognizer must wait for `other` to fail. */
- requiresFailureOf(other: GestureRecognizer): boolean {
+ protected requiresFailureOf(other: GestureRecognizer): boolean {
return (
this.failureRequirements.has(other) ||
this.options.shouldRequireFailureOf?.(this, other) === true ||
@@ -299,7 +338,7 @@ export abstract class GestureRecognizer {
* (`recognizeSimultaneouslyWith` sets both sides; each delegate answers for
* its own recognizer), so one delegate alone keeps them exclusive (FR15).
*/
- allowsSimultaneous(other: GestureRecognizer): boolean {
+ protected allowsSimultaneous(other: GestureRecognizer): boolean {
return (
this.simultaneousWith.has(other) ||
(this.options.shouldRecognizeSimultaneously?.(this, other) === true &&
@@ -308,10 +347,20 @@ export abstract class GestureRecognizer {
}
/** @internal The delegate's should-begin answer. */
- shouldBegin(): boolean {
+ protected shouldBegin(): boolean {
return this.options.shouldBegin?.(this) ?? true;
}
+ /**
+ * @internal Whether this recognizer holds `touchId` before it begins: the
+ * host then cancels that finger's moves as for a touch a recognizer owns.
+ * None does by default (`spec:AST-094` FR19); a scroll-edge pan holds a
+ * finger pulling away from an edge every scroller under it is at.
+ */
+ protected keepsTouch(_touchId: number): boolean {
+ return false;
+ }
+
/** UIKit's `canPrevent(_:)`: whether recognizing fails `other`. */
canPrevent(_other: GestureRecognizer): boolean {
return true;
@@ -396,6 +445,10 @@ export abstract class GestureRecognizer {
touches: ReadonlyArray,
event: GestureEventContext,
): boolean {
+ if (!this.claimsTouches) {
+ // A watcher follows the browser's scroll to the lift.
+ return true;
+ }
this.touchesCancelled(touches, event);
return false;
}
@@ -469,7 +522,7 @@ export abstract class GestureRecognizer {
// --- Arena hooks (internal) ----------------------------------------------
/** @internal Whether this recognizer takes `touch` right now. */
- wantsTouch(touch: GestureTouch): boolean {
+ protected wantsTouch(touch: GestureTouch): boolean {
if (this.state !== 'possible' && !isActive(this.state)) {
return false;
}
@@ -480,7 +533,7 @@ export abstract class GestureRecognizer {
}
/** @internal */
- deliverBegan(
+ protected deliverBegan(
touches: ReadonlyArray,
event: GestureEventContext,
): void {
@@ -493,7 +546,7 @@ export abstract class GestureRecognizer {
}
/** @internal */
- deliverBeganElsewhere(
+ protected deliverBeganElsewhere(
touches: ReadonlyArray,
event: GestureEventContext,
): void {
@@ -504,7 +557,7 @@ export abstract class GestureRecognizer {
}
/** @internal */
- deliverMoved(
+ protected deliverMoved(
touches: ReadonlyArray,
event: GestureEventContext,
): void {
@@ -517,7 +570,7 @@ export abstract class GestureRecognizer {
}
/** @internal */
- deliverEnded(
+ protected deliverEnded(
touches: ReadonlyArray,
event: GestureEventContext,
): void {
@@ -535,7 +588,7 @@ export abstract class GestureRecognizer {
}
/** @internal */
- deliverCancelled(
+ protected deliverCancelled(
touches: ReadonlyArray,
event: GestureEventContext,
): void {
@@ -552,7 +605,7 @@ export abstract class GestureRecognizer {
}
/** @internal */
- deliverTakenByBrowser(
+ protected deliverTakenByBrowser(
touches: ReadonlyArray,
event: GestureEventContext,
): void {
@@ -568,12 +621,19 @@ export abstract class GestureRecognizer {
}
}
+ /** @internal The host's hook: listeners follow whether anything is enabled. */
+ onEnabledChange: (() => void) | null = null;
+
/**
* UIKit's `isEnabled` setter: turning a recognizer off mid-gesture cancels
* the gesture (an active one) or fails it (a possible one).
*/
setEnabled(value: boolean): void {
+ const changed = this.isEnabled !== value;
this.isEnabled = value;
+ if (changed) {
+ this.onEnabledChange?.();
+ }
if (!value && (this.state !== 'possible' || this.isInvolved())) {
this.cancel();
}
@@ -592,7 +652,7 @@ export abstract class GestureRecognizer {
}
/** @internal Apply a transition the arena has approved. */
- applyState(next: GestureState): void {
+ protected applyState(next: GestureState): void {
const from = this.stateValue;
if (from === next && next !== 'changed') {
return;
@@ -615,7 +675,7 @@ export abstract class GestureRecognizer {
}
/** @internal Return to `possible` for the next attempt. */
- resetToPossible(): void {
+ protected resetToPossible(): void {
this.clearTimer();
this.pending = null;
this.touches.clear();
diff --git a/packages/core/src/gestures/HoverGestureRecognizer.ts b/packages/core/src/gestures/HoverGestureRecognizer.ts
new file mode 100644
index 0000000000000..844ee41852795
--- /dev/null
+++ b/packages/core/src/gestures/HoverGestureRecognizer.ts
@@ -0,0 +1,91 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file HoverGestureRecognizer.ts
+ * @input Hover enter / move / exit reports from a GestureHost
+ * @output Exports HoverGestureRecognizer (UIHoverGestureRecognizer)
+ * @position Continuous recognizer of packages/core/src/gestures that takes
+ * no touches: a mouse or a hovering pen over its view
+ *
+ * Began when a hovering pointer (no button pressed) enters the view, changed
+ * as it moves inside, ended when it leaves. A finger never hovers, so the
+ * recognizer is silent on touch screens, as UIKit's is on an iPhone. It
+ * claims nothing and conflicts with nothing: the host drives it directly,
+ * outside the touch arena.
+ *
+ * Governed by architecture:interaction-modality: hover only enhances, so no
+ * behavior may depend on this recognizer alone, and a touch or keyboard
+ * path reaches whatever it reveals.
+ */
+
+import {
+ GestureRecognizer,
+ isActive,
+ isTerminal,
+ type GestureRecognizerOptions,
+} from './GestureRecognizer';
+import type {Point, PointerKind} from './types';
+
+export class HoverGestureRecognizer extends GestureRecognizer {
+ private point: Point = {x: 0, y: 0};
+ /** The kind of pointer hovering: a mouse, or a pen in range. */
+ pointerType: PointerKind | null = null;
+
+ constructor(options: GestureRecognizerOptions = {}) {
+ super(options);
+ }
+
+ override get claimsTouches(): boolean {
+ return false;
+ }
+
+ /**
+ * @internal The host's brand for a hover recognizer, which it drives
+ * outside the touch arena, read without importing this class
+ * (recognizerInternals).
+ */
+ protected get family(): 'hover' {
+ return 'hover';
+ }
+
+ override location(): Point {
+ return this.point;
+ }
+
+ /** @internal The host saw a hovering pointer enter the view. */
+ hoverEntered(point: Point, pointerType: PointerKind): void {
+ if (!this.isEnabled || isActive(this.state)) {
+ return;
+ }
+ if (isTerminal(this.state)) {
+ // A hover cancelled mid-way (the recognizer was disabled, or stopped
+ // from code) starts over at the next enter.
+ this.resetToPossible();
+ }
+ this.point = point;
+ this.pointerType = pointerType;
+ this.applyState('began');
+ }
+
+ /** @internal */
+ hoverMoved(point: Point): void {
+ if (!isActive(this.state)) {
+ return;
+ }
+ this.point = point;
+ this.applyState('changed');
+ }
+
+ /** @internal The pointer left, pressed, or went away. */
+ hoverExited(point?: Point): void {
+ if (!isActive(this.state)) {
+ return;
+ }
+ if (point != null) {
+ this.point = point;
+ }
+ this.applyState('ended');
+ this.resetToPossible();
+ this.pointerType = null;
+ }
+}
diff --git a/packages/core/src/gestures/LongPressGestureRecognizer.ts b/packages/core/src/gestures/LongPressGestureRecognizer.ts
index b552928573727..91fdde83db499 100644
--- a/packages/core/src/gestures/LongPressGestureRecognizer.ts
+++ b/packages/core/src/gestures/LongPressGestureRecognizer.ts
@@ -21,6 +21,7 @@
*/
import {UIKIT_LONG_PRESS, UIKIT_TAP} from '../motion/uikit/gestures';
+import {lockSelection} from '../utils/selectionLock';
import {travel} from './geometry';
import {
GestureRecognizer,
@@ -41,6 +42,11 @@ export interface LongPressGestureRecognizerOptions extends GestureRecognizerOpti
}
export class LongPressGestureRecognizer extends GestureRecognizer {
+ /** @internal The host's brand: a long press keeps the callout and selection off (recognizerInternals). */
+ protected get family(): 'longPress' {
+ return 'longPress';
+ }
+
minimumPressDuration: number;
allowableMovement: number;
numberOfTapsRequired: number;
@@ -55,7 +61,20 @@ export class LongPressGestureRecognizer extends GestureRecognizer {
beganAt: number | null = null;
constructor(options: LongPressGestureRecognizerOptions = {}) {
- super(options);
+ // A long press's hold does something (a menu, a hold-repeat): it locks
+ // selection for its press (spec:AST-094 FR41). One at 0 ms recognizes at
+ // touch-down and has no hold, so it brings none, nor does one told null:
+ // there the browser keeps its native long press.
+ super({
+ ...options,
+ selectionLock:
+ options.selectionLock !== undefined
+ ? options.selectionLock
+ : (options.minimumPressDuration ??
+ UIKIT_LONG_PRESS.minimumPressDuration.value) > 0
+ ? lockSelection
+ : null,
+ });
this.minimumPressDuration =
options.minimumPressDuration ??
UIKIT_LONG_PRESS.minimumPressDuration.value;
diff --git a/packages/core/src/gestures/PanGestureRecognizer.ts b/packages/core/src/gestures/PanGestureRecognizer.ts
index 136fbcfe094f3..a3ad3b5ef2b8b 100644
--- a/packages/core/src/gestures/PanGestureRecognizer.ts
+++ b/packages/core/src/gestures/PanGestureRecognizer.ts
@@ -87,6 +87,11 @@ const DIRECTION_VECTORS: Partial> = {
};
export class PanGestureRecognizer extends GestureRecognizer {
+ /** @internal The host's brand: a pan follows the one-finger rule (recognizerInternals). */
+ protected get family(): 'pan' {
+ return 'pan';
+ }
+
minimumNumberOfTouches: number;
maximumNumberOfTouches: number;
hysteresis: number;
@@ -364,6 +369,36 @@ export class PanGestureRecognizer extends GestureRecognizer {
this.offset = {x: -swallowed.x, y: -swallowed.y};
}
+ /**
+ * A scroll-edge pan holds its finger from the first move while every
+ * scroller under it is at its edge and the finger pulls away from that
+ * edge, the pan's own way (`spec:AST-094` FR19, DEC-18):
+ * the browser would only rubber-band there. It still begins at its
+ * hysteresis; a move the content can still scroll stays the browser's,
+ * and so does every move while a second finger is down (a pinch).
+ */
+ protected override keepsTouch(touchId: number): boolean {
+ const touch = this.touches.get(touchId);
+ if (
+ !this.scrollEdge ||
+ this.state !== 'possible' ||
+ // A lone finger only: two fingers before a claim are the page's pinch
+ // (spec:AST-094 FR10).
+ this.touches.size !== 1 ||
+ touch == null ||
+ touch.pointerType !== 'touch' ||
+ touch.takenByBrowser
+ ) {
+ return false;
+ }
+ const moved = this.raw;
+ return (
+ (moved.x !== 0 || moved.y !== 0) &&
+ this.classify(moved) === 'begin' &&
+ !this.nativeCanScroll(moved.x, moved.y)
+ );
+ }
+
private nativeCanScroll(dx: number, dy: number): boolean {
for (const touch of this.touches.values()) {
if (touch.scroll?.canScroll(dx, dy) === true) {
diff --git a/packages/core/src/gestures/PinchGestureRecognizer.ts b/packages/core/src/gestures/PinchGestureRecognizer.ts
new file mode 100644
index 0000000000000..0ec42d730b991
--- /dev/null
+++ b/packages/core/src/gestures/PinchGestureRecognizer.ts
@@ -0,0 +1,149 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file PinchGestureRecognizer.ts
+ * @input Touches from a GestureArena
+ * @output Exports PinchGestureRecognizer (UIPinchGestureRecognizer) and its
+ * options
+ * @position Continuous two-finger recognizer of packages/core/src/gestures
+ *
+ * Tracks the first two fingers; more are ignored. Begins once the distance
+ * between them has changed by `hysteresis` px. As in UIKit, `scale` is then
+ * the current distance over the starting distance moved by the hysteresis
+ * toward the pinch, so it does not jump (settable to re-base it); `velocity`
+ * is scale per second. Lifting either finger ends an
+ * active pinch and fails a possible one.
+ *
+ * A host gives every element WITHOUT a pinch recognizer `pinch-zoom` in its
+ * `touch-action`, so the page stays zoomable (WCAG 1.4.4); attaching one
+ * takes the two-finger gesture for this element only.
+ */
+
+import {UIKIT_PINCH} from '../motion/uikit/gestures';
+import {centroid, distance} from './geometry';
+import {
+ GestureRecognizer,
+ isActive,
+ type GestureRecognizerOptions,
+ type NativeTouchBehavior,
+} from './GestureRecognizer';
+import type {GestureEventContext, GestureTouch, Point} from './types';
+import {VelocityTracker} from './VelocityTracker';
+
+export interface PinchGestureRecognizerOptions extends GestureRecognizerOptions {
+ hysteresis?: number;
+}
+
+export class PinchGestureRecognizer extends GestureRecognizer {
+ hysteresis: number;
+ private initialSpan = 0;
+ private scaleValue = 1;
+ private readonly tracker = new VelocityTracker();
+ /** The lift's event time, for the stillness reset. */
+ private liftTime: number | undefined;
+
+ constructor(options: PinchGestureRecognizerOptions = {}) {
+ super(options);
+ this.hysteresis = options.hysteresis ?? UIKIT_PINCH.hysteresis.value;
+ }
+
+ /** Current span over the span the fingers started at. */
+ get scale(): number {
+ return this.scaleValue;
+ }
+
+ /** Re-base: from now on the scale reads `value` at the current span. */
+ set scale(value: number) {
+ const span = this.span();
+ if (span > 0 && value > 0) {
+ const factor = this.scaleValue > 0 ? value / this.scaleValue : 1;
+ this.initialSpan = span / value;
+ this.scaleValue = value;
+ // The velocity continues from the re-based value, without a jump: the
+ // samples scale with it, keeping the release.
+ this.tracker.scaleBy(factor);
+ }
+ }
+
+ /** Scale per second (UIKit's filter; measured for pans, assumed here). */
+ get velocity(): number {
+ return this.tracker.velocity(this.liftTime).x;
+ }
+
+ /** The midpoint between the two fingers. */
+ focalPoint(): Point {
+ return centroid(this.touches.values());
+ }
+
+ /** Two fingers on this view are this recognizer's, not the browser's zoom. */
+ override get nativeTouchBehavior(): NativeTouchBehavior {
+ return {panX: true, panY: true, pinchZoom: false, doubleTapZoom: false};
+ }
+
+ protected override acceptsTouch(touch: GestureTouch): boolean {
+ return this.numberOfTouches < 2 && super.acceptsTouch(touch);
+ }
+
+ protected override touchesBegan(
+ _touches: ReadonlyArray,
+ event: GestureEventContext,
+ ): void {
+ if (this.numberOfTouches === 2) {
+ this.initialSpan = this.span();
+ this.scaleValue = 1;
+ this.tracker.reset();
+ this.tracker.add(1, 0, event.time);
+ }
+ }
+
+ protected override touchesMoved(
+ _touches: ReadonlyArray,
+ event: GestureEventContext,
+ ): void {
+ if (this.numberOfTouches < 2 || this.initialSpan <= 0) {
+ return;
+ }
+ const span = this.span();
+ // Once recognized, a pinch waiting on a recognizer it requires to fail
+ // keeps its re-based start: its moves only follow the fingers.
+ if (this.state === 'possible' && this.pending == null) {
+ if (Math.abs(span - this.initialSpan) >= this.hysteresis) {
+ // UIKit measures from the start moved by the hysteresis, and its
+ // first velocity spans from the touch-down (scale 1) to here.
+ this.initialSpan +=
+ Math.sign(span - this.initialSpan) * this.hysteresis;
+ this.scaleValue = span / this.initialSpan;
+ this.tracker.add(this.scaleValue, 0, event.time);
+ this.setState('began');
+ }
+ return;
+ }
+ this.scaleValue = span / this.initialSpan;
+ // Every move of the fingers is a sample: a span that holds while they
+ // move reads as no change, so the velocity never goes stale.
+ this.tracker.add(this.scaleValue, 0, event.time, {isMotion: true});
+ if (isActive(this.state)) {
+ this.setState('changed');
+ }
+ }
+
+ protected override touchesEnded(
+ _touches: ReadonlyArray,
+ event: GestureEventContext,
+ ): void {
+ this.liftTime = event.time;
+ this.setState(isActive(this.state) ? 'ended' : 'failed');
+ }
+
+ protected override reset(): void {
+ this.liftTime = undefined;
+ this.initialSpan = 0;
+ this.scaleValue = 1;
+ this.tracker.reset();
+ }
+
+ private span(): number {
+ const [a, b] = this.trackedTouches;
+ return a == null || b == null ? 0 : distance(a, b);
+ }
+}
diff --git a/packages/core/src/gestures/PressTracker.ts b/packages/core/src/gestures/PressTracker.ts
new file mode 100644
index 0000000000000..d6c23bc5968e4
--- /dev/null
+++ b/packages/core/src/gestures/PressTracker.ts
@@ -0,0 +1,397 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file PressTracker.ts
+ * @input Touches the arena delivers to a pressable view (TouchConsumer)
+ * @output Exports PressTracker (UIControl's touch tracking), its options, the
+ * control event type, and PRESS_PROFILES (a control's and a table cell's
+ * tracking and release clock)
+ * @position View-level consumer of packages/core/src/gestures: what a
+ * UIControl does with the touches no gesture recognizer took
+ *
+ * UIControl semantics, event for event:
+ *
+ * - `touchDown` when the finger lands; the highlight comes on in the same
+ * frame, unless the control sits in a scroller, where it waits
+ * `contentTouchDelay` (UIScrollView.delaysContentTouches) so a flick
+ * paints nothing.
+ * - `touchDragInside` / `touchDragOutside` on every move, `touchDragExit` /
+ * `touchDragEnter` when the finger crosses the control's bounds EXPANDED
+ * by `trackingExpansion` (a fingertip dragged a little off a button is
+ * still on it). The highlight follows inside / outside.
+ * - `touchUpInside` / `touchUpOutside` at the lift. A tap quicker than the
+ * scroller delay shows the highlight at the lift for `quickTapFlash`.
+ * - `touchCancel` when a recognizer took the touch (`cancelsTouchesInView`)
+ * or the browser took it to scroll: the highlight goes at once, with no
+ * release, one frame before the content moves.
+ *
+ * Two profiles (PRESS_PROFILES) cover what UIKit does:
+ *
+ * - `control` (a button, a switch): as above.
+ * - `cell` (a table or collection row): the highlight waits the
+ * content-touch delay inside a scroll view and comes on at once outside one
+ * (`spec:AST-094` FR11), travel past the pan hysteresis cancels the press
+ * for good (the scroll view took the touch), and a quicker tap highlights
+ * at the lift straight into the release, with no flash.
+ *
+ * And UIControl's iOS 6 rule: a single-finger single tap recognizer on an
+ * ANCESTOR does not beat the control's own action for a touch on it (the
+ * default veto; `vetoesAncestorRecognizer` replaces it, as a slider vetoes a
+ * pan along its track).
+ *
+ * The bounds are read once, when the finger lands; nothing reads layout
+ * during the press. Activation stays the browser's click, which the arena
+ * swallows for a touch a recognizer took.
+ */
+
+import {UIKIT_CONTROL, UIKIT_PAN, UIKIT_PRESS} from '../motion/uikit/gestures';
+import {timing} from '../motion/uikit/timing';
+import {travel} from './geometry';
+import type {GestureRecognizer, SelectionLock} from './GestureRecognizer';
+import {internals} from './recognizerInternals';
+import type {TapGestureRecognizer} from './TapGestureRecognizer';
+import type {GestureTouch, TouchConsumer} from './types';
+
+export type ControlEvent =
+ | 'touchDown'
+ | 'touchDragInside'
+ | 'touchDragOutside'
+ | 'touchDragEnter'
+ | 'touchDragExit'
+ | 'touchUpInside'
+ | 'touchUpOutside'
+ | 'touchCancel';
+
+export interface PressBounds {
+ readonly left: number;
+ readonly top: number;
+ readonly right: number;
+ readonly bottom: number;
+}
+
+export interface PressTrackerOptions {
+ /** The control's bounds in client px, read when a finger lands. */
+ getBounds: () => PressBounds | null;
+ /** The view the tracker belongs to; recognizers on it are not ancestors. */
+ view?: unknown;
+ onControlEvent?: (event: ControlEvent, touch: GestureTouch) => void;
+ onHighlightChange?: (isHighlighted: boolean) => void;
+ /**
+ * A continuous-tracking control (a held stepper) keeps its touch once its
+ * press is delivered: the host cancels the browser's pan for that finger,
+ * as UIKit's `touchesShouldCancel(in:)` is false for a tracking control.
+ * Its element then gets a blocking `touchmove` listener, so a scroll that
+ * starts on it waits on the main thread: never set it on plain buttons,
+ * links, cards or list cells.
+ * @default false
+ */
+ keepsTouch?: boolean;
+ /**
+ * For a control whose own record defines a hold action (a stepper's
+ * hold-repeat): pass `lockSelection` (gestures/selectionLock.ts) to turn
+ * text selection and the callout off for each press, where the primary
+ * pointer is coarse (`spec:AST-094` FR41). A control whose hold does
+ * nothing passes none. @default undefined
+ */
+ selectionLock?: SelectionLock;
+ trackingExpansion?: number;
+ /**
+ * When the highlight waits `contentTouchDelay`: inside a scroller (a
+ * control or a cell), always, or never. @default 'inScrollers'
+ */
+ highlightDelay?: PressHighlightDelay;
+ /**
+ * Travel (px) past which the press is cancelled for good, with no
+ * re-highlight on the way back (a cell whose scroll view took the touch).
+ * Unset, the finger is tracked in and out of the expanded bounds.
+ */
+ cancelTravel?: number;
+ /**
+ * How long a tap quicker than the delay shows the highlight at the lift,
+ * ms; 0 highlights and releases at once. @default the quick-tap flash
+ */
+ quickTapFlash?: number;
+ /** Which ancestor recognizers the control's own action beats. */
+ vetoesAncestorRecognizer?: (recognizer: GestureRecognizer) => boolean;
+ /** @default () => true */
+ isEnabled?: () => boolean;
+ /**
+ * Whether this touch is braking a scroll the finger set moving (see
+ * GestureHost.isScrollBraking): a native list highlights nothing for one,
+ * and the control does not track it at all.
+ */
+ isBraking?: (touch: GestureTouch) => boolean;
+}
+
+const PRESS = UIKIT_PRESS;
+
+export type PressHighlightDelay = 'inScrollers' | 'always' | 'never';
+
+/**
+ * How a kind of pressable tracks a finger and how long its release takes.
+ */
+export interface PressProfile {
+ readonly highlightDelay: PressHighlightDelay;
+ readonly cancelTravel?: number;
+ readonly quickTapFlash: number;
+ /** The release (`fading`) clock a painter uses, ms. */
+ readonly releaseMs: number;
+}
+
+export const PRESS_PROFILES = {
+ /** UIControl: same-frame highlight outside scrollers, 70 pt tracking. */
+ control: {
+ highlightDelay: 'inScrollers',
+ quickTapFlash: PRESS.quickTapFlash.value,
+ // The shared press styles release every surface on the deselect clock
+ // until a control runs its own release (UIButton's 250 ms), so a
+ // painter's `fading` never outlasts the fade it shows.
+ releaseMs: timing.press.highlightOutMs,
+ },
+ /**
+ * A table or collection cell: inside a scroll view it highlights after the
+ * content-touch delay, outside one at once (AST-094 FR11: the delay applies
+ * only inside scroll views); cancelled by the scroll.
+ */
+ cell: {
+ highlightDelay: 'inScrollers',
+ cancelTravel: UIKIT_PAN.hysteresis.value,
+ quickTapFlash: 0,
+ releaseMs: timing.press.highlightOutMs,
+ },
+} as const satisfies Record;
+
+export type PressProfileName = keyof typeof PRESS_PROFILES;
+
+/**
+ * UIButton's rule: an ancestor's single-finger single tap. The tap is told
+ * apart by its family rather than its class, so press tracking never ships
+ * TapGestureRecognizer.
+ */
+function vetoesSingleTap(recognizer: GestureRecognizer): boolean {
+ if (internals(recognizer).family !== 'tap') {
+ return false;
+ }
+ const tap = recognizer as TapGestureRecognizer;
+ return tap.numberOfTapsRequired === 1 && tap.numberOfTouchesRequired === 1;
+}
+
+export class PressTracker implements TouchConsumer {
+ isHighlighted = false;
+ private isKept = false;
+ isTracking = false;
+ isTouchInside = false;
+ trackingExpansion: number;
+
+ private readonly options: PressTrackerOptions;
+ private touchId: number | null = null;
+ private bounds: PressBounds | null = null;
+ private highlightTimer: ReturnType | null = null;
+ private flashTimer: ReturnType | null = null;
+ /** A pending highlight whose finger travelled: it will never paint. */
+ private isPendingDead = false;
+
+ constructor(options: PressTrackerOptions) {
+ if (options.keepsTouch === true) {
+ // Only the tracked touch whose press was delivered is kept.
+ this.keepsTouch = touchId => this.isKept && this.touchId === touchId;
+ }
+ this.options = options;
+ this.trackingExpansion =
+ options.trackingExpansion ?? UIKIT_CONTROL.trackingExpansion.value;
+ }
+
+ /**
+ * Whether the control takes touches now: a disabled one gives the host no
+ * reason to listen (`spec:AST-094` FR24).
+ */
+ isEnabled(): boolean {
+ return this.options.isEnabled?.() !== false;
+ }
+
+ touchBegan(touch: GestureTouch): void {
+ if (
+ this.touchId != null ||
+ this.options.isEnabled?.() === false ||
+ // The scroll brake is a finger's (`spec:AST-094` FR11, DEC-21).
+ (touch.pointerType === 'touch' &&
+ this.options.isBraking?.(touch) === true)
+ ) {
+ return;
+ }
+ this.clearFlash();
+ this.touchId = touch.id;
+ this.bounds = this.options.getBounds();
+ this.isTracking = true;
+ this.isTouchInside = true;
+ this.isPendingDead = false;
+ this.emit('touchDown', touch);
+ const delay = this.options.highlightDelay ?? 'inScrollers';
+ const delays =
+ touch.pointerType === 'touch' &&
+ (delay === 'always' ||
+ (delay === 'inScrollers' && touch.scroll?.isInsideScroller === true));
+ if (delays) {
+ this.highlightTimer = setTimeout(() => {
+ this.highlightTimer = null;
+ if (this.isTouchInside) {
+ this.setHighlighted(true);
+ }
+ }, PRESS.contentTouchDelay.value);
+ } else {
+ this.setHighlighted(true);
+ }
+ }
+
+ touchMoved(touch: GestureTouch): void {
+ if (touch.id !== this.touchId) {
+ return;
+ }
+ const cancelTravel = this.options.cancelTravel;
+ if (cancelTravel != null && travel(touch) > cancelTravel) {
+ // A cell's scroll view took the touch: the press is over.
+ this.touchCancelled(touch);
+ return;
+ }
+ if (this.highlightTimer != null && travel(touch) > PRESS.slop.value) {
+ // A finger travelling before the delay ran out is scrolling: the
+ // press it began never paints, as a flick on a list paints nothing.
+ clearTimeout(this.highlightTimer);
+ this.highlightTimer = null;
+ this.isPendingDead = true;
+ }
+ const inside = this.contains(touch);
+ if (inside !== this.isTouchInside) {
+ this.isTouchInside = inside;
+ this.emit(inside ? 'touchDragEnter' : 'touchDragExit', touch);
+ if (this.highlightTimer == null && !this.isPendingDead) {
+ this.setHighlighted(inside);
+ }
+ }
+ this.emit(inside ? 'touchDragInside' : 'touchDragOutside', touch);
+ }
+
+ touchEnded(touch: GestureTouch): void {
+ if (touch.id !== this.touchId) {
+ return;
+ }
+ this.isKept = false;
+ const inside = this.contains(touch);
+ const wasPending = this.highlightTimer != null;
+ this.clearHighlightTimer();
+ this.emit(inside ? 'touchUpInside' : 'touchUpOutside', touch);
+ const flash = this.options.quickTapFlash ?? PRESS.quickTapFlash.value;
+ if (wasPending && inside) {
+ // Quicker than the delay: the highlight shows at the lift, for the
+ // flash, or (a cell) straight into the release.
+ this.setHighlighted(true);
+ if (flash > 0) {
+ this.flashTimer = setTimeout(() => {
+ this.flashTimer = null;
+ this.setHighlighted(false);
+ }, flash);
+ } else {
+ this.setHighlighted(false);
+ }
+ } else {
+ this.setHighlighted(false);
+ }
+ this.finish();
+ }
+
+ touchCancelled(touch: GestureTouch): void {
+ if (touch.id !== this.touchId) {
+ return;
+ }
+ this.isKept = false;
+ this.clearHighlightTimer();
+ this.emit('touchCancel', touch);
+ this.setHighlighted(false);
+ this.finish();
+ }
+
+ allowsAncestorRecognizer(recognizer: unknown): boolean {
+ const candidate = recognizer as GestureRecognizer;
+ if (candidate.view != null && candidate.view === this.options.view) {
+ return true;
+ }
+ const vetoes = this.options.vetoesAncestorRecognizer ?? vetoesSingleTap;
+ return !vetoes(candidate);
+ }
+
+ /** Drop any highlight and timers (the control unmounted or was disabled). */
+ dispose(): void {
+ this.clearHighlightTimer();
+ this.clearFlash();
+ this.setHighlighted(false);
+ this.finish();
+ }
+
+ private contains(touch: GestureTouch): boolean {
+ const bounds = this.bounds;
+ if (bounds == null) {
+ return true;
+ }
+ const margin = this.trackingExpansion;
+ return (
+ touch.x >= bounds.left - margin &&
+ touch.x <= bounds.right + margin &&
+ touch.y >= bounds.top - margin &&
+ touch.y <= bounds.bottom + margin
+ );
+ }
+
+ private emit(event: ControlEvent, touch: GestureTouch): void {
+ this.options.onControlEvent?.(event, touch);
+ }
+
+ private setHighlighted(value: boolean): void {
+ if (value && this.touchId != null) {
+ // Delivered: the control keeps this touch until it ends (also when a
+ // quick tap's flash is still showing as the press arrives).
+ this.isKept = true;
+ }
+ if (this.isHighlighted === value) {
+ return;
+ }
+ this.isHighlighted = value;
+ this.options.onHighlightChange?.(value);
+ }
+
+ /**
+ * Present only for a continuous-tracking control created with
+ * `keepsTouch: true` (a held stepper): once its press is delivered, the
+ * browser's pan must not take the touch. A plain pressable leaves it
+ * undefined and keeps passive listeners, so a scroll starting on it never
+ * waits on the main thread.
+ */
+ readonly keepsTouch?: (touchId: number) => boolean;
+
+ /** The selection lock of a control whose hold acts (FR41); see the option. */
+ get selectionLock(): SelectionLock | undefined {
+ return this.options.selectionLock;
+ }
+
+ private clearHighlightTimer(): void {
+ if (this.highlightTimer != null) {
+ clearTimeout(this.highlightTimer);
+ this.highlightTimer = null;
+ }
+ }
+
+ private clearFlash(): void {
+ if (this.flashTimer != null) {
+ clearTimeout(this.flashTimer);
+ this.flashTimer = null;
+ }
+ }
+
+ private finish(): void {
+ // A highlight shown at the lift (a quick tap's flash) keeps nothing.
+ this.isKept = false;
+ this.touchId = null;
+ this.isTracking = false;
+ this.isTouchInside = false;
+ this.bounds = null;
+ }
+}
diff --git a/packages/core/src/gestures/README.md b/packages/core/src/gestures/README.md
new file mode 100644
index 0000000000000..e356025eaa0ac
--- /dev/null
+++ b/packages/core/src/gestures/README.md
@@ -0,0 +1,688 @@
+# Gestures
+
+A gesture recognizer system with UIKit's semantics, for the touch
+presentations of Astryx core components. Components import it from
+`../gestures`. Lab reaches its primitives through
+`@astryxdesign/core/internal/touch`, an internal entry reserved for Astryx's
+own packages (`architecture:react-component-runtime` INV13); nothing here is
+part of the public API.
+
+UIKit is the reference throughout: the same recognizer states, the same
+conflict rules, the same thresholds (from `../motion/uikit/gestures.ts`, each
+one sourced or marked as a guess awaiting measurement), and touch handling
+that behaves like `UIControl` and `UIScrollView`. The rest of this page says
+where the web forces a difference.
+
+## Using it from a component
+
+```tsx
+import {useRef} from 'react';
+import {PanGestureRecognizer, useGestureRecognizer} from '../gestures';
+
+function Sheet() {
+ const ref = useRef(null);
+ useGestureRecognizer(
+ ref,
+ () => new PanGestureRecognizer({direction: 'down'}),
+ pan => {
+ // Runs inside the input event: write the transform, render nothing.
+ const element = ref.current;
+ if (element == null) return;
+ if (pan.state === 'ended') {
+ settle(element, pan.translation().y, pan.velocity().y);
+ } else {
+ element.style.transform = `translateY(${pan.translation().y}px)`;
+ }
+ },
+ );
+ return …
;
+}
+```
+
+- The recognizer is created once per component and keeps its identity, so
+ relationships set up when it is created (`requireToFail`,
+ `recognizeSimultaneouslyWith`) keep working.
+- The listener is read through a ref. Passing a new function on each render
+ attaches nothing new, and a gesture never causes a React render.
+- The recognizer attaches in a layout effect, so the element's `touch-action`
+ is set before the browser can see a finger. It follows the element behind
+ the ref: a node rendered after mount, or swapped behind the same ref (a
+ trigger rendered `as` another element), is attached at that commit.
+- `isEnabled: false` mid-gesture ends the gesture without calling the
+ listener, since the handler that turned it off owns that end (no consumer
+ callback from an Effect). A handler that wants UIKit's `.cancelled` calls
+ `recognizer.setEnabled(false)` itself, which reports it at once. A
+ recognizer that required it to fail (a single tap waiting on a double
+ tap) resolves just after that render, and its listener reports as usual;
+ so does one waiting on a recognizer whose node a render replaced. After
+ unmount the listener hears nothing, not even the cancellation of a
+ gesture the unmount ended.
+- `actionGroup: true` attaches the element as an action group, sibling
+ actions a held pointer slides across (`family:action-groups` FR5), served
+ like every attachment, for every pointer. Changing it attaches the
+ element again.
+- `usePressTracker(ref, options)` gives an element `UIControl` touch tracking
+ (see below). It follows its element the same way, takes `isBraking` (by
+ default the host's: a touch that stops a flick presses nothing), and keeps
+ one tracker for the component's life: it reads `trackingExpansion`,
+ `highlightDelay`, `cancelTravel` and `quickTapFlash` (and the ref itself)
+ as they are now, so a change, even mid-press, never drops the press in
+ progress. Unmounting or losing its element calls neither
+ `onHighlightChange` nor `onControlEvent`, and neither does disabling the
+ control under a finger: the press ends, so the handler that disables it
+ clears its own highlight.
+- `useFingerScroll(scroller)`, an internal piece of the touch module with
+ no public API (not in the gestures barrel; a scroll-driven component
+ imports its module, so it ships only where used), returns a reader, `true` while the scroller's scroll is a finger's: from a scroll
+ while a finger that landed inside it is down, until its `scrollend` (the
+ momentum is still the finger's), or, where the browser fires none (mobile
+ Safari before 26), until its scroll events go quiet. Pass a ref, or the
+ document for the page. Outside React, `watchFingerScrolls(host, scroller)` and
+ `isFingerScroll(host, scroller)` from `fingerScroll.ts` do the same; that
+ module adds passive scroll listeners only while something is watched.
+
+Outside React, `getGestureHost(document).attach(element, recognizer)` does
+the same and returns a detach function. `getGestureHost(document).fingersDown()`
+is how many fingers are on the screen (the last touch event's `touches`,
+wherever they landed): a recognizer that must not begin under a second
+finger checks it in `shouldBegin`, so two fingers stay the browser's pinch.
+Once a drag has claimed its finger, a second finger never breaks it.
+
+## Recognizers
+
+| Recognizer | UIKit | Kind | Notes |
+| -------------------------------- | ---------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `TapGestureRecognizer` | `UITapGestureRecognizer` | discrete | `numberOfTapsRequired`, `numberOfTouchesRequired`; fails past 45 px of travel, on extra fingers, a hold over 1.5 s, a late or far next tap |
+| `LongPressGestureRecognizer` | `UILongPressGestureRecognizer` | continuous | `minimumPressDuration` 500 ms, `allowableMovement` 10 px (a distance, not per axis), `numberOfTapsRequired`; a zero duration begins on the touch itself (a selection tracker over a group of actions) |
+| `PanGestureRecognizer` | `UIPanGestureRecognizer` | continuous | min/max touches, `direction` (axis or one-way) with `axisRatio`, `lockAxis`, `scrollEdge`; `translation()`, `totalTranslation()`, `velocity()`, `scrollVelocity()` |
+| `SwipeGestureRecognizer` | `UISwipeGestureRecognizer` | discrete | recognized during the motion; one or more directions, `numberOfTouchesRequired`; 50 px along, at most 50 px across, 500 ms |
+| `PinchGestureRecognizer` | `UIPinchGestureRecognizer` | continuous | `scale` (settable, to re-base it), `velocity` in scale per second, `focalPoint()` |
+| `RotationGestureRecognizer` | `UIRotationGestureRecognizer` | continuous | `rotation` in radians, unwrapped, clockwise positive; `velocity` in rad/s |
+| `ScreenEdgePanGestureRecognizer` | `UIScreenEdgePanGestureRecognizer` | continuous | `edges` including `leading` / `trailing` (resolved against the writing direction); fails as soon as the touch lands outside |
+| `HoverGestureRecognizer` | `UIHoverGestureRecognizer` | continuous | a mouse or a hovering pen; silent for fingers |
+
+A recognizer created with `claimsTouches: false` is a watcher (a tab bar
+that minimizes while the page scrolls): it never claims, so its element gets
+only passive listeners and a scroll never waits for it; it prevents no other
+recognizer and none prevents it; and it follows a touch the browser takes
+for a scroll to the lift. It adds nothing to the element's derived
+`touch-action`.
+
+Every recognizer also has UIKit's switches: `isEnabled` (use `setEnabled`;
+turning it off mid-gesture cancels), `cancelsTouchesInView` (default true),
+`delaysTouchesBegan` (default false), `delaysTouchesEnded` (default true),
+`allowedPointerTypes`, and `requiresExclusiveTouchType`.
+
+### What the pan reports
+
+The pan reads its numbers the way `UIPanGestureRecognizer` does, as measured
+in the UIKit lab:
+
+- It begins once the centroid has moved `hysteresis` (10 px) along either
+ axis, not as a distance.
+- `translation()` leaves out the travel the threshold took: a slow drag reads
+ zero when the pan begins, and a step that overshoots keeps only the
+ overshoot. A dragged surface does not jump. `totalTranslation()` is the
+ movement since the fingers landed, for a surface that wants it.
+- `axisRatio` (for a pan limited to an axis): the movement along the axis
+ must exceed `axisRatio` times the movement across it to begin; movement
+ across that exceeds the movement along it fails the pan; in between it
+ waits.
+- `velocity()` is UIKit's own filter (see Velocity). The lift updates
+ neither number; a finger that rested past the stillness interval releases
+ at 0.
+
+### States
+
+```
+possible ─► began ─► changed* ─► ended | cancelled continuous
+possible ─► ended discrete ("recognized")
+possible ─► failed
+```
+
+Listeners (UIKit's target-action) run for `began`, `changed`, `ended` and
+`cancelled`. `addTransitionListener` also reports `failed` and the return to
+`possible`. A recognizer in a terminal state ignores the touches it still
+holds and returns to `possible` when all of them have ended.
+
+## Conflicts: the arena
+
+A new touch goes to every recognizer on its hit path, innermost first, and
+each one that accepts it tracks it until it ends. Then:
+
+- **Exclusivity.** Two recognizers that share a touch are never active
+ together unless one allows it (`recognizeSimultaneouslyWith`, or the
+ `shouldRecognizeSimultaneously` delegate). The first to recognize fails
+ the other possible ones. A recognizer that tries to begin while a
+ conflicting one is active fails instead. When two qualify on the same
+ input, the innermost one wins.
+- **Failure requirements.** `a.requireToFail(b)` keeps `a` in `possible`
+ until `b` fails, then lets `a` recognize. If `b` recognizes, `a` fails. A
+ recognizer that is not involved in the current touches holds nothing up.
+ The delegates `shouldRequireFailureOf` and `shouldBeRequiredToFailBy`
+ express the same thing dynamically.
+- **Delegates.** `shouldBegin`, `shouldReceiveTouch`, and `canPrevent` /
+ `canBePrevented` on subclasses. The arena's plumbing (touch delivery,
+ approved transitions, a pending recognition) is `protected`: a subclass
+ overrides the `touches*` handlers and sets `state`, and only the gesture
+ system drives the rest.
+
+```ts
+// Single tap waits for the double tap to fail, as in UIKit.
+single.requireToFail(double);
+
+// Inner horizontal scroller yields to the back-swipe in the edge band only:
+// the edge pan fails at touch-down anywhere else, so nothing waits.
+innerPan.requireToFail(edgePan);
+```
+
+## Touches in the view: `PressTracker`
+
+A pressable control's view-level touch handling, with `UIControl`
+semantics:
+
+- `touchDown`, `touchDragInside` / `touchDragOutside`, `touchDragEnter` /
+ `touchDragExit`, `touchUpInside` / `touchUpOutside`, `touchCancel`.
+- "Inside" is the control's bounds expanded by `trackingExpansion`, so a
+ fingertip that slides a little off a button is still on it.
+- The highlight comes on in the same frame. Inside a scroller it waits
+ `contentTouchDelay` (`UIScrollView.delaysContentTouches`), so a flick
+ paints nothing. A tap quicker than that delay shows the highlight at the
+ lift for `quickTapFlash`.
+- A recognizer that takes the touch (`cancelsTouchesInView`), or the browser
+ taking it to scroll, cancels the press with no release.
+- `UIControl`'s iOS 6 rule applies: an ancestor's single-finger single tap
+ does not beat the control's own action for a touch on it.
+ `vetoesAncestorRecognizer` replaces that rule (a slider vetoes a pan along
+ its track).
+
+The browser's click stays the control's activation, so keyboard and
+assistive-technology activation keep working.
+
+### Press profiles
+
+`PRESS_PROFILES` holds the two ways UIKit tracks a press:
+
+- `control` (a button, a switch): the highlight comes on in the same frame
+ outside a scroller, the finger is tracked 70 pt beyond the bounds, and
+ the release takes the list's deselect clock, as every surface's does
+ until a control runs its own (UIButton's 0.25 s);
+- `cell` (a table or collection row): the highlight waits the content-touch
+ delay inside a scroll view and comes on at once outside one
+ (`spec:AST-094` FR11), travel past the pan hysteresis cancels the press for
+ good, a quicker tap highlights at the lift straight into the release, and
+ the release takes the list's deselect clock.
+
+`paintPress(element, 'on' | 'held' | 'fading' | 'none', {releaseMs})`, in
+`utils/pressFeedback.ts`, writes `data-astryx-press` for every press it tracks;
+the action-group tracker (`hooks/actionGroupTracker.ts`) writes its groups'
+presses itself. `paintPress` owns the release timer and writes
+only over values the caller may replace (by default none, `on` and
+`fading`), so a `held` row survives presses until its owner releases it
+with `onlyFrom: ['held']`.
+
+### Rows and cards
+
+A list of a thousand rows cannot pay for a hook and four listeners per row.
+The document press controller (`utils/pressFeedback.ts`) presses the
+innermost enabled `[data-astryx-pressable]` under each touch and paints it,
+with no listener per row; a touch that brakes a scroll the finger set moving
+presses nothing.
+
+## What the browser is told
+
+- **Pens.** A pen drives every recognizer and press tracker as a finger
+ does: it pans, taps and long-presses; its hold, where the hold does
+ something, keeps text unselected as a finger's does (FR41); a hovering
+ pen reaches a hover recognizer as a mouse does; its barrel button is a
+ context click and starts no press; a pinch never mixes a pen with a
+ finger; and the screen-edge pan, a finger's only, ignores it (FR43).
+- **For every pointer.** Recognizers and press trackers attach for a mouse,
+ a pen and a finger alike (`spec:AST-094` FR43, FR19). Where the primary
+ pointer is coarse (`TOUCH_QUERY`, `../utils/coarsePointer.ts`), the host
+ listens while something enabled could take a touch. Where it is fine, each
+ element that uses a gesture keeps one passive `pointerdown` listener (and
+ a recognizer that claims a moving finger its blocking `touchmove`, which
+ FR24 counts outside that budget, FR19) and the page has none: a press on the element (a mouse's primary button, a
+ pen, or a finger, whose `pointerdown` comes before its `touchstart`)
+ adds the host's document and window listeners, and they go once that
+ press's contacts end, or when the window blurs during it (FR24). A press
+ tracker created with `keepsTouch` gets its blocking `touchmove` only
+ during such a press; where the pointer is coarse it keeps it, since
+ Safari honours a blocking listener only when it was registered before
+ the touch. An element with a hover recognizer listens for a hovering
+ mouse or pen with two passive listeners of its own, `pointermove` and
+ `pointerout`. A component that uses no gesture, and a
+ disabled recognizer or consumer, registers nothing; after a consumer's
+ `isEnabled()` changes, `host.update(element)` re-syncs. The screen-edge
+ pan stays off under a mouse, a trackpad and a pen (its recognizer allows
+ touches only; it takes no `allowedPointerTypes`), and a pinch
+ never mixes a pen with a finger (a recognizer takes one kind of pointer
+ at a time) (FR43). A press tracker takes a mouse press as a press, with
+ the fine-pointer profile: it paints at once, inside a scroller too, and
+ the scroll brake is a finger's only (FR11, DEC-21). A right click, a pen's
+ barrel button and a ctrl-click on macOS (its context click) start no press
+ (`isPrimaryClick`, `../utils/primaryClick.ts`). A mouse or a pen drag
+ that a recognizer claims takes no text selection from then on: the
+ selection the drag started goes, the claiming elements refuse one, and
+ the document refuses one too until the press's mouseup and click have
+ passed, since WebKit extends a drag's selection past them (FR41); a click
+ keeps its selection. The host follows the
+ query (a tablet docking or undocking a mouse): turning fine cancels every
+ touch in flight. `perEventPointer`, an option of `attach`,
+ `attachConsumer`, `useGestureRecognizer` and `usePressTracker` from
+ before FR43, is accepted and does nothing.
+- **`touch-action`.** Each element gets the intersection of what its
+ recognizers leave the browser, and never more than the element's own
+ value allows: a caller's explicit `touch-action` (a trigger's `none`)
+ stays in force, as the intersection of both. It is set inline on attach
+ and restored on detach (opt out with `manageStyle: false`):
+
+ | Recognizers on the element | `touch-action` |
+ | ------------------------------------------------- | ------------------- |
+ | tap, long press | `manipulation` |
+ | horizontal pan or swipe | `pan-y pinch-zoom` |
+ | horizontal edge pan on a strip within its band | `pan-y pinch-zoom` |
+ | vertical pan or swipe | `pan-x pinch-zoom` |
+ | one-way pan, pan with `scrollEdge` | left as is (`auto`) |
+ | edge pan on a view wider than its band | left as is (`auto`) |
+ | free pan | `pinch-zoom` |
+ | pinch or rotation | `pan-x pan-y` |
+ | pinch or rotation together with a free pan | `none` |
+ | a watcher (`claimsTouches: false`), any direction | left as is (`auto`) |
+
+ A one-way pan leaves the browser its own axis too: a toast dragged away
+ from its edge scrolls the page. It claims its direction by cancelling
+ `touchmove` when it begins.
+
+ `pinch-zoom` stays in the value unless a pinch or rotation recognizer is
+ attached, so the page stays zoomable (WCAG 1.4.4). A long press with a
+ hold also turns off the iOS callout and text selection on its element for
+ its press; one at 0 ms (a touch-down detector) or given
+ `selectionLock: null` leaves them to the browser.
+
+ While a finger or a pen is down, the host turns them off too only where
+ the hold does something (`spec:AST-094` FR41): on the element of a
+ recognizer created with a `selectionLock` (pass `lockSelection`; a
+ slider's thumb, a segmented
+ control's or a switch's drag) that would take the touch (enabled, and
+ accepting its pointer), restored when the contact ends. The host calls
+ the recognizer's lock and never imports one, so a component with no such
+ hold does not bundle it. A press tracker created with a `selectionLock`
+ (a control whose own record defines a hold action, such as a stepper's
+ hold-repeat) gets the same for each press. A press-tracked
+ control or row whose hold does nothing, and the document press
+ controller's surfaces (`usePressFeedback` rows), keep the browser's
+ long-press selection; a tap never selects. A non-text input under the finger inside such an element (a checkbox, a radio, a switch's input) is locked too: WebKit's own stylesheet makes every input selectable, which no ancestor's lock reaches. A text field keeps its selection, since a held finger places its caret. A
+ finger or a pen takes the lock under any primary pointer, a touchscreen
+ laptop and a desktop pen tablet included; a mouse never does, so mouse
+ selection is unchanged (`spec:AST-017` FR59); content under a sheet's or a
+ pager's pan stays selectable.
+
+- **Claiming.** When a recognizer recognizes, it owns its touches. Each
+ element with a recognizer that can claim a moving touch (a pan, a swipe, a
+ long press that then drags, a pinch, a rotation) has a non-passive
+ `touchmove` listener that cancels the event only while one of its touches
+ is owned. A touch nobody owns scrolls exactly as it would without the
+ system. Taps claim only at the lift, and a recognizer that never claims
+ (`claimsTouches` false, such as a pull that only watches the scroll) never
+ owns a touch, so an element with only those, or only a press consumer,
+ gets no such listener (see the known limit below). A press tracker created
+ with `keepsTouch: true` (opt-in, for a continuous-tracking control such
+ as a held stepper; never plain buttons, links, cards or list cells) gets
+ one: once its press is delivered its finger's moves are cancelled, so the
+ browser's pan never takes the held control (UIKit's
+ `touchesShouldCancel(in:)` is false for a tracking control). Its element
+ also gets `touch-action: manipulation`, for every pointer, as a tap
+ recognizer's does (`spec:AST-094` FR18), so taps in quick succession
+ never zoom. Where the primary pointer is fine the blocking listener comes
+ only with a press: the element keeps its one `pointerdown` listener
+ (FR24), the press adds the `touchmove` before its `touchstart`, and the
+ kept finger's moves are cancelled from then on, as on a phone (FR11,
+ DEC-21). A scroll that starts on such a control waits on the main
+ thread; a move before the press is delivered still scrolls. The listener the host
+ adds to a finger's target at touch-down, for a target that leaves the
+ document mid-gesture, is passive too unless a recognizer on that touch can
+ claim while it moves.
+- **Claim at the begin.** A pan claims its touch only as it begins, at
+ UIKit's 10 pt (`spec:AST-094` FR19), before WebKit commits a slow drag to
+ a scroll at 10–11 px; a drag the browser took first stays the browser's
+ (FR14). One exception: a `scrollEdge` pan cancels its finger's moves from
+ the first one while every scroller under the finger is at its edge and
+ the finger pulls away from it, the pan's own way (the browser could only
+ rubber-band there); it still begins at 10 pt. It holds only while that is
+ the one finger on the screen: with a second down anywhere, the moves stay
+ the browser's, for its pinch (FR10).
+ Measured in Mobile Safari (iOS 26.2): `touchmove` arrives from the first
+ pixel; on a scrollable element WebKit commits to scrolling at 10 to 11 px
+ (the last cancelable `touchmove` at 10 px), and cancelling every
+ `touchmove` from 8 px keeps the page still for the whole gesture. Under
+ `pan-y` it scrolls only drags steeper than about 2:1 to 3:1, once the
+ vertical travel reaches about 10 px. A pan that competes with native
+ scrolling therefore decides at 10 px or less.
+- **The browser's claim.** `pointercancel` on a finger, or a scroll above
+ the touch once the finger has travelled the slop (the browser starts a pan
+ no earlier), means the browser took the finger; a scroll under a finger
+ that has not moved (scrollIntoView, a transcript pinned to its end, scroll
+ anchoring) is the page's own and leaves the press and its click alone. So
+ does its first `touchmove`
+ that can no longer be cancelled, when the browser could pan along the
+ finger's main axis. (Chromium also stops asking after one unconsumed
+ `touchmove` when `touch-action` keeps it from panning that way, as for a
+ horizontal drag under `pan-y`; that is not a claim.) Possible recognizers that share it fail, active ones are
+ cancelled, and the view's press is cancelled.
+- **Clicks.** When a touch's gesture cancelled the view's touches, its
+ `touchend` is cancelled (no click and no compatibility mouse events) and
+ the shared click guard (`utils/clickGuard.ts`, the menu press model's
+ too) swallows the browser's next click, until the next `pointerdown` or
+ 400 ms. A click held back for a
+ recognizer that may still recognize (a double tap between taps) is
+ swallowed at once and replayed on the original target if the recognizer
+ fails. Clicks with `detail === 0` (keyboard, assistive technology, code)
+ always pass.
+- **Context menus.** A `contextmenu` while a recognizer owns a touch is
+ cancelled, so a long press never also opens the browser's menu.
+- **Focus.** Losing window focus or hiding the page cancels every touch in
+ flight, as UIKit does when the app resigns active.
+
+### Native scrolling
+
+`scrollEdge` makes a pan share its axis with the native scrollers under the
+finger. This is the sheet over its own list: the pan begins only if nothing
+under the touch could scroll for that movement, the finger landed at the
+edge and pulls away from it. The browser has not committed yet, so beginning
+claims the touch and the content never moves. From the first move that
+pulls away from such an edge the pan already holds the finger, so its moves
+are cancelled before it begins and the page does not rubber-band either.
+
+Who gets a drag is decided once, when it begins, as in UIKit: a drag that
+began as a scroll stays the scroll view's for its whole length (it scrolls
+to the top, then rubber-bands), and never changes hands midway
+(`spec:AST-094` FR14).
+
+"Could scroll" follows the browser's own rules: `touch-action` between the
+touch and each scroller, scroll chaining stopped by `overscroll-behavior`,
+and RTL `scrollLeft` ranges, along the flat tree (content slotted into a
+shadow-tree scroller scrolls with it, as the hit path does).
+
+### The input stream
+
+The host listens through `utils/inputStream.ts`, which it shares with the
+press feedback controller: one native listener per event type, on the
+window's capture phase, fanned out to every subscriber in order. It runs
+ahead of any document listener, so an app's window-capture
+`stopPropagation`, or a document-capture `stopImmediatePropagation`
+registered before core, no longer hides a touch from either, as UIKit's
+recognizers see touches before views do. A recognizer that must claim
+mid-gesture still needs the touch to reach its element, where its blocking
+`touchmove` sits, below such a stop. The stream stays ahead of an app's own
+window-capture listener only until a type's listener is re-added (a
+passivity switch, or a subscription after its last subscriber left), which
+appends it after the app's. A type's listener is passive unless a
+subscriber cancels it: the host cancels `touchend` and `contextmenu` there
+while a claiming participant is attached, never a `touchstart` or a
+`touchmove`, and the click guard (`../utils/clickGuard.ts`) keeps one
+cancelling `click` once it first arms. A blocking `touchmove` sits on a claiming
+element and on a claimed touch's target, on the document while an element a
+claiming recognizer lives in is moving (Touches on moving elements), or on
+the window only while a page hold is held, so a page of pressables blocks
+no scroll.
+
+### Holding the page still
+
+A scroll lock may hold the page with `holdPageScroll(document, decide)` from
+`pageHold.ts`, which returns the release (none does yet: `useScrollLock`
+does not call it). It lives in its own module, so a page without a scroll
+lock never ships it, and it imports nothing of the gesture host: it asks the
+document's host whether a recognizer owns a touch only if a host exists, and
+a document without one has no recognizer to own it. A hold asks nothing as
+it starts (an overlay opens without reading the pointer): the input stream's
+`pointerdown` reads the primary pointer at its first touch or pen contact,
+and the hold follows it from then on. While any hold is active, and only
+where the primary pointer is coarse, the input stream listens with a
+non-passive `touchmove`; where the contact's `touchstart` came before its
+`pointerdown`, the finger is named at its first move. Once per one-finger touch that no
+recognizer owns, after the
+finger clears a 5 px slop (under WebKit's 10–11 px scroll claim), it asks
+`decide(origin, dx, dy)` whether that pan would scroll the page, `origin`
+being the innermost node under the finger (the composed path's first node),
+and cancels the touch's moves for its whole length if so. A second finger is
+never cancelled, so pinch-zoom keeps working. With no hold, the listener is
+gone and every touch scrolls as without the system.
+
+### Multiple fingers
+
+A drag a recognizer has claimed keeps its finger: a second finger never
+cancels or redirects it (`spec:AST-094` FR10), as in UIKit, where a pan
+ignores touches beyond its maximum. Before any claim, a second finger landing
+anywhere (often on a plain row no recognizer is on) fails a pan limited to
+one finger, so the two fingers zoom the page (WCAG 1.4.4) instead of the pan
+claiming them midway.
+
+## Time
+
+Every input carries its own timestamp (`event.timeStamp`), and timers are
+deadlines on that same clock. Before an input is processed, every timer due
+by its timestamp runs first, at its own deadline. A long press therefore
+begins at exactly touch-down + 500 ms even when the main thread was blocked
+and the timer callback ran late. Tests drive the whole system with explicit
+timestamps and no real time.
+
+## Velocity
+
+`VelocityTracker` reports px per second, UIKit's unit, and is the one
+velocity tracker every continuous recognizer uses
+(`architecture:interaction-modality` INV10, `spec:AST-100` FR1, FR2; no
+alternative estimators). One filter, iOS's measured blend, updated on each
+moved sample (a sample at the newest position is not motion):
+
+- A pan's velocity is 0.2 times the newest interval's velocity plus 0.8 times
+ the previous interval's (an interval runs from one moved sample to the
+ next; the touch-down counts as the first). With one interval so far it is
+ that interval, as UIKit reports it. It reproduces every value UIKit
+ reported on six recorded touch streams, and like UIKit's it reacts a frame
+ late to an acceleration.
+- A scrolling surface's release (paging, a wheel) is 0.25 times the newest
+ pan value plus 0.75 times the previous one: a pan's `scrollVelocity()`.
+ Everything else reads `velocity()`.
+
+A release reads 0 when the finger stayed still for longer than the stillness
+interval (`UIKIT_GESTURES.velocity.stillness`) before lifting, the one
+difference from iOS, whose filter never decays and so flings after a rest;
+and 0 with fewer than two moved samples. The pan passes its lift's event time
+to the tracker.
+
+Times are the events' own. A finger the browser reports on both pointer and
+touch events is sampled from its pointer events alone (from its touch events
+once the browser cancels its pointer), so the two copies of one move never
+both count.
+
+## Haptics
+
+`playHaptic(kind)` vibrates where `navigator.vibrate` exists, with one
+pattern per UIKit feedback kind. iOS Safari has no vibration API. It plays a
+light impact only when a real tap toggles an ` `, and WebKit requires a trusted event for it. `attachTapHaptic(label)`
+puts a hidden switch inside a label the control renders as its tap target,
+so every real tap on it plays that impact; focus the activation gives the
+switch goes straight back. A label tied to its control by `for`, or whose
+control is itself a switch, gets no switch; a label wrapping its own control
+gets one, which hands each tap on to that control. Nothing can play a haptic later
+from script, so mid-drag haptics (detent snaps, picker ticks) are not
+possible in iOS Safari.
+
+## Thresholds
+
+All numbers live in `../motion/uikit/gestures.ts`. Each one carries either
+its source (Apple documentation, or a measurement: the recognizers' own
+values read in a UIKit app on the iOS 26.2 simulator and confirmed with
+touches, or Mobile Safari measured on the same simulator) or a guess
+together with the measurement that will replace it. A unit test fails if an
+entry has neither.
+
+## Touch presentation
+
+Whether a component presents itself the touch (UIKit) way is answered once,
+in `../utils/touchPresentation.ts`:
+
+- touch behaviours (this system, the press model, UIKit springs and
+ timings) key on a coarse PRIMARY pointer (`TOUCH_QUERY`); gestures
+ themselves attach for every pointer (What the browser is told);
+- layout behaviours key on a compact viewport, narrower than the theme's
+ `md` width point or shorter than 500 px (`COMPACT_QUERY`; behaviour reads
+ the active theme's points), the closest a size can get to
+ UIKit's compact size class (a Max-size iPhone in landscape is its one
+ known miss);
+- a presentation that needs both, a sheet in place of a popover, asks for
+ both (`COMPACT_TOUCH_QUERY`).
+
+Styles switch on the same conditions by writing the same query as a StyleX
+media key (`'@media (pointer: coarse)'`, and the compact-touch query spelled
+exactly as `COMPACT_TOUCH_QUERY`, which a test enforces), so the first paint
+is right. Behaviour reads
+`useTouchPresentation()` (SSR-safe), a component with no interaction to
+infer from included (UIKit timings under a finger, its own motion under a
+mouse), and a decision taken once reads `readTouchPresentation()`.
+
+## Migrating a raw-touch drag
+
+A component that listens to touches itself moves onto the recognizers this
+way:
+
+1. No window or document touch listeners: attach a recognizer with
+ `useGestureRecognizer` and read `translation()` and `velocity()` per
+ state. The host owns the page-wide listeners.
+2. Pick the reading: a drag that coasts like a scroll view (a wheel, a
+ picker, a pager, a carousel) releases with the pan's `scrollVelocity()`;
+ anything else with `velocity()`. A one-way drag sets `direction`. The host derives
+ `touch-action`, so delete a hand-written one.
+3. Native scrolling: a sheet over its own list uses `scrollEdge`, not
+ `scrollTop` checks; who gets a drag is decided once, when it begins
+ (`spec:AST-094` FR14).
+4. A behavior that only watches the person's scrolling uses a watcher
+ (`claimsTouches: false`) and `useFingerScroll`.
+5. A scroll lock holds the page with `holdPageScroll`; no `preventDefault`
+ of its own.
+6. A drag limited to one finger sets `maximumNumberOfTouches: 1`. Before
+ it claims, a second finger fails it; once claimed, it keeps its finger
+ (`spec:AST-094` FR10). Delete a local pinch yield.
+7. The release hands `velocity()` (px/s, event time) to the animator.
+ Delete local estimators, sample buffers and time windows; a flick, fling
+ or commit threshold stays in the component, compared against it.
+8. A control whose hold acts (a slider's thumb, a stepper's hold-repeat)
+ passes `selectionLock: lockSelection` to its recognizer or press tracker
+ (`spec:AST-094` FR41); content drags keep selection.
+9. Presses use `usePressTracker`; `keepsTouch: true` only for a control that
+ tracks a held finger across its own area.
+10. Unit tests drive the gesture harness (`__tests__/harness.ts`) with
+ timestamped touches; interaction specs use real touch input, never
+ synthetic DOM events.
+
+## Known limits
+
+- **A scroll that begins on a recognizer waits for a busy main thread.** In
+ Mobile Safari (iOS 26.2, measured), a vertical scroll that starts on an
+ element with a non-passive `touchmove` listener waits until the current
+ JavaScript task ends; on a plain row, a pressable row or a tap-only
+ element it starts at once (within 50 ms) even during a 500 ms task.
+ Chromium documents the same rule for non-passive listeners. The listener
+ stays because nothing else keeps UIKit's arbitration: with a passive
+ listener, or one added during `touchstart` (which Safari ignores for that
+ touch), Safari hands every diagonal drag on a swipe row to the list's
+ scroll, 3:1 included. It is never worse than UIKit, where a busy main
+ thread stalls every scroll.
+- **Lists where every row has a horizontal recognizer** (swipe actions on
+ every row of a mail list): every scroll start waits on the current task in
+ Safari. Keep list screens' tasks short. A native horizontal `scroll-snap`
+ reveal inside the row would never wait, but it tracks with scroll physics
+ rather than UIKit's measured tracking spring; it is a possible opt-in, not
+ the default.
+- **Follow-up:** Chromium keeps 3:1 to 1.5:1 drags as swipes even with a
+ passive listener (its `touch-action` filter drops a `pan-y` scroll that
+ starts sideways), so a passive listener there would cost no parity. It
+ waits on a measured run on Android before any engine-specific choice.
+
+## Touches on moving elements
+
+iOS WebKit hit-tests an element that a compositor animation moves where
+the animation started: it keeps the transform it resolved when the
+animation went to the compositor, so event targets, `elementFromPoint` and
+`getBoundingClientRect` stay there for the whole curve, though the inline
+style holds the value the curve starts from and the computed style is live
+(iOS 26.2, measured; Chromium and Linux WebKit hit what is drawn). A finger on a settling sheet
+lands on whatever sat under the sheet's start (an upward release can hit
+the scrim and close the sheet). UIKit keeps an interruptible animation
+hit-testable where it is drawn (UIViewPropertyAnimator).
+
+A style write on the element makes WebKit resolve the transform again, to
+the value drawn at that moment, where it stays. So on iOS WebKit, at
+touch-down, the host has every element the animator reports as moving
+(`motion/presentation.ts`) resolved with one custom-property write, then
+asks the engine what is under the finger: the touch goes to what is drawn
+there, with z-order and `pointer-events` as the engine sees them. Press
+trackers take their bounds from `presentedRect`, the reported box moved by
+the motion since. A retargeted tap cancels the engine's click,
+which would hit the start position, and clicks what it touched; the
+click guard drops a late engine click, so the tap acts once. Safari honours a
+blocking `touchmove` only where it was registered before the touch began,
+on the engine's own target, which for a moving element is whatever sits
+under its start: so while an element a claiming recognizer lives in is
+moving, the document carries the blocking listener, and a scroll starting
+anywhere waits on the main thread only for that long. Not covered:
+scale and rotate (a zoom transition, a lifted preview is hit at its
+unscaled box) and CSS transitions or animations the animator did not start.
+
+This is an engine workaround (`spec:AST-013` FR6). Its review condition:
+re-measure on each iOS release, and remove the correction once iOS WebKit
+hit-tests a compositor-moved element where it is drawn. Its regression
+fixture is `__tests__/presentedHitTesting.test.ts`.
+
+## Where the web differs from UIKit
+
+- Once a recognizer owns a touch, the browser cannot take the scroll back
+ later in the same gesture. UIKit does not either: it decides between a
+ sheet and its list when the drag begins.
+- In Chromium a finger's first `touchmove` arrives only after about 16 px.
+ Movement below that comes from the finger's pointer events, but the
+ browser's own decision to scroll happens at that first `touchmove`.
+ Mobile Safari delivers `touchmove` from the first pixel.
+- A tap that holds a plain `` for a second or more gets no click in
+ Mobile Safari, while UIKit's tap allows 1.5 s. A tap recognizer on the
+ element follows UIKit.
+- iOS Safari's own back swipe at the left edge cannot be cancelled by the
+ page. A leading-edge pan works where that swipe has no history to go back
+ to.
+- `translation()` is in client CSS px. It is not converted into a rotated or
+ scaled ancestor's coordinate space.
+- A replayed click is untrusted. Anything that needs real user activation
+ inside the replay window (opening a file picker, for example) should not
+ sit under a multi-tap recognizer.
+
+## Tests
+
+- `__tests__/.test.ts`: each recognizer's state machine on
+ synthetic touches with explicit timestamps.
+- `__tests__/arena*.test.ts`: the conflict matrix, delivery to the view,
+ click dispositions, cancellation, and multiple fingers.
+- `__tests__/GestureHost.test.ts`: the DOM integration through real
+ listeners in jsdom.
+- `__tests__/VelocityTracker.test.ts`: the filter against the velocities
+ UIKit reported on recorded touch streams, a steady speed and a hard
+ acceleration.
+- `__tests__/haptics.test.ts`: vibration patterns and the hidden switch.
+- `__tests__/importGraph.test.ts`: a gesture-only component's static
+ imports (`useGestureRecognizer`, the pan recognizers, the host) reach
+ neither the press tracker nor haptics, which live behind
+ `usePressTracker` and `attachTapHaptic` / `playHaptic`.
+- `__tests__/useFingerScroll.test.tsx`: a finger's scroll until its
+ `scrollend`, the page through the document, and the ref's node followed.
+- `__tests__/browser/`: the browser proof on a phone-sized touch screen, in
+ Chromium (CDP touches with timestamps) and WebKit (its own touch input).
+ CI runs it in Chromium in the interaction lane
+ (`pnpm test:interaction --project chromium`, `playwright.interaction.config.ts`).
+ Run both engines with
+ `pnpm exec playwright test --config packages/core/src/gestures/__tests__/browser/playwright.config.ts`;
+ where WebKit cannot launch on the host, its project runs in the official
+ Playwright image (see the config header). The same fixture passed in
+ Mobile Safari on the iOS 26.2 simulator: claims, click suppression, the
+ scroll edge, pinch, and a long press at exactly 500 ms.
diff --git a/packages/core/src/gestures/RotationGestureRecognizer.ts b/packages/core/src/gestures/RotationGestureRecognizer.ts
new file mode 100644
index 0000000000000..7fd2930771321
--- /dev/null
+++ b/packages/core/src/gestures/RotationGestureRecognizer.ts
@@ -0,0 +1,129 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file RotationGestureRecognizer.ts
+ * @input Touches from a GestureArena
+ * @output Exports RotationGestureRecognizer (UIRotationGestureRecognizer)
+ * and its options
+ * @position Continuous two-finger recognizer of packages/core/src/gestures
+ *
+ * Tracks the first two fingers. Begins once the line between them has turned
+ * `hysteresis` radians. `rotation` is the angle turned since they landed,
+ * unwrapped (it keeps counting past half a turn), clockwise positive in
+ * screen coordinates as in UIKit; `velocity` is radians per second.
+ */
+
+import {UIKIT_ROTATION} from '../motion/uikit/gestures';
+import {angleBetween, centroid, unwrapAngle} from './geometry';
+import {
+ GestureRecognizer,
+ isActive,
+ type GestureRecognizerOptions,
+ type NativeTouchBehavior,
+} from './GestureRecognizer';
+import type {GestureEventContext, GestureTouch, Point} from './types';
+import {VelocityTracker} from './VelocityTracker';
+
+export interface RotationGestureRecognizerOptions extends GestureRecognizerOptions {
+ hysteresis?: number;
+}
+
+export class RotationGestureRecognizer extends GestureRecognizer {
+ hysteresis: number;
+ private initialAngle = 0;
+ private lastAngle = 0;
+ private rotationValue = 0;
+ private readonly tracker = new VelocityTracker();
+ /** The lift's event time, for the stillness reset. */
+ private liftTime: number | undefined;
+
+ constructor(options: RotationGestureRecognizerOptions = {}) {
+ super(options);
+ this.hysteresis = options.hysteresis ?? UIKIT_ROTATION.hysteresis.value;
+ }
+
+ /** Radians turned since the fingers landed (or since the last set). */
+ get rotation(): number {
+ return this.rotationValue;
+ }
+
+ set rotation(value: number) {
+ this.initialAngle = this.lastAngle - value;
+ this.rotationValue = value;
+ // The velocity needs nothing: the tracker samples the raw angle, which a
+ // re-base does not move.
+ }
+
+ /** Radians per second. */
+ get velocity(): number {
+ return this.tracker.velocity(this.liftTime).x;
+ }
+
+ focalPoint(): Point {
+ return centroid(this.touches.values());
+ }
+
+ /** Two fingers on this view are this recognizer's, not the browser's zoom. */
+ override get nativeTouchBehavior(): NativeTouchBehavior {
+ return {panX: true, panY: true, pinchZoom: false, doubleTapZoom: false};
+ }
+
+ protected override acceptsTouch(touch: GestureTouch): boolean {
+ return this.numberOfTouches < 2 && super.acceptsTouch(touch);
+ }
+
+ protected override touchesBegan(
+ _touches: ReadonlyArray,
+ event: GestureEventContext,
+ ): void {
+ if (this.numberOfTouches === 2) {
+ const [a, b] = this.trackedTouches;
+ this.initialAngle = angleBetween(a, b);
+ this.lastAngle = this.initialAngle;
+ this.rotationValue = 0;
+ this.tracker.reset();
+ // The tracker follows the unwrapped angle itself, so setting
+ // `rotation` (UIKit's way to read it incrementally) never moves the
+ // velocity.
+ this.tracker.add(this.lastAngle, 0, event.time);
+ }
+ }
+
+ protected override touchesMoved(
+ _touches: ReadonlyArray,
+ event: GestureEventContext,
+ ): void {
+ if (this.numberOfTouches < 2) {
+ return;
+ }
+ const [a, b] = this.trackedTouches;
+ this.lastAngle = unwrapAngle(angleBetween(a, b), this.lastAngle);
+ this.rotationValue = this.lastAngle - this.initialAngle;
+ // Every move of the fingers is a sample: an angle that holds while they
+ // move reads as no turn, so the velocity never goes stale.
+ this.tracker.add(this.lastAngle, 0, event.time, {isMotion: true});
+ if (this.state === 'possible') {
+ if (Math.abs(this.rotationValue) >= this.hysteresis) {
+ this.setState('began');
+ }
+ } else if (isActive(this.state)) {
+ this.setState('changed');
+ }
+ }
+
+ protected override touchesEnded(
+ _touches: ReadonlyArray,
+ event: GestureEventContext,
+ ): void {
+ this.liftTime = event.time;
+ this.setState(isActive(this.state) ? 'ended' : 'failed');
+ }
+
+ protected override reset(): void {
+ this.liftTime = undefined;
+ this.initialAngle = 0;
+ this.lastAngle = 0;
+ this.rotationValue = 0;
+ this.tracker.reset();
+ }
+}
diff --git a/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts b/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts
new file mode 100644
index 0000000000000..7a1cb582f59cc
--- /dev/null
+++ b/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts
@@ -0,0 +1,198 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file ScreenEdgePanGestureRecognizer.ts
+ * @input Touches from a GestureArena; the viewport size and writing
+ * direction from its environment
+ * @output Exports ScreenEdgePanGestureRecognizer
+ * (UIScreenEdgePanGestureRecognizer), its options and edge type
+ * @position Continuous recognizer of packages/core/src/gestures, a pan that
+ * only starts from a band at the edge of the viewport
+ *
+ * A touch that lands outside every requested band fails at once, so a
+ * recognizer that requires this one to fail is not held up by a touch in the
+ * middle of the screen. Inside a band, the pan begins on movement away from
+ * that edge. `leading` and `trailing` resolve against the view's writing
+ * direction when the touch lands: under RTL the leading edge is the right
+ * one, as the interactive pop gesture's is.
+ *
+ * Attach it to the edge zone: a thin element along the edge, no wider than
+ * `edgeRegion` across it. There a pan from one edge takes the axis across
+ * the edge from the browser, which has nothing to scroll in so thin a band,
+ * so a touch in the band decides at UIKit's 15 px screen-edge hysteresis. On
+ * any wider view, whose `touch-action` cannot claim per touch, it leaves the
+ * browser every touch behavior, so the author's scrolling there is untouched,
+ * and a touch in the band decides at FR19's 10 px like every other claim
+ * (spec:AST-094 FR18, DEC-22). A touch outside every band fails at once. A pan
+ * from several edges claims each axis only in the zone of the edges it watches
+ * on that axis.
+ */
+
+import {UIKIT_PAN, UIKIT_SCREEN_EDGE_PAN} from '../motion/uikit/gestures';
+import {ALL_NATIVE_TOUCH, type NativeTouchBehavior} from './GestureRecognizer';
+
+/**
+ * How far past `edgeRegion` a view still counts as the edge zone, px: layout
+ * snaps sizes to 1/64 px, so a strip authored at the region measures a hair
+ * wider; half a pixel takes that and nothing a pixel wider.
+ */
+const EDGE_ZONE_TOLERANCE = 0.5;
+import {
+ PanGestureRecognizer,
+ type PanGestureRecognizerOptions,
+} from './PanGestureRecognizer';
+import type {GestureEventContext, GestureTouch} from './types';
+
+export type ScreenEdge =
+ 'left' | 'right' | 'top' | 'bottom' | 'leading' | 'trailing';
+
+/**
+ * A screen-edge pan is a finger's: it takes no `allowedPointerTypes`
+ * (`spec:AST-094` FR43). An edge pan on web input is a record change first.
+ */
+export interface ScreenEdgePanGestureRecognizerOptions extends Omit<
+ PanGestureRecognizerOptions,
+ 'direction' | 'allowedPointerTypes'
+> {
+ /** The edges a touch may start from. @default ['leading'] */
+ edges?: ReadonlyArray;
+ /** Width of the band at each edge, px. */
+ edgeRegion?: number;
+}
+
+/** Whether a pan from `edge` runs along the horizontal axis. */
+function isAcrossHorizontal(edge: ScreenEdge): boolean {
+ return edge !== 'top' && edge !== 'bottom';
+}
+
+export class ScreenEdgePanGestureRecognizer extends PanGestureRecognizer {
+ edges: ReadonlyArray;
+ edgeRegion: number;
+ /** The physical edge the current gesture started from. */
+ activeEdge: 'left' | 'right' | 'top' | 'bottom' | null = null;
+ /** The hysteresis where the pan takes its axis (UIKit's 15 px). */
+ private readonly edgeHysteresis: number;
+
+ constructor(options: ScreenEdgePanGestureRecognizerOptions = {}) {
+ super({
+ maximumNumberOfTouches: 1,
+ hysteresis: UIKIT_SCREEN_EDGE_PAN.hysteresis.value,
+ ...options,
+ // A finger's only: under a mouse, a trackpad or a pen the screen-edge
+ // pan stays off (`spec:AST-094` FR43), whatever a caller passes.
+ allowedPointerTypes: ['touch'],
+ });
+ this.edges = options.edges ?? ['leading'];
+ this.edgeRegion =
+ options.edgeRegion ?? UIKIT_SCREEN_EDGE_PAN.edgeRegion.value;
+ this.edgeHysteresis = this.hysteresis;
+ }
+
+ /**
+ * The pan takes the axis across its edges only in their edge zone, a view
+ * no wider than `edgeRegion` across them; elsewhere it leaves the browser
+ * every touch behavior, so the author's scrolling is untouched
+ * (spec:AST-094 FR18). A pan from several edges takes each axis only in the
+ * zone of the edges it watches on that axis, never across a whole wider
+ * element.
+ */
+ override get nativeTouchBehavior(): NativeTouchBehavior {
+ const takesX = this.edges.some(isAcrossHorizontal) && this.isEdgeZone('x');
+ const takesY =
+ this.edges.some(edge => !isAcrossHorizontal(edge)) &&
+ this.isEdgeZone('y');
+ if (!takesX && !takesY) {
+ return ALL_NATIVE_TOUCH;
+ }
+ return {
+ panX: !takesX,
+ panY: !takesY,
+ pinchZoom: true,
+ doubleTapZoom: false,
+ };
+ }
+
+ protected override touchesBegan(
+ touches: ReadonlyArray,
+ event: GestureEventContext,
+ ): void {
+ if (this.numberOfTouches === touches.length) {
+ const edge = this.edgeAt(touches[0]);
+ if (edge == null) {
+ this.setState('failed');
+ return;
+ }
+ this.activeEdge = edge;
+ // On an element within the band, which takes the axis across the edge,
+ // a touch decides at UIKit's 15 px; on one that reaches beyond it,
+ // which leaves the browser that axis, at FR19's 10 px, before the
+ // browser commits to a scroll (spec:AST-094 FR18, DEC-22).
+ const offZone = !this.isEdgeZone(
+ edge === 'left' || edge === 'right' ? 'x' : 'y',
+ );
+ this.hysteresis = offZone
+ ? Math.min(this.edgeHysteresis, UIKIT_PAN.hysteresis.value)
+ : this.edgeHysteresis;
+ this.direction =
+ edge === 'left'
+ ? 'right'
+ : edge === 'right'
+ ? 'left'
+ : edge === 'top'
+ ? 'down'
+ : 'up';
+ }
+ super.touchesBegan(touches, event);
+ }
+
+ /**
+ * Whether the view is the edge zone: no wider than `edgeRegion` along the
+ * axis across its edge, within layout's rounding (a strip authored at the
+ * 40.2 px region lays out at 40.203 px, on 1/64 px). A view that cannot be
+ * measured is not.
+ */
+ private isEdgeZone(across: 'x' | 'y'): boolean {
+ const size = this.environment.viewSize?.(this.view);
+ if (size == null) {
+ return false;
+ }
+ const extent = across === 'x' ? size.width : size.height;
+ return extent > 0 && extent <= this.edgeRegion + EDGE_ZONE_TOLERANCE;
+ }
+
+ protected override reset(): void {
+ super.reset();
+ this.activeEdge = null;
+ }
+
+ private edgeAt(
+ touch: GestureTouch,
+ ): 'left' | 'right' | 'top' | 'bottom' | null {
+ const {width, height} = this.environment.viewportSize();
+ const isRtl = this.environment.isRtl(this.view);
+ for (const edge of this.edges) {
+ const physical =
+ edge === 'leading'
+ ? isRtl
+ ? 'right'
+ : 'left'
+ : edge === 'trailing'
+ ? isRtl
+ ? 'left'
+ : 'right'
+ : edge;
+ const inBand =
+ physical === 'left'
+ ? touch.startX <= this.edgeRegion
+ : physical === 'right'
+ ? touch.startX >= width - this.edgeRegion
+ : physical === 'top'
+ ? touch.startY <= this.edgeRegion
+ : touch.startY >= height - this.edgeRegion;
+ if (inBand) {
+ return physical;
+ }
+ }
+ return null;
+ }
+}
diff --git a/packages/core/src/gestures/SwipeGestureRecognizer.ts b/packages/core/src/gestures/SwipeGestureRecognizer.ts
new file mode 100644
index 0000000000000..d4b83bc072278
--- /dev/null
+++ b/packages/core/src/gestures/SwipeGestureRecognizer.ts
@@ -0,0 +1,193 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file SwipeGestureRecognizer.ts
+ * @input Touches from a GestureArena
+ * @output Exports SwipeGestureRecognizer (UISwipeGestureRecognizer), its
+ * options and direction type
+ * @position Discrete recognizer of packages/core/src/gestures
+ *
+ * Recognized WHILE the fingers move, as UIKit's is: as soon as the centroid
+ * has travelled `minimumDistance` along an allowed direction, within
+ * `maximumDuration` of touch-down, with off-axis travel no more than
+ * `maximumSecondaryMovement` px across it. Off-axis travel past that
+ * allowance, or the distance travelled the wrong way, rules a direction out;
+ * the swipe fails once every allowed direction is ruled out, as it does for
+ * too many fingers, the duration running out, and a lift before it was
+ * recognized.
+ */
+
+import {UIKIT_SWIPE} from '../motion/uikit/gestures';
+import {centroid} from './geometry';
+import {
+ GestureRecognizer,
+ type GestureRecognizerOptions,
+ type NativeTouchBehavior,
+} from './GestureRecognizer';
+import type {GestureEventContext, GestureTouch, Point} from './types';
+
+export type SwipeDirection = 'left' | 'right' | 'up' | 'down';
+
+export interface SwipeGestureRecognizerOptions extends GestureRecognizerOptions {
+ /** @default ['right'] */
+ direction?: SwipeDirection | ReadonlyArray