diff --git a/.changeset/animator-input-event-start.md b/.changeset/animator-input-event-start.md new file mode 100644 index 0000000000000..61d36fe357a2c --- /dev/null +++ b/.changeset/animator-input-event-start.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the touch animator takes `start: 'next-frame'` for a call made in an input event's own task (a key, a click, a pointer, a touch). Such a call from rest starts at the first frame after that task, in Chromium and in WebKit, so a later listener of the same event that holds the page delays the motion instead of letting it skip into its curve. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gestures-wheel.md b/.changeset/gestures-wheel.md new file mode 100644 index 0000000000000..755a7032488c0 --- /dev/null +++ b/.changeset/gestures-wheel.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a pan follows a trackpad's two-finger scroll where its owner allows it (`allowedScrollTypes: ['continuous']`, UIKit's `allowedScrollTypesMask`; none by default), along its own axis and after the scrollers under the pointer, and never a mouse wheel's notches, which scroll what is under the pointer; the screen-edge pan takes none; and a pinch takes a ctrl-wheel from a trackpad or a mouse (AST-094 FR43, DEC-24). A watcher pan never takes the wheel, and a wheel another handler already prevented stays the page's. Input the deltas cannot place, Shift+wheel included, reads as a mouse wheel; a trackpad scroll a pan follows keeps moving at the fingers' speed until their lift is read, ends there still moving, and the momentum never moves it. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/public-animator.md b/.changeset/public-animator.md new file mode 100644 index 0000000000000..378452818eeb3 --- /dev/null +++ b/.changeset/public-animator.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[feat] `createAnimator(element)` and its hook `useAnimator(ref)` animate one element with the motion Astryx's components use; the hook takes the element's ref, so a gesture hook can share the element. `animate` springs `x`, `y`, `scale`, `rotate` and `opacity` to a target on the state-change motion, `decay` coasts any of them from a release velocity, `set` jumps, `get` and `getVelocity` read, and `stop` halts; `animate` and `decay` return a promise. Reduce Motion is planned for every call. `createAnimator` is exported from the package root and `useAnimator` from `@astryxdesign/core/hooks`; the CLI's templates gain a playing-cards example of each. + +@vjeux diff --git a/.changeset/public-drag-to-snap-points.md b/.changeset/public-drag-to-snap-points.md new file mode 100644 index 0000000000000..f4b470e4573d3 --- /dev/null +++ b/.changeset/public-drag-to-snap-points.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[feat] `createDragToSnapPoints` and its hook `useDragToSnapPoints` let a drag move an element along one axis and settle it on snap points (px offsets from its resting position) at the release: the nearest one, the next one in its direction after a flick faster than `flickThreshold` (default 1008 px/s), or the one it started from when the release moves back. The settle continues the release velocity, `settleTo(index)` settles without a drag, and `onSettle(index)` reports where it landed. `createDragToSnapPoints` is exported from the package root and `useDragToSnapPoints` from `@astryxdesign/core/hooks`; the CLI's templates gain a playing-card example of each. + +@vjeux diff --git a/.changeset/public-pan-gesture.md b/.changeset/public-pan-gesture.md new file mode 100644 index 0000000000000..93593a77d1bb7 --- /dev/null +++ b/.changeset/public-pan-gesture.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[feature] `createPanGesture(element, {axis?, onPan})` and `usePanGesture(ref, {axis?, onPan})` report a drag on an element as values: `onPan` gets `{state, translation, velocity}` as the drag begins, changes, ends at the release, or is cancelled, and a finger, a mouse and a pen all drive it. `axis` limits the drag to `'x'` or `'y'`; a drag along the other axis is not reported. `translation` counts from where the drag began, so an element that follows it does not jump, and `velocity` at `ended` is the release velocity in pixels per second. The drag claims its touch at UIKit's 10 pt and leaves presses and long presses alone. `destroy()` removes every listener; the hook creates one pan for the element behind `ref` and destroys it at unmount. + +@vjeux diff --git a/apps/sandbox/scripts/next-export-routes.json b/apps/sandbox/scripts/next-export-routes.json index 2ba661f60202f..14a4b1f4e9ff7 100644 --- a/apps/sandbox/scripts/next-export-routes.json +++ b/apps/sandbox/scripts/next-export-routes.json @@ -61,6 +61,8 @@ "/templates/", "/templates/AlertDialogAsyncAction/", "/templates/AlertDialogDeleteConfirmation/", + "/templates/AnimatorPlayingCards/", + "/templates/AnimatorPlayingCardsVanilla/", "/templates/AppShellContentOnly/", "/templates/AppShellMobileHookUsage/", "/templates/AppShellShowcase/", @@ -292,6 +294,8 @@ "/templates/DividerShowcase/", "/templates/DividerVariants/", "/templates/DividerVertical/", + "/templates/DragToSnapPointsCard/", + "/templates/DragToSnapPointsCardVanilla/", "/templates/DrawerForm/", "/templates/DrawerHeaderShowcase/", "/templates/DrawerHeaderSubtitleEndContent/", @@ -464,6 +468,8 @@ "/templates/PaginationPageSize/", "/templates/PaginationVariants/", "/templates/PaginationWithTable/", + "/templates/PanPlayingCard/", + "/templates/PanPlayingCardVanilla/", "/templates/PopoverBottomSheetAlternative/", "/templates/PopoverConfirmAction/", "/templates/PopoverFilterPanel/", diff --git a/internal/shadcn-registry/routes.lock.json b/internal/shadcn-registry/routes.lock.json index b09c556e1629f..14d60e78e9123 100644 --- a/internal/shadcn-registry/routes.lock.json +++ b/internal/shadcn-registry/routes.lock.json @@ -1959,6 +1959,21 @@ "path": "examples/context-menu/bottom-sheet", "aliases": [] }, + { + "name": "example-create-animator-playing-cards-in-plain-java-script", + "path": "examples/create-animator/playing-cards-in-plain-java-script", + "aliases": [] + }, + { + "name": "example-create-drag-to-snap-points-playing-card-in-plain-java-script", + "path": "examples/create-drag-to-snap-points/playing-card-in-plain-java-script", + "aliases": [] + }, + { + "name": "example-create-pan-gesture-a-playing-card-without-react", + "path": "examples/create-pan-gesture/a-playing-card-without-react", + "aliases": [] + }, { "name": "example-date-input-clearable", "path": "examples/date-input/clearable", @@ -3634,6 +3649,11 @@ "path": "examples/typeahead/with-validation", "aliases": [] }, + { + "name": "example-use-animator-playing-cards", + "path": "examples/use-animator/playing-cards", + "aliases": [] + }, { "name": "example-use-app-shell-mobile-custom-mobile-trigger", "path": "examples/use-app-shell-mobile/custom-mobile-trigger", @@ -3649,6 +3669,11 @@ "path": "examples/use-container-reveal/reveal-on-hover-row-actions", "aliases": [] }, + { + "name": "example-use-drag-to-snap-points-playing-card-on-three-places", + "path": "examples/use-drag-to-snap-points/playing-card-on-three-places", + "aliases": [] + }, { "name": "example-use-hover-card-profile-preview", "path": "examples/use-hover-card/profile-preview", @@ -3664,6 +3689,11 @@ "path": "examples/use-layer/anchored-layer", "aliases": [] }, + { + "name": "example-use-pan-gesture-a-playing-card", + "path": "examples/use-pan-gesture/a-playing-card", + "aliases": [] + }, { "name": "example-use-popover-quick-actions", "path": "examples/use-popover/quick-actions", @@ -3744,11 +3774,31 @@ "path": "examples/visually-hidden/supplementary-context", "aliases": [] }, + { + "name": "hook-create-animator", + "path": "hooks/create-animator", + "aliases": [] + }, + { + "name": "hook-create-drag-to-snap-points", + "path": "hooks/create-drag-to-snap-points", + "aliases": [] + }, + { + "name": "hook-create-pan-gesture", + "path": "hooks/create-pan-gesture", + "aliases": [] + }, { "name": "hook-use-animated-layout", "path": "hooks/use-animated-layout", "aliases": [] }, + { + "name": "hook-use-animator", + "path": "hooks/use-animator", + "aliases": [] + }, { "name": "hook-use-announce", "path": "hooks/use-announce", @@ -3794,6 +3844,11 @@ "path": "hooks/use-dev-warning", "aliases": [] }, + { + "name": "hook-use-drag-to-snap-points", + "path": "hooks/use-drag-to-snap-points", + "aliases": [] + }, { "name": "hook-use-entry-animation", "path": "hooks/use-entry-animation", @@ -3904,6 +3959,11 @@ "path": "hooks/use-overflow", "aliases": [] }, + { + "name": "hook-use-pan-gesture", + "path": "hooks/use-pan-gesture", + "aliases": [] + }, { "name": "hook-use-popover", "path": "hooks/use-popover", diff --git a/packages/cli/api/template/pan-playing-card.test.tsx b/packages/cli/api/template/pan-playing-card.test.tsx new file mode 100644 index 0000000000000..6ea9e6875d833 --- /dev/null +++ b/packages/cli/api/template/pan-playing-card.test.tsx @@ -0,0 +1,76 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// @vitest-environment jsdom + +/** + * @file Behavior test for the two playing-card pan blocks. + * @input The shipped PanPlayingCard (usePanGesture) and PanPlayingCardVanilla + * (createPanGesture) blocks, rendered in jsdom with the pan itself stubbed. + * @output Guards the keyboard path: a second Discard while the first throw + * still runs deals one card, not two, in both blocks. + * @position CLI template regression coverage; runs in the node test project, + * under jsdom for this file. The pan is stubbed because the Discard button + * is the drag's keyboard path and never goes through it. + */ + +import {act, createElement, type ComponentType} from 'react'; +import {createRoot} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import PanPlayingCardVanilla from '../../assets/templates/blocks/components/createPanGesture/PanPlayingCardVanilla'; +import PanPlayingCard from '../../assets/templates/blocks/components/usePanGesture/PanPlayingCard'; + +vi.mock('@astryxdesign/core/hooks', () => ({usePanGesture: () => {}})); +vi.mock('@astryxdesign/core', () => ({ + createPanGesture: () => ({destroy: () => {}}), +})); + +const reactActEnvironment = globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}; + +beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + // Motion allowed: a discard throws the card and deals at its transitionend. + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })) as unknown as typeof window.matchMedia; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('the playing-card pan blocks', () => { + for (const [name, Block] of [ + ['usePanGesture', PanPlayingCard], + ['createPanGesture', PanPlayingCardVanilla], + ] as ReadonlyArray<[string, ComponentType]>) { + it(`deals one card for a second Discard pressed while the first throw runs (${name})`, async () => { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render(createElement(Block)); + }); + const button = container.querySelector('button'); + const card = container.querySelector('[aria-label="Top card"]'); + if (button == null || card == null) { + throw new Error('the block renders no Discard button or top card'); + } + expect(card.textContent).toBe('A\u2660'); + await act(async () => { + button.click(); + button.click(); + }); + // The throw's two transitions end one after the other. + await act(async () => { + card.dispatchEvent(new Event('transitionend')); + card.dispatchEvent(new Event('transitionend')); + }); + expect(card.textContent).toBe('K\u2665'); + await act(async () => { + root.unmount(); + }); + }); + } +}); diff --git a/packages/cli/assets/templates/blocks/components/createAnimator/AnimatorPlayingCardsVanilla.doc.mjs b/packages/cli/assets/templates/blocks/components/createAnimator/AnimatorPlayingCardsVanilla.doc.mjs new file mode 100644 index 0000000000000..2573e34d272c7 --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/createAnimator/AnimatorPlayingCardsVanilla.doc.mjs @@ -0,0 +1,14 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */ +export const doc = { + type: 'block', + exampleFor: 'createAnimator', + name: 'createAnimator — Playing cards in plain JavaScript', + displayName: 'createAnimator — Playing cards in plain JavaScript', + description: + 'The playing card from the useAnimator example, built in plain JavaScript with createAnimator and pointer events: it follows a drag, springs back on a gentle release, flies off with momentum on a quick flick, and a button discards it from the keyboard. React only mounts it.', + isReady: true, + aspectRatio: 4 / 3, + componentsUsed: [], +}; diff --git a/packages/cli/assets/templates/blocks/components/createAnimator/AnimatorPlayingCardsVanilla.tsx b/packages/cli/assets/templates/blocks/components/createAnimator/AnimatorPlayingCardsVanilla.tsx new file mode 100644 index 0000000000000..89c46ac0f183b --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/createAnimator/AnimatorPlayingCardsVanilla.tsx @@ -0,0 +1,153 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import {useEffect, useRef} from 'react'; +import {createAnimator} from '@astryxdesign/core'; + +const DECK = ['A\u2660', 'K\u2665', 'Q\u2663', 'J\u2666']; +/** A release faster than this (px/s) throws the card away. */ +const THROW_SPEED = 1000; +/** The slowest throw, so a thrown card always leaves the table. */ +const MIN_THROW = 1600; + +/** + * A deck on a table, in plain JavaScript: no React below this line. It + * returns a function that takes it down again. + */ +function mountDeck(table: HTMLElement, discardButton: HTMLButtonElement) { + let top = 0; + const card = document.createElement('div'); + card.textContent = DECK[0]; + card.setAttribute('aria-label', 'Top card'); + Object.assign(card.style, { + width: '120px', + height: '170px', + display: 'grid', + placeItems: 'center', + font: 'bold 40px system-ui, sans-serif', + // The theme's surface and text, so the rank reads in light and dark. + borderRadius: 'var(--radius-container)', + backgroundColor: 'var(--color-background-surface)', + color: 'var(--color-text-primary)', + boxShadow: '0 2px 8px var(--color-shadow)', + touchAction: 'none', + cursor: 'grab', + userSelect: 'none', + }); + // Announces each discard to screen readers. + const status = document.createElement('div'); + status.setAttribute('aria-live', 'polite'); + Object.assign(status.style, { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + }); + table.append(card, status); + const animator = createAnimator(card); + + // Announces the discard and deals the next card in place of the old one. + async function dealNext() { + status.textContent = `${DECK[top % DECK.length]} discarded`; + top += 1; + card.textContent = DECK[top % DECK.length]; + animator.set({x: 0, y: 0, rotate: 0, scale: 0.9, opacity: 0}); + await animator.animate({scale: 1, opacity: 1}); + } + + async function discard(vx: number, vy: number) { + const speed = Math.hypot(vx, vy); + const boost = speed < MIN_THROW ? MIN_THROW / Math.max(speed, 1) : 1; + // The card coasts on while it fades; once it is gone, the next card is + // dealt in place, which ends the coast. A coast continues the person's + // throw, so Reduce Motion keeps it. + void animator.decay({x: speed < 1 ? MIN_THROW : vx * boost, y: vy * boost}); + await animator.animate({opacity: 0}); + await dealNext(); + } + + // The drag: follow the pointer, then read the 80 ms before the release, so + // a pointer that held still before letting go releases at rest. + let origin: {x: number; y: number} | null = null; + let samples: {t: number; x: number; y: number}[] = []; + function onDown(event: PointerEvent) { + card.setPointerCapture(event.pointerId); + animator.stop(); + const {x, y} = animator.get(); + origin = {x: event.clientX - x, y: event.clientY - y}; + samples = []; + } + function onMove(event: PointerEvent) { + if (origin == null) { + return; + } + const x = event.clientX - origin.x; + const y = event.clientY - origin.y; + samples.push({t: event.timeStamp, x, y}); + animator.set({x, y, rotate: x / 18}); + } + function onUp(event: PointerEvent) { + if (origin == null) { + return; + } + origin = null; + const recent = samples.filter(s => event.timeStamp - s.t <= 80); + const first = recent[0]; + const last = recent.at(-1); + const seconds = last && first ? (last.t - first.t) / 1000 : 0; + const vx = seconds > 0 && last && first ? (last.x - first.x) / seconds : 0; + const vy = seconds > 0 && last && first ? (last.y - first.y) / seconds : 0; + if (Math.abs(vx) > THROW_SPEED) { + void discard(vx, vy); + } else { + void animator.animate( + {x: 0, y: 0, rotate: 0}, + {velocity: {x: vx, y: vy}}, + ); + } + } + // The button moves the card off on the state-change motion: a planned + // move, which Reduce Motion turns into a fade where the card is. + async function discardWithButton() { + await animator.animate({x: 400, opacity: 0}); + await dealNext(); + } + const onDiscard = () => void discardWithButton(); + + card.addEventListener('pointerdown', onDown); + card.addEventListener('pointermove', onMove); + card.addEventListener('pointerup', onUp); + card.addEventListener('pointercancel', onUp); + discardButton.addEventListener('click', onDiscard); + return () => { + discardButton.removeEventListener('click', onDiscard); + animator.destroy(); + card.remove(); + status.remove(); + }; +} + +export default function AnimatorPlayingCardsVanilla() { + const table = useRef(null); + const button = useRef(null); + useEffect(() => { + if (table.current == null || button.current == null) { + return undefined; + } + return mountDeck(table.current, button.current); + }, []); + return ( +
+
+ +
+ ); +} diff --git a/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.doc.mjs b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.doc.mjs new file mode 100644 index 0000000000000..cb6b6445eaaf8 --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.doc.mjs @@ -0,0 +1,14 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */ +export const doc = { + type: 'block', + exampleFor: 'createDragToSnapPoints', + name: 'createDragToSnapPoints — Playing card in plain JavaScript', + displayName: 'createDragToSnapPoints — Playing card in plain JavaScript', + description: + 'The three-place playing card from the useDragToSnapPoints example, built in plain JavaScript with createDragToSnapPoints: a sideways drag settles it on the nearest place or the next after a flick, buttons settle it from the keyboard, and a live region announces the place. React only mounts it.', + isReady: true, + aspectRatio: 4 / 3, + componentsUsed: [], +}; diff --git a/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.tsx b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.tsx new file mode 100644 index 0000000000000..dfb36f4728a7b --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/createDragToSnapPoints/DragToSnapPointsCardVanilla.tsx @@ -0,0 +1,92 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import {useEffect, useRef} from 'react'; +import {createDragToSnapPoints} from '@astryxdesign/core'; + +const PLACES = ['Hand', 'Table', 'Discard pile']; + +/** + * A card that snaps between three places, in plain JavaScript: no React + * below this line. It returns a function that takes it down again. + */ +function mountCard(table: HTMLElement, buttons: HTMLElement) { + const card = document.createElement('div'); + card.textContent = 'Q\u2665'; + Object.assign(card.style, { + width: '96px', + height: '136px', + display: 'grid', + placeItems: 'center', + font: 'bold 36px system-ui, sans-serif', + // The theme's surface and text, so the card reads in light and dark. + borderRadius: 'var(--radius-container)', + backgroundColor: 'var(--color-background-surface)', + color: 'var(--color-text-primary)', + boxShadow: '0 2px 8px var(--color-shadow)', + touchAction: 'none', + cursor: 'grab', + userSelect: 'none', + }); + table.append(card); + const status = document.createElement('div'); + status.setAttribute('aria-live', 'polite'); + Object.assign(status.style, {position: 'absolute', clip: 'rect(0 0 0 0)'}); + table.append(status); + + // The places run in reading order: leftward in a right-to-left layout, + // where the snap points are negative and so sort the other way round + // (design:bidirectionality DR13). + const steps = [0, 110, 220]; + const isRtl = getComputedStyle(table).direction === 'rtl'; + const placeAt = (index: number) => (isRtl ? steps.length - 1 - index : index); + const snap = createDragToSnapPoints(card, { + axis: 'x', + snapPoints: isRtl ? steps.map(step => -step) : steps, + onSettle: index => { + status.textContent = `The card is on the ${PLACES[placeAt(index)].toLowerCase()}.`; + }, + }); + const controls = PLACES.map((name, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = name; + button.addEventListener('click', () => snap.settleTo(placeAt(index))); + buttons.append(button); + return button; + }); + return () => { + snap.destroy(); + for (const button of controls) { + button.remove(); + } + card.remove(); + status.remove(); + }; +} + +export default function DragToSnapPointsCardVanilla() { + const table = useRef(null); + const buttons = useRef(null); + useEffect(() => { + if (table.current == null || buttons.current == null) { + return undefined; + } + return mountCard(table.current, buttons.current); + }, []); + return ( +
+
+
+
+ ); +} diff --git a/packages/cli/assets/templates/blocks/components/createPanGesture/PanPlayingCardVanilla.doc.mjs b/packages/cli/assets/templates/blocks/components/createPanGesture/PanPlayingCardVanilla.doc.mjs new file mode 100644 index 0000000000000..ab9e7dc478372 --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/createPanGesture/PanPlayingCardVanilla.doc.mjs @@ -0,0 +1,14 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */ +export const doc = { + type: 'block', + exampleFor: 'createPanGesture', + name: 'createPanGesture — A playing card without React', + displayName: 'createPanGesture — A playing card without React', + description: + 'A playing card built in plain JavaScript with createPanGesture: it follows a drag from a finger, a mouse or a pen, snaps back on a gentle release, flies off on a quick flick, and a button discards it from the keyboard. React only mounts it.', + isReady: true, + aspectRatio: 4 / 3, + componentsUsed: [], +}; diff --git a/packages/cli/assets/templates/blocks/components/createPanGesture/PanPlayingCardVanilla.tsx b/packages/cli/assets/templates/blocks/components/createPanGesture/PanPlayingCardVanilla.tsx new file mode 100644 index 0000000000000..4a19aac8073e7 --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/createPanGesture/PanPlayingCardVanilla.tsx @@ -0,0 +1,132 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import {useEffect, useRef} from 'react'; +import {createPanGesture} from '@astryxdesign/core'; + +const DECK = ['A\u2660', 'K\u2665', 'Q\u2663', 'J\u2666']; +/** A release faster than this (px/s) throws the card away. */ +const THROW_SPEED = 1000; +/** How far a thrown card travels before the next one is dealt, px. */ +const THROW_DISTANCE = 480; +const SETTLE = 'translate 300ms cubic-bezier(0.2, 0.9, 0.3, 1.2)'; +const THROW = 'translate 250ms ease-in, opacity 250ms ease-in'; + +/** + * A deck on a table, in plain JavaScript: no React below this line. The pan + * reports the drag; the card's own style moves it. Returns a function that + * takes it down again. + */ +function mountDeck(table: HTMLElement, discardButton: HTMLButtonElement) { + let top = 0; + /** A throw is in flight: the next card is dealt when it ends. */ + let isThrowing = false; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + const card = document.createElement('div'); + card.textContent = DECK[0]; + card.setAttribute('aria-label', 'Top card'); + Object.assign(card.style, { + width: '120px', + height: '170px', + display: 'grid', + placeItems: 'center', + font: 'bold 40px system-ui, sans-serif', + borderRadius: '12px', + background: 'white', + boxShadow: '0 2px 8px rgb(0 0 0 / 0.2)', + cursor: 'grab', + userSelect: 'none', + }); + // Announces each discard to screen readers. + const status = document.createElement('div'); + status.setAttribute('aria-live', 'polite'); + Object.assign(status.style, { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + }); + table.append(card, status); + + /** Move the card, with a transition unless the person reduces motion. */ + function moveTo(x: number, y: number, transition: string) { + card.style.transition = reduceMotion.matches ? 'none' : transition; + card.style.translate = `${x}px ${y}px`; + } + + function discard(direction: number) { + // One throw at a time: a second Discard during it would deal twice. + if (isThrowing) { + return; + } + isThrowing = true; + moveTo(direction * THROW_DISTANCE, 0, THROW); + card.style.opacity = '0'; + const deal = () => { + isThrowing = false; + status.textContent = `${DECK[top % DECK.length]} discarded`; + top += 1; + card.textContent = DECK[top % DECK.length]; + moveTo(0, 0, 'none'); + card.style.opacity = '1'; + }; + if (reduceMotion.matches) { + deal(); + } else { + card.addEventListener('transitionend', deal, {once: true}); + } + } + + const pan = createPanGesture(card, { + onPan: ({state, translation, velocity}) => { + if (state === 'began' || state === 'changed') { + // Follows the finger: translation leaves out the travel the drag + // needed to begin, so the card does not jump. + moveTo(translation.x, translation.y, 'none'); + } else if (state === 'ended' && Math.abs(velocity.x) > THROW_SPEED) { + discard(Math.sign(velocity.x)); + } else { + moveTo(0, 0, SETTLE); + } + }, + }); + // The drag's keyboard and assistive-technology path (WCAG 2.5.7). + const onDiscard = () => discard(1); + discardButton.addEventListener('click', onDiscard); + return () => { + discardButton.removeEventListener('click', onDiscard); + pan.destroy(); + card.remove(); + status.remove(); + }; +} + +export default function PanPlayingCardVanilla() { + const table = useRef(null); + const button = useRef(null); + useEffect(() => { + if (table.current == null || button.current == null) { + return undefined; + } + return mountDeck(table.current, button.current); + }, []); + return ( +
+
+ +
+ ); +} diff --git a/packages/cli/assets/templates/blocks/components/useAnimator/AnimatorPlayingCards.doc.mjs b/packages/cli/assets/templates/blocks/components/useAnimator/AnimatorPlayingCards.doc.mjs new file mode 100644 index 0000000000000..bcb27e22f62ca --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/useAnimator/AnimatorPlayingCards.doc.mjs @@ -0,0 +1,21 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */ +export const doc = { + type: 'block', + exampleFor: 'useAnimator', + name: 'useAnimator — Playing cards', + displayName: 'useAnimator — Playing cards', + description: + 'A playing card that follows a drag: a gentle release springs it back on its release velocity, and a quick flick throws it away with momentum before the next card is dealt. A Discard button moves it off from the keyboard, fading it in place under Reduce Motion, and the discard is announced. Built only on the public useAnimator hook.', + isReady: true, + aspectRatio: 4 / 3, + componentsUsed: [ + 'Card', + 'Button', + 'Layout', + 'Text', + 'VisuallyHidden', + 'useAnimator', + ], +}; diff --git a/packages/cli/assets/templates/blocks/components/useAnimator/AnimatorPlayingCards.tsx b/packages/cli/assets/templates/blocks/components/useAnimator/AnimatorPlayingCards.tsx new file mode 100644 index 0000000000000..b3d52ed96bc5d --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/useAnimator/AnimatorPlayingCards.tsx @@ -0,0 +1,172 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import {useRef, useState} from 'react'; +import type {PointerEvent} from 'react'; +import {Button} from '@astryxdesign/core/Button'; +import {Card} from '@astryxdesign/core/Card'; +import {useAnimator} from '@astryxdesign/core/hooks'; +import {HStack, VStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; +import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden'; + +const DECK = [ + {rank: 'A', suit: '\u2660', name: 'Ace of spades'}, + {rank: 'K', suit: '\u2665', name: 'King of hearts'}, + {rank: 'Q', suit: '\u2663', name: 'Queen of clubs'}, + {rank: 'J', suit: '\u2666', name: 'Jack of diamonds'}, +]; + +/** A release faster than this (px/s) throws the card away. */ +const THROW_SPEED = 1000; +/** The slowest throw, so a thrown card always leaves the table. */ +const MIN_THROW = 1600; + +interface Sample { + t: number; + x: number; + y: number; +} + +/** + * The release velocity in px/s, over the 80 ms before the release: a finger + * that held still before letting go releases at rest. + */ +function releaseVelocity( + samples: Sample[], + releasedAt: number, +): {x: number; y: number} { + const recent = samples.filter(s => releasedAt - s.t <= 80); + const first = recent[0]; + const last = recent.at(-1); + if (last == null || first == null || last.t === first.t) { + return {x: 0, y: 0}; + } + const seconds = (last.t - first.t) / 1000; + return {x: (last.x - first.x) / seconds, y: (last.y - first.y) / seconds}; +} + +export default function AnimatorPlayingCards() { + const ref = useRef(null); + const animator = useAnimator(ref); + const [top, setTop] = useState(0); + const [status, setStatus] = useState(''); + const drag = useRef<{start: Sample; samples: Sample[]} | null>(null); + const card = DECK[top % DECK.length]; + + // Announces the discard and deals the next card in place of the old one. + const dealNext = async () => { + setStatus(`${card.name} discarded`); + setTop(index => index + 1); + animator.set({x: 0, y: 0, rotate: 0, scale: 0.9, opacity: 0}); + await animator.animate({scale: 1, opacity: 1}); + }; + + // Throws the card away on its release velocity, then deals the next one. + const discard = async (velocity: {x: number; y: number}) => { + const speed = Math.hypot(velocity.x, velocity.y); + const boost = speed < MIN_THROW ? MIN_THROW / Math.max(speed, 1) : 1; + const throwX = speed < 1 ? MIN_THROW : velocity.x * boost; + // The card coasts on while it fades; once it is gone, the next card is + // dealt in place, which ends the coast. A coast continues the person's + // throw, so Reduce Motion keeps it. + void animator.decay({x: throwX, y: velocity.y * boost}); + await animator.animate({opacity: 0}); + await dealNext(); + }; + + // The button moves the card off on the state-change motion: a planned + // move, which Reduce Motion turns into a fade where the card is. + const discardWithButton = async () => { + await animator.animate({x: 400, opacity: 0}); + await dealNext(); + }; + + const onPointerDown = (event: PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + animator.stop(); + const {x, y} = animator.get(); + const start = { + t: event.timeStamp, + x: event.clientX - x, + y: event.clientY - y, + }; + drag.current = {start, samples: []}; + }; + + const onPointerMove = (event: PointerEvent) => { + const current = drag.current; + if (current == null) { + return; + } + const x = event.clientX - current.start.x; + const y = event.clientY - current.start.y; + current.samples.push({t: event.timeStamp, x, y}); + // The card follows the finger, turning a little with the drag. + animator.set({x, y, rotate: x / 18}); + }; + + const onPointerUp = (event: PointerEvent) => { + const current = drag.current; + drag.current = null; + if (current == null) { + return; + } + const velocity = releaseVelocity(current.samples, event.timeStamp); + if (Math.abs(velocity.x) > THROW_SPEED) { + void discard(velocity); + } else { + // A gentle release springs back, carrying on as it was let go. + void animator.animate({x: 0, y: 0, rotate: 0}, {velocity}); + } + }; + + return ( + + + Drag the card and let go. A gentle release springs it back; a quick + flick throws it away. + +
+ + + + {card.rank} + + {card.suit} + + +
+ + +
+ {announcement} +
+
+ ); +} diff --git a/packages/cli/foundation/doc-compiler/inputs.test.mjs b/packages/cli/foundation/doc-compiler/inputs.test.mjs index 5673a5fa425d1..3983c29be346e 100644 --- a/packages/cli/foundation/doc-compiler/inputs.test.mjs +++ b/packages/cli/foundation/doc-compiler/inputs.test.mjs @@ -97,6 +97,9 @@ describe('collectDocInputs over what this repo ships', () => { 'NavMenu', 'Resizable', 'SyntaxTheme', + 'createAnimator', + 'createDragToSnapPoints', + 'createPanGesture', ]); }); diff --git a/packages/cli/test/__golden__/no-icon-set/search-icon-set.json b/packages/cli/test/__golden__/no-icon-set/search-icon-set.json index 1fd75d8e92fb1..6bf726fd1b97d 100644 --- a/packages/cli/test/__golden__/no-icon-set/search-icon-set.json +++ b/packages/cli/test/__golden__/no-icon-set/search-icon-set.json @@ -3,7 +3,7 @@ "type": "search", "data": { "query": "icon set", - "matchCount": 373, + "matchCount": 374, "results": [ { "domain": "doc", diff --git a/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt b/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt index 8267e31717d42..e51ca61ba754c 100644 --- a/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt +++ b/packages/cli/test/__golden__/no-icon-set/search-icon-set.txt @@ -1,4 +1,4 @@ -Results for "icon set" (20 of 373) +Results for "icon set" (20 of 374) name: authoring/building-blocks/templates/build-the-template/template-assets/template-icons section: use-astryx-icon domain: doc diff --git a/packages/core/src/animator/__tests__/animatorBundle.ts b/packages/core/src/animator/__tests__/animatorBundle.ts new file mode 100644 index 0000000000000..586ac9add91e9 --- /dev/null +++ b/packages/core/src/animator/__tests__/animatorBundle.ts @@ -0,0 +1,14 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file animatorBundle.ts + * @input The public animator and the math to check it against + * @output Re-exports them as one entry point + * @position The esbuild entry createAnimator.interaction.browser.spec.ts + * bundles into its page (global `PublicAnimator`), and that global's type + */ + +export {createAnimator} from '../createAnimator'; +export {solveDecay} from '../../motion/decay'; +export {solveSpring, toSpring} from '../../motion/spring'; +export {springs} from '../../motion/uikit/springs'; diff --git a/packages/core/src/animator/__tests__/createAnimator.interaction.browser.spec.ts b/packages/core/src/animator/__tests__/createAnimator.interaction.browser.spec.ts new file mode 100644 index 0000000000000..d7fa7c8b93084 --- /dev/null +++ b/packages/core/src/animator/__tests__/createAnimator.interaction.browser.spec.ts @@ -0,0 +1,222 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createAnimator.interaction.browser.spec.ts + * @input The public animator (createAnimator) bundled with esbuild onto a + * blank fixture page; Chromium and WebKit on the phone profile, and a + * desktop context for a fine pointer + * @output Proof of `spec:AST-107` FR3 in a real engine: `animate` plays the + * `springs.stateChange` curve on screen (seeked frame by frame) and rests on + * its target when its promise resolves; `decay` coasts to the analytic rest + * of the normal deceleration; `stop` holds the box where it is; under + * Reduce Motion a move cross-fades on a touch screen (the box jumps only + * while transparent) and lands at once with a mouse + * @position Browser proof for animator/createAnimator.ts, run by + * `pnpm test:interaction`; jsdom runs no animations + */ + +import {fileURLToPath} from 'node:url'; +import {expect, test, type Page} from '@playwright/test'; +import {build} from 'esbuild'; +import type * as Bundle from './animatorBundle'; +import type {Animator} from '../createAnimator'; + +declare global { + interface Window { + PublicAnimator: typeof Bundle; + card: Animator; + } +} + +let script = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [ + fileURLToPath(new URL('./animatorBundle.ts', import.meta.url)), + ], + bundle: true, + write: false, + format: 'iife', + globalName: 'PublicAnimator', + target: 'es2022', + }); + script = result.outputFiles[0].text; +}); + +const TRAVEL = 300; + +// A real navigation (not setContent): the page keeps its own window. +async function open(page: Page) { + const html = ` + +
+ +`; + await page.route('http://animator.test/', async route => + route.fulfill({contentType: 'text/html', body: html}), + ); + await page.goto('http://animator.test/'); +} + +/** The card's left edge on screen. */ +async function left(page: Page): Promise { + return page.evaluate( + () => + (document.getElementById('card') as HTMLElement).getBoundingClientRect() + .left, + ); +} + +test('animate plays the springs.stateChange curve on screen, seeked frame by frame', async ({ + page, +}) => { + await open(page); + const rows = await page.evaluate(async travel => { + void window.card.animate({x: travel}); + // Let the browser commit the motion, then hold it to seek. + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const animations = document.getAnimations(); + for (const animation of animations) { + animation.pause(); + } + const {solveSpring, toSpring, springs} = window.PublicAnimator; + const curve = solveSpring(toSpring(springs.stateChange), { + from: 0, + to: travel, + }); + const card = document.getElementById('card') as HTMLElement; + return [40, 120, 250, 400, 650].map(ms => { + for (const animation of animations) { + animation.currentTime = ms; + } + return { + ms, + onScreen: card.getBoundingClientRect().left, + expected: curve.value(ms / 1000), + }; + }); + }, TRAVEL); + for (const {ms, onScreen, expected} of rows) { + expect(Math.abs(onScreen - expected), `at ${ms} ms`).toBeLessThan(0.5); + } + // It moves: the samples are not all at one end. + expect(rows[0].onScreen).toBeLessThan(rows[rows.length - 1].onScreen); +}); + +test('animate resolves with the card resting on its target', async ({page}) => { + await open(page); + await page.evaluate(async travel => { + await window.card.animate({x: travel}); + }, TRAVEL); + expect(await left(page)).toBeCloseTo(TRAVEL, 0); + const values = await page.evaluate(() => window.card.get()); + expect(values.x).toBeCloseTo(TRAVEL, 3); +}); + +test('decay coasts to the rest of the normal deceleration', async ({page}) => { + await open(page); + const rest = await page.evaluate(async () => { + await window.card.decay({x: 900}); + return window.PublicAnimator.solveDecay({from: 0, velocity: 900}).rest; + }); + expect(Math.abs((await left(page)) - rest)).toBeLessThan(1); +}); + +test('stop holds the card where it is on screen', async ({page}) => { + await open(page); + const held = await page.evaluate(async travel => { + void window.card.animate({x: travel}); + await new Promise(resolve => setTimeout(resolve, 150)); + // A busy machine can draw no frame in that time, and nothing moves + // before one: wait for the frame that commits the move and the one after. + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + window.card.stop(); + return window.card.get().x; + }, TRAVEL); + expect(held).toBeGreaterThan(0); + expect(held).toBeLessThan(TRAVEL); + await page.waitForTimeout(400); + expect(Math.abs((await left(page)) - held)).toBeLessThan(0.5); +}); + +test('under Reduce Motion on a touch screen, a move cross-fades: the card jumps only while transparent', async ({ + page, +}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await open(page); + // Sampled every frame in real time: the travel lands with a style write + // at the bottom of the fade, which no seek of the running animations + // shows. A first cross-fade warms the page up (a fresh WebKit page skips + // frames for about 100 ms); the second, back home, is the one measured. + const frames = await page.evaluate(async travel => { + const card = document.getElementById('card') as HTMLElement; + await window.card.animate({x: travel}); + const samples: {left: number; opacity: number}[] = []; + let done = false; + void window.card.animate({x: 0}).then(() => { + done = true; + }); + while (!done && samples.length < 120) { + await new Promise(requestAnimationFrame); + samples.push({ + left: card.getBoundingClientRect().left, + opacity: Number(getComputedStyle(card).opacity), + }); + } + return samples; + }, TRAVEL); + // Never between the ends: the travel is substituted, not slowed. + for (const {left: x} of frames) { + expect(x === 0 || Math.abs(x - TRAVEL) < 0.5, `left ${x}`).toBe(true); + } + const jump = frames.findIndex(f => f.left === 0); + expect(jump).toBeGreaterThan(0); + // It fades out where it was, lands at the bottom of the fade, and fades + // back in where it landed: never shown moving, never brightening before + // it lands. + const before = frames.slice(0, jump).map(f => f.opacity); + const after = frames.slice(jump).map(f => f.opacity); + for (let i = 1; i < before.length; i++) { + expect(before[i]).toBeLessThanOrEqual(before[i - 1] + 0.01); + } + for (let i = 1; i < after.length; i++) { + expect(after[i]).toBeGreaterThanOrEqual(after[i - 1] - 0.01); + } + expect(Math.min(before[before.length - 1], after[0])).toBeLessThan(0.1); + expect(frames[frames.length - 1].opacity).toBeCloseTo(1, 2); +}); + +test('under Reduce Motion with a mouse, a move lands at once', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 800, height: 600}, + hasTouch: false, + isMobile: false, + reducedMotion: 'reduce', + }); + const page = await context.newPage(); + try { + await open(page); + const coarse = await page.evaluate( + () => matchMedia('(pointer: coarse)').matches, + ); + expect(coarse).toBe(false); + const sameTask = await page.evaluate(travel => { + void window.card.animate({x: travel}); + return ( + document.getElementById('card') as HTMLElement + ).getBoundingClientRect().left; + }, TRAVEL); + expect(sameTask).toBeCloseTo(TRAVEL, 0); + } finally { + await context.close(); + } +}); diff --git a/packages/core/src/animator/createAnimator.doc.mjs b/packages/core/src/animator/createAnimator.doc.mjs new file mode 100644 index 0000000000000..47c2bc05a320d --- /dev/null +++ b/packages/core/src/animator/createAnimator.doc.mjs @@ -0,0 +1,153 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').HookDoc} */ +export const docs = { + name: 'createAnimator', + displayName: 'createAnimator', + // A factory documented as a hook: listed with the utilities, beside its + // hook, not as a tile in the components gallery. + group: 'Utilities', + keywords: [ + 'animate', + 'animation', + 'animator', + 'spring', + 'decay', + 'momentum', + 'fling', + 'motion', + 'transform', + 'opacity', + 'vanilla', + 'factory', + ], + params: [ + { + name: 'element', + type: 'HTMLElement', + description: + 'The element to animate. The animator writes its `translate`, `scale`, `rotate` and `opacity`, and while it moves an inline `will-change` for them, merged with any it already has and restored 500 ms after its last motion; nothing else, so lay it out and style it as usual. Use one animator per element: two on one element overwrite each other.', + required: true, + }, + ], + returns: [ + { + name: 'animate', + type: '(target: {x?, y?, scale?, rotate?, opacity?}, options?: {velocity?}) => Promise', + description: + "Springs the given channels to `target` from wherever they are on screen, on the state-change motion Astryx's components use, and resolves when they arrive (or as soon as a later call takes them over). `x` and `y` are px, `scale` is 1 at the element's size, `rotate` is degrees and `opacity` runs from 0 to 1. Pass `velocity` (units per second, such as a drag's release velocity) to start with it; without it a channel keeps the velocity it has on screen, so interrupting a motion never jumps.", + }, + { + name: 'decay', + type: '(velocity: {x?, y?, scale?, rotate?, opacity?}) => Promise', + description: + "Coasts the given channels from a release velocity in their units per second, slowing as a scroll view's momentum does, and resolves when they stop (or as soon as a later call takes any of them over); opacity stops at 0 or 1. The distance follows from the velocity: use it to fling something away, then remove or reset it.", + }, + { + name: 'set', + type: '(values: {x?, y?, scale?, rotate?, opacity?}) => void', + description: + 'Puts the given channels at these values at once, ending their motion: follow a finger with it, frame by frame.', + }, + { + name: 'get', + type: '() => {x, y, scale, rotate, opacity}', + description: "Every channel's value on screen now.", + }, + { + name: 'getVelocity', + type: '() => {x, y, scale, rotate, opacity}', + description: + "Every channel's velocity on screen now, in units per second.", + }, + { + name: 'stop', + type: '() => void', + description: 'Halts every channel where it is on screen.', + }, + { + name: 'destroy', + type: '() => void', + description: + 'Stops every motion and lets the element go; the element keeps the values it shows. Call it when you remove the element or no longer animate it.', + }, + ], + usage: { + description: + "Animates one element with the motion Astryx's own components use, without React. `animate` springs an element to new values on the state-change motion, `decay` flings it with the momentum of a released scroll view, and `set` moves it directly, so a card can follow a finger, snap back and be thrown away. Use the `useAnimator` hook in React. To animate an area's layout as its items change, use `useAnimatedLayout`; for what a component already animates, such as a sheet's detents or a carousel's snapping, use the component's own props.", + bestPractices: [ + { + guidance: true, + description: + 'Start every `animate` that follows a drag with the release velocity, so the element carries on as it was thrown.', + }, + { + guidance: true, + description: + 'Reach every state an animation shows without the animation too: a key, a button or a menu item that calls the same code.', + }, + { + guidance: true, + description: + '`await` the promise before removing an element you animate out, and call `destroy` once it is gone.', + }, + { + guidance: false, + description: + 'Read the element\u2019s position from layout while it moves: `get` gives the value on screen, wherever the motion runs.', + }, + ], + accessibility: [ + { + name: 'Keyboard and assistive technology', + category: 'Keyboard', + description: + 'An animator moves an element; it adds no way to act. Give every action an animation stands for a control a keyboard and a screen reader can reach, such as a button that discards a card, and have it call `animate`, which Reduce Motion turns into a fade in place. Keep `decay` for a throw the person made: Reduce Motion keeps the momentum of a coast, as it continues that throw. Keep focus on an element that stays on screen: move it before you fling its element away.', + }, + { + name: 'Reduce Motion', + category: 'Content', + criterion: '2.3.3 Animation from Interactions', + description: + 'When the person asks for reduced motion, every call is planned for them. On a touch screen a move or a scale becomes a short opacity cross-fade: the element fades out where it was and fades back in where it lands. With a mouse it lands at once. A decay of `x` and `y` keeps its momentum, as it continues the throw the person made; a decay of `scale`, `rotate` or `opacity` is planned like a move. No option turns this off.', + }, + ], + }, + relatedHooks: ['useAnimator', 'useAnimatedLayout'], + importPath: '@astryxdesign/core', + category: 'interaction', +}; + +/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */ +export const docsDense = { + description: + "Animates one element (x, y, scale, rotate, opacity) with Astryx's motion, without React.", + paramDescriptions: { + element: 'The element to animate; one animator per element.', + }, + returnDescriptions: { + animate: + '(target, {velocity?}) => Promise; springs on the state-change motion.', + decay: + '({x?, y?, scale?, rotate?, opacity?}) => Promise; coasts from a release velocity.', + set: '(values) => void; jumps, ending motion.', + get: '() => every value on screen.', + getVelocity: '() => every velocity on screen.', + stop: '() => void; halts where it is.', + destroy: '() => void; lets the element go.', + }, + usage: { + description: + 'Spring with animate, fling with decay, follow a finger with set. Reduce Motion: a cross-fade on touch, at once with a mouse; decay keeps its momentum.', + bestPractices: [ + { + guidance: true, + description: 'Pass the release velocity to animate after a drag.', + }, + { + guidance: false, + description: 'Read layout for the position mid-motion; use get.', + }, + ], + }, +}; diff --git a/packages/core/src/animator/createAnimator.server.test.ts b/packages/core/src/animator/createAnimator.server.test.ts new file mode 100644 index 0000000000000..be450785ef19a --- /dev/null +++ b/packages/core/src/animator/createAnimator.server.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createAnimator.server.test.ts + * @input vitest; esbuild and a plain Node process (no window, no document) + * @output Checks `spec:AST-107` FR6: importing the factory or the hook does + * no DOM work, so both are safe on the server + * @position Testing; validates createAnimator.ts and hooks/useAnimator.ts + */ + +import {execFileSync} from 'node:child_process'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; + +/** Bundles `entry` and imports it in a fresh Node process; prints its export names. */ +function importOnServer(entry: string): string[] { + const out = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + [ + "import {build} from 'esbuild';", + `const result = await build({entryPoints: [${JSON.stringify(entry)}], bundle: true, write: false, format: 'esm', platform: 'node', logLevel: 'silent'});`, + "const source = Buffer.from(result.outputFiles[0].text).toString('base64');", + "if (typeof window !== 'undefined' || typeof document !== 'undefined') throw new Error('not a server');", + "const mod = await import('data:text/javascript;base64,' + source);", + 'process.stdout.write(JSON.stringify(Object.keys(mod).sort()));', + ].join('\n'), + ], + {cwd: __dirname, encoding: 'utf8'}, + ); + return JSON.parse(out) as string[]; +} + +describe('the public animator on the server', () => { + it('the factory imports with no window or document', () => { + // The module also hands the hook its construction; the package root + // re-exports `createAnimator` alone (publicSurface.test.ts). + expect(importOnServer(path.join(__dirname, 'createAnimator.ts'))).toEqual([ + 'createAnimator', + 'createAttachableAnimator', + ]); + }); + + it('the hook imports with no window or document', () => { + expect( + importOnServer(path.join(__dirname, '..', 'hooks', 'useAnimator.ts')), + ).toEqual(['useAnimator']); + }); +}); diff --git a/packages/core/src/animator/createAnimator.test.ts b/packages/core/src/animator/createAnimator.test.ts new file mode 100644 index 0000000000000..80720f184356c --- /dev/null +++ b/packages/core/src/animator/createAnimator.test.ts @@ -0,0 +1,518 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createAnimator.test.ts + * @input vitest (jsdom, fake clock and frames), the public animator, a + * recording stand-in for Element.animate (jsdom runs no animations), the + * spring and decay math to check the curves against + * @output Checks `spec:AST-107` FR3 and FR6 for the factory: every method and + * channel, `animate` on the `springs.stateChange` role with a given or + * on-screen velocity, `decay` on the normal deceleration, `set`, `get`, + * `getVelocity`, `stop`, `destroy`, the promises, Reduce Motion on a coarse + * and a fine pointer, and nothing internal on the handle + * @position Testing; the compositor run is proven in + * __tests__/createAnimator.interaction.browser.spec.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {solveDecay} from '../motion/decay'; +import {solveSpring, toSpring} from '../motion/spring'; +import {springs} from '../motion/uikit/springs'; +import { + createAnimator, + type AnimatorChannel, + type AnimatorValues, +} from './createAnimator'; + +interface Recorded { + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + startTime: number | null; + cancelled: boolean; + readonly playState: string; + ready: Promise; + finish(): void; + cancel(): void; +} + +let recorded: Recorded[] = []; +const FRAME_MS = 16; + +/** The person's settings, through matchMedia as the animator reads them. */ +function stubMedia({reduced = false, coarse = false} = {}) { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('prefers-reduced-motion') + ? reduced + : query.includes('pointer: coarse') + ? coarse + : false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +} + +beforeEach(() => { + recorded = []; + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'performance', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ], + }); + stubMedia(); + // Like a browser: a new animation starts at the next frame. + HTMLElement.prototype.animate = function (keyframes, options) { + let start: number | null = null; + let resolveReady: () => void = () => {}; + const animation: Recorded = { + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + get startTime() { + return start; + }, + set startTime(value) { + start = value; + resolveReady(); + }, + cancelled: false, + get playState() { + return this.cancelled ? 'idle' : 'running'; + }, + ready: new Promise(resolve => { + resolveReady = resolve; + }), + finish() {}, + cancel() { + this.cancelled = true; + }, + }; + requestAnimationFrame(() => { + if (start == null && !animation.cancelled) { + start = performance.now(); + resolveReady(); + } + }); + recorded.push(animation); + return animation as unknown as Animation; + }; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + vi.useRealTimers(); +}); + +async function advance(ms: number) { + await vi.advanceTimersByTimeAsync(ms); +} + +/** The time the browser started the latest animation, after its frame. */ +async function started(): Promise { + await advance(FRAME_MS); + const start = recorded.at(-1)?.startTime; + if (start == null) { + throw new Error('no animation started'); + } + return start; +} + +const STATE_CHANGE = toSpring(springs.stateChange); + +function element(): HTMLElement { + const box = document.createElement('div'); + document.body.append(box); + return box; +} + +describe('createAnimator', () => { + it('hands out the six methods and destroy, and nothing of the motion module beneath', () => { + const animator = createAnimator(element()); + expect(Object.keys(animator).sort()).toEqual( + [ + 'animate', + 'decay', + 'destroy', + 'get', + 'getVelocity', + 'set', + 'stop', + ].sort(), + ); + for (const method of Object.values(animator)) { + expect(typeof method).toBe('function'); + } + animator.destroy(); + }); + + it('reads every channel at rest: identity', () => { + const animator = createAnimator(element()); + expect(animator.get()).toEqual({ + x: 0, + y: 0, + scale: 1, + rotate: 0, + opacity: 1, + }); + expect(animator.getVelocity()).toEqual({ + x: 0, + y: 0, + scale: 0, + rotate: 0, + opacity: 0, + }); + }); + + it('animate plays the springs.stateChange role on each channel', async () => { + const box = element(); + const animator = createAnimator(box); + const target: Record = { + x: 120, + y: -80, + scale: 1.5, + rotate: 45, + opacity: 0.25, + }; + const from: Record = { + x: 0, + y: 0, + scale: 1, + rotate: 0, + opacity: 1, + }; + void animator.animate(target); + const start = await started(); + await advance(150); + const t = (performance.now() - start) / 1000; + const now = animator.get(); + for (const channel of Object.keys(target) as AnimatorChannel[]) { + const curve = solveSpring(STATE_CHANGE, { + from: from[channel], + to: target[channel], + }); + expect(now[channel]).toBeCloseTo(curve.value(t), 6); + expect(animator.getVelocity()[channel]).toBeCloseTo(curve.velocity(t), 4); + // Moving, not there yet. + expect(now[channel]).not.toBeCloseTo(target[channel], 2); + } + }); + + it('animate starts from a given velocity, and otherwise from the one on screen', async () => { + const animator = createAnimator(element()); + void animator.animate({x: 100}, {velocity: {x: 900}}); + const start = await started(); + await advance(100); + const t = (performance.now() - start) / 1000; + const withVelocity = solveSpring(STATE_CHANGE, { + from: 0, + to: 100, + velocity: 900, + }); + expect(animator.get().x).toBeCloseTo(withVelocity.value(t), 6); + + // Interrupted: the new spring keeps the velocity on screen. + const before = animator.getVelocity().x; + expect(before).not.toBe(0); + void animator.animate({x: 0}); + expect(animator.getVelocity().x).toBeCloseTo(before, 4); + }); + + it('animate resolves once the channels arrive, with the values at the target', async () => { + const animator = createAnimator(element()); + let done = false; + void animator.animate({x: 60, opacity: 0.5}).then(() => { + done = true; + }); + await started(); + await advance(200); + expect(done).toBe(false); + await advance(3000); + expect(done).toBe(true); + expect(animator.get().x).toBeCloseTo(60, 3); + expect(animator.get().opacity).toBeCloseTo(0.5, 3); + }); + + it('animate resolves early when a later call takes its channels over', async () => { + const animator = createAnimator(element()); + let done = false; + void animator.animate({x: 300}).then(() => { + done = true; + }); + await started(); + await advance(50); + animator.set({x: 10}); + await advance(0); + expect(done).toBe(true); + expect(animator.get().x).toBe(10); + }); + + it('a later animate or decay on a channel resolves its earlier promise where it stopped', async () => { + const animator = createAnimator(element()); + const settled: string[] = []; + void animator.animate({x: 300}).then(() => settled.push('first animate')); + await started(); + await advance(60); + const before = animator.get().x; + void animator.animate({x: -100}).then(() => settled.push('second animate')); + await advance(0); + expect(settled).toEqual(['first animate']); + // The second picks up where the first stopped on screen. + expect(animator.get().x).toBeCloseTo(before, 0); + await advance(40); + void animator.decay({x: 800}).then(() => settled.push('decay')); + await advance(0); + expect(settled).toEqual(['first animate', 'second animate']); + await advance(40); + void animator.animate({x: 0}); + await advance(0); + expect(settled).toEqual(['first animate', 'second animate', 'decay']); + }); + + it('decay coasts x and y on the normal deceleration from the velocity', async () => { + const animator = createAnimator(element()); + let done = false; + void animator.decay({x: 1500, y: -600}).then(() => { + done = true; + }); + const start = await started(); + await advance(250); + const t = (performance.now() - start) / 1000; + const x = solveDecay({from: 0, velocity: 1500}); + const y = solveDecay({from: 0, velocity: -600}); + expect(animator.get().x).toBeCloseTo(x.value(t), 4); + expect(animator.get().y).toBeCloseTo(y.value(t), 4); + expect(animator.getVelocity().x).toBeCloseTo(x.velocity(t), 2); + await advance(10_000); + expect(done).toBe(true); + // The rest is where the velocity alone carries it: no bounds. + expect(animator.get().x).toBeCloseTo(x.rest, 0); + expect(animator.get().y).toBeCloseTo(y.rest, 0); + }); + + it('decay coasts every channel it is given on the same momentum decay', async () => { + const animator = createAnimator(element()); + let done = false; + void animator.decay({scale: 0.8, rotate: 90}).then(() => { + done = true; + }); + const start = await started(); + await advance(250); + const t = (performance.now() - start) / 1000; + const scale = solveDecay({from: 1, velocity: 0.8}); + const rotate = solveDecay({from: 0, velocity: 90}); + expect(animator.get().scale).toBeCloseTo(scale.value(t), 4); + expect(animator.get().rotate).toBeCloseTo(rotate.value(t), 3); + await advance(10_000); + expect(done).toBe(true); + expect(animator.get().scale).toBeCloseTo(scale.rest, 3); + expect(animator.get().rotate).toBeCloseTo(rotate.rest, 1); + }); + + it('a decay that coasts x with rotate resolves as soon as a later call takes x over, as animate does (spec:AST-107 FR3)', async () => { + const animator = createAnimator(element()); + let done = false; + void animator.decay({x: 1200, rotate: 90}).then(() => { + done = true; + }); + await started(); + await advance(50); + // The rotate coast runs for seconds more; taking x over still ends the + // call where it stopped. + animator.set({x: 0}); + await advance(0); + expect(done).toBe(true); + expect(animator.get().x).toBe(0); + }); + + it('a decay that coasts x with rotate, left alone, resolves once both channels stop', async () => { + const animator = createAnimator(element()); + let done = false; + void animator.decay({x: 1200, rotate: 90}).then(() => { + done = true; + }); + await started(); + await advance(500); + expect(done).toBe(false); + await advance(10_000); + expect(done).toBe(true); + }); + + it('an opacity coast stops at 0 or 1', async () => { + const animator = createAnimator(element()); + animator.set({opacity: 0.5}); + void animator.decay({opacity: -5}); + await started(); + await advance(10_000); + expect(animator.get().opacity).toBe(0); + }); + + it('set puts values in place at once and writes them to the element', () => { + const box = element(); + const animator = createAnimator(box); + animator.set({x: 40, y: 8, scale: 0.5, rotate: 90, opacity: 0.3}); + expect(animator.get()).toEqual({ + x: 40, + y: 8, + scale: 0.5, + rotate: 90, + opacity: 0.3, + }); + expect(box.style.translate).toBe('40px 8px'); + expect(box.style.scale).toBe('0.5 0.5'); + expect(box.style.rotate).toBe('90deg'); + expect(box.style.opacity).toBe('0.3'); + }); + + it('a call leaves the channels it is not given where they are', async () => { + const animator = createAnimator(element()); + animator.set({y: 30, opacity: 0.5}); + void animator.animate({x: 100}); + await started(); + await advance(5000); + expect(animator.get()).toMatchObject({x: 100, y: 30, opacity: 0.5}); + }); + + it('stop halts every channel where it is on screen', async () => { + const animator = createAnimator(element()); + let done = false; + void animator.animate({x: 200, rotate: 30}).then(() => { + done = true; + }); + await started(); + await advance(120); + animator.stop(); + const held = animator.get(); + await advance(2000); + expect(animator.get()).toEqual(held); + expect(held.x).toBeGreaterThan(0); + expect(held.x).toBeLessThan(200); + expect(animator.getVelocity().x).toBe(0); + expect(done).toBe(true); + }); + + it('destroy stops everything; the animator does nothing after it', async () => { + const box = element(); + const animator = createAnimator(box); + void animator.animate({x: 200}); + await started(); + await advance(100); + animator.destroy(); + expect(recorded.every(animation => animation.cancelled)).toBe(true); + const count = recorded.length; + await expect(animator.animate({x: 0})).resolves.toBeUndefined(); + await expect(animator.decay({x: 500})).resolves.toBeUndefined(); + await expect(animator.decay({scale: 1})).resolves.toBeUndefined(); + animator.set({y: 50}); + expect(() => animator.stop()).not.toThrow(); + expect(() => animator.destroy()).not.toThrow(); + expect(recorded.length).toBe(count); + expect(animator.get().y).toBe(0); + expect(animator.getVelocity().x).toBe(0); + }); + + it('destroy removes every listener it added (spec:AST-107 FR6)', async () => { + const added = vi.spyOn(EventTarget.prototype, 'addEventListener'); + const removed = vi.spyOn(EventTarget.prototype, 'removeEventListener'); + try { + const animator = createAnimator(element()); + void animator.animate({x: 200, opacity: 0.5}); + await started(); + await advance(100); + animator.destroy(); + await advance(1000); + expect(removed.mock.calls.length).toBe(added.mock.calls.length); + } finally { + added.mockRestore(); + removed.mockRestore(); + } + }); + + it('a channel it does not know is ignored: no throw, and a call with nothing it knows resolves at once', async () => { + const box = element(); + const animator = createAnimator(box); + const unknown = {width: 200, x: 30} as unknown as AnimatorValues; + expect(() => animator.set(unknown)).not.toThrow(); + expect(animator.get()).toEqual({ + x: 30, + y: 0, + scale: 1, + rotate: 0, + opacity: 1, + }); + expect(box.style.width).toBe(''); + await expect( + animator.animate({height: 10} as unknown as AnimatorValues), + ).resolves.toBeUndefined(); + await expect( + animator.decay({height: 10} as unknown as AnimatorValues), + ).resolves.toBeUndefined(); + expect(recorded).toEqual([]); + }); + + it('an empty call resolves at once and moves nothing', async () => { + const animator = createAnimator(element()); + await expect(animator.animate({})).resolves.toBeUndefined(); + await expect(animator.decay({})).resolves.toBeUndefined(); + expect(recorded).toEqual([]); + }); + + describe('Reduce Motion (design:reduced-motion)', () => { + it('on a coarse pointer, a move becomes a cross-fade: the travel lands while transparent', async () => { + stubMedia({reduced: true, coarse: true}); + const animator = createAnimator(element()); + let done = false; + void animator.animate({x: 200}).then(() => { + done = true; + }); + await started(); + const samples: {x: number; opacity: number}[] = []; + for (let i = 0; i < 40; i++) { + await advance(FRAME_MS); + const {x, opacity} = animator.get(); + samples.push({x, opacity}); + } + // Never a value between the ends: the move is substituted, not slowed. + expect(samples.every(s => s.x === 0 || s.x === 200)).toBe(true); + // It fades: opacity dips below 1, and the jump happens out of sight. + expect(Math.min(...samples.map(s => s.opacity))).toBeLessThan(0.05); + const jump = samples.findIndex(s => s.x === 200); + expect(jump).toBeGreaterThan(0); + expect(samples[jump].opacity).toBeLessThan(0.05); + await advance(1000); + expect(done).toBe(true); + expect(animator.get()).toMatchObject({x: 200, opacity: 1}); + }); + + it('on a fine pointer, a move lands at once', async () => { + stubMedia({reduced: true, coarse: false}); + const animator = createAnimator(element()); + const finished = animator.animate({x: 200, scale: 2}); + expect(animator.get()).toMatchObject({x: 200, scale: 2}); + await advance(FRAME_MS); + await expect(finished).resolves.toBeUndefined(); + }); + + it('a release coast keeps its momentum: it continues the throw', async () => { + stubMedia({reduced: true, coarse: true}); + const animator = createAnimator(element()); + void animator.decay({x: 1200}); + const start = await started(); + await advance(200); + const t = (performance.now() - start) / 1000; + expect(animator.get().x).toBeCloseTo( + solveDecay({from: 0, velocity: 1200}).value(t), + 4, + ); + }); + }); +}); diff --git a/packages/core/src/animator/createAnimator.ts b/packages/core/src/animator/createAnimator.ts new file mode 100644 index 0000000000000..acfa41a11f48e --- /dev/null +++ b/packages/core/src/animator/createAnimator.ts @@ -0,0 +1,322 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file createAnimator.ts + * @input An element; the internal motion animator (motion/animator.ts), the + * `springs.stateChange` motion role and the momentum coast (motion/coast.ts) + * @output The public animator (`spec:AST-107` FR3, FR6): `createAnimator`, + * the `AnimatorHandle` it returns, and their value types + * @position Public entry point, re-exported from the package root. The + * `useAnimator` hook (hooks/useAnimator.ts) is a thin wrapper over it. + * + * A product animates one element with the motion Core uses. Every `animate` + * plays the `springs.stateChange` motion role (`spec:AST-094` FR4), `decay` + * coasts any channel on UIScrollView's normal deceleration (FR4: + * fixed), and Reduce Motion is planned for every call + * (`design:reduced-motion` DR1–DR3). The handle hands + * out values and these methods only; the motion module beneath it stays + * internal (`spec:AST-107` FR4). Importing this module does no DOM work, so + * it is safe on the server. + * + * SYNC: When modified, update: + * - /packages/core/src/animator/createAnimator.test.ts + * - /packages/core/src/animator/createAnimator.doc.mjs + * - /packages/core/src/hooks/useAnimator.ts + */ + +import { + createMotionAnimator, + SETTLE_THRESHOLD, + type Animator as MotionAnimator, + type MotionCurve, + type MotionProperty, + type MotionValues, +} from '../motion/animator'; +import {coast} from '../motion/coast'; +import {solveDecay} from '../motion/decay'; +import {registerSpringsTable, springs} from '../motion/uikit/springs'; + +/** + * A channel the animator drives: `x` and `y` translate the element (px), + * `scale` scales it about its transform origin (1 is its size), `rotate` + * turns it (degrees, clockwise) and `opacity` fades it (0 to 1). + */ +export type AnimatorChannel = 'x' | 'y' | 'scale' | 'rotate' | 'opacity'; + +/** + * Values by channel. A velocity uses the same keys, in units per second: + * px/s, scale per second, deg/s, opacity per second. + */ +export type AnimatorValues = Partial>; + +/** Every channel's value (or velocity), as `get` and `getVelocity` read it. */ +export type AnimatorSnapshot = Readonly>; + +/** Options of `AnimatorHandle.animate`. */ +export interface AnimatorAnimateOptions { + /** + * Each channel's velocity at the start, such as a drag's release velocity. + * A channel without one keeps the + * velocity it has on screen, so an `animate` that interrupts another + * carries on smoothly. + */ + velocity?: AnimatorValues; +} + +/** + * A release velocity for `AnimatorHandle.decay`, per channel, in its units + * per second: px/s for `x` and `y`, degrees per second for `rotate`, and + * per second for `scale` and `opacity`. + */ +export type AnimatorDecayVelocity = AnimatorValues; + +/** + * Animates one element. Each method acts on the channels it is given and + * leaves the others where they are. + */ +export interface AnimatorHandle { + /** + * Springs the given channels to `target` on the `springs.stateChange` + * motion role, from wherever they are on screen. The promise resolves when + * they arrive, or as soon as a later `animate`, `decay`, `set` or `stop` + * takes any of them over. + */ + animate( + target: AnimatorValues, + options?: AnimatorAnimateOptions, + ): Promise; + /** + * Coasts the given channels from a release velocity, slowing as a scroll + * view's momentum does, and resolves when they stop (or when another call + * takes them over). The distance follows from the velocity alone: there are + * no bounds, except that opacity stops at 0 or 1. + */ + decay(velocity: AnimatorDecayVelocity): Promise; + /** + * Puts the given channels at these values at once, ending their motion: + * the per-frame write of a drag. + */ + set(values: AnimatorValues): void; + /** Every channel's value on screen now. */ + get(): AnimatorSnapshot; + /** Every channel's velocity on screen now, in units per second. */ + getVelocity(): AnimatorSnapshot; + /** Halts every channel where it is on screen. */ + stop(): void; +} + +/** What `createAnimator` returns: the handle, and `destroy`. */ +export interface Animator extends AnimatorHandle { + /** + * Stops every motion and lets go of the element; the element keeps the + * values it shows. The animator does nothing after this. + */ + destroy(): void; +} + +const CHANNELS: ReadonlyArray = [ + 'x', + 'y', + 'scale', + 'rotate', + 'opacity', +]; + +/** The values of the channels the public API names, and nothing else. */ +function motionValues(values: AnimatorValues | undefined): MotionValues { + const out: MotionValues = {}; + if (values == null) { + return out; + } + for (const channel of CHANNELS) { + const value = values[channel]; + if (typeof value === 'number' && Number.isFinite(value)) { + out[channel] = value; + } + } + return out; +} + +/** + * A coast from `from` at `velocity` on the momentum decay a scroll view + * uses (`spec:AST-108` FR4: fixed, whatever the theme), ending at rest; an + * opacity coast stops at 0 or 1. + */ +function momentum( + from: number, + velocity: number, + threshold: number, + isUnit: boolean, +): MotionCurve { + const glide = solveDecay({from, velocity}); + const edge = !isUnit ? null : glide.rest < 0 ? 0 : glide.rest > 1 ? 1 : null; + const hit = edge == null ? null : glide.timeToReach(edge); + const duration = hit ?? glide.settleTime(threshold); + const end = edge ?? glide.rest; + return { + duration, + at: t => (t >= duration ? end : glide.value(t)), + velocity: t => (t >= duration ? 0 : glide.velocity(t)), + }; +} + +function snapshot( + read: (channel: AnimatorChannel) => number, +): AnimatorSnapshot { + return { + x: read('x'), + y: read('y'), + scale: read('scale'), + rotate: read('rotate'), + opacity: read('opacity'), + }; +} + +/** + * Resolves once every motion of one call has finished, or as soon as any of + * them is interrupted (`finished` resolves `false`): a later call that takes + * over one channel ends the whole call where it stopped, as one `animate` + * does (`spec:AST-107` FR3), while the other channels move on. + */ +async function settledOrTakenOver( + moving: ReadonlyArray>, +): Promise { + if (moving.length === 0) { + return; + } + await new Promise(resolve => { + let left = moving.length; + for (const finished of moving) { + void finished.then(completed => { + left -= 1; + if (!completed || left === 0) { + resolve(); + } + }); + } + }); +} + +/** + * An animator whose element is attached later, through `attach`: the one + * construction `createAnimator` and `useAnimator` share, so the hook has no + * behavior of its own (`spec:AST-107` FR6). Not exported from the package. + */ +export function createAttachableAnimator(): { + readonly animator: Animator; + readonly attach: (element: HTMLElement | null) => void; +} { + const motion: MotionAnimator = createMotionAnimator(null); + // Calls do nothing while no element is attached (before the ref attaches, + // or after it lets go) and after destroy; animate and decay resolve at once. + let element: HTMLElement | null = null; + let destroyed = false; + const inactive = () => destroyed || element == null; + const animator: Animator = { + async animate(target, options) { + const to = motionValues(target); + if (inactive() || Object.keys(to).length === 0) { + return; + } + // The role registry is written on first use, never on import. + registerSpringsTable(); + const velocity = motionValues(options?.velocity); + await motion.animate(to, { + spring: springs.stateChange, + ...(Object.keys(velocity).length > 0 ? {velocity} : {}), + }).finished; + }, + async decay(velocity) { + if (inactive()) { + return; + } + const given = motionValues(velocity); + // The translate axes coast as a throw, which Reduce Motion lets + // continue; the others coast as a transition, which it replaces + // (design:reduced-motion DR2). + const translate = coast(motion, {x: given.x, y: given.y}); + const others: Partial> = {}; + for (const channel of ['scale', 'rotate', 'opacity'] as const) { + const v0 = given[channel]; + if (v0 != null) { + const property = channel === 'scale' ? 'scaleX' : channel; + others[channel] = momentum( + motion.get(property), + v0, + SETTLE_THRESHOLD[property], + channel === 'opacity', + ); + } + } + const moving: Promise[] = []; + if (Object.keys(translate).length > 0) { + moving.push(motion.decay(translate).finished); + } + if (Object.keys(others).length > 0) { + moving.push(motion.play(others).finished); + } + await settledOrTakenOver(moving); + }, + set(values) { + if (!inactive()) { + motion.set(motionValues(values)); + } + }, + get() { + return snapshot(channel => motion.get(channel)); + }, + getVelocity() { + return snapshot(channel => motion.getVelocity(channel)); + }, + stop() { + if (!inactive()) { + motion.stop(); + } + }, + destroy() { + if (!destroyed) { + destroyed = true; + // Halts every channel where it shows, so the element keeps those + // values and nothing reads as moving, then lets go of it. + motion.stop(); + motion.dispose(); + } + }, + }; + return { + animator, + attach: next => { + if (!destroyed) { + element = next; + motion.attach(next); + } + }, + }; +} + +/** + * Animates `element` with the motion Core uses: `animate` springs channels + * to a target on the state-change motion, `decay` coasts them from a + * release velocity, `set` jumps them, `get` and `getVelocity` read them, + * `stop` halts them, and `destroy` lets the element go. Under Reduce Motion + * every call is planned (`design:reduced-motion`): a move becomes a short + * cross-fade on a touch screen and lands at once with a mouse. + * + * Use one animator per element: two on one element overwrite each other. + * + * @example + * ``` + * const card = document.querySelector('.card'); + * const animator = createAnimator(card); + * await animator.animate({x: 0, rotate: 0}, {velocity: {x: -900}}); + * animator.decay({x: 2400}); + * animator.destroy(); + * ``` + */ +export function createAnimator(element: HTMLElement): Animator { + const {animator, attach} = createAttachableAnimator(); + attach(element); + return animator; +} diff --git a/packages/core/src/animator/playingCardsTemplates.test.tsx b/packages/core/src/animator/playingCardsTemplates.test.tsx new file mode 100644 index 0000000000000..b6b0197cc764e --- /dev/null +++ b/packages/core/src/animator/playingCardsTemplates.test.tsx @@ -0,0 +1,184 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file playingCardsTemplates.test.tsx + * @input The CLI's two playing-card examples of the public animator (the + * `useAnimator` one and the plain-JavaScript `createAnimator` one), each + * rendered with a recording animator in place of the real one. + * @output Tests that a drag held still before its release springs back + * instead of being thrown, that a thrown card's successor is dealt and + * announced once the thrown card has faded, without waiting for its coast, + * and that the Discard button moves the card with `animate`, never `decay`, + * so Reduce Motion fades it in place (`design:reduced-motion` DR2). + * @position The examples ship to products through the CLI, so their release + * logic is checked here, beside the animator they demonstrate. + */ + +import type {ComponentType} from 'react'; +import {act, render, screen} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +// The examples live in the CLI's templates, outside Core's type-checked +// sources, so they load at run time; the CLI type-checks them itself. +const TEMPLATES = '../../../cli/assets/templates/blocks/components'; +async function loadExample(file: string): Promise { + const module = (await import(/* @vite-ignore */ `${TEMPLATES}/${file}`)) as { + default: ComponentType; + }; + return module.default; +} +const AnimatorPlayingCards = await loadExample( + 'useAnimator/AnimatorPlayingCards', +); +const AnimatorPlayingCardsVanilla = await loadExample( + 'createAnimator/AnimatorPlayingCardsVanilla', +); + +const recorder = vi.hoisted(() => { + const state = { + calls: [] as {method: string; values?: unknown}[], + finishFade: () => {}, + }; + const animator = { + animate: async (values: {opacity?: number}) => { + state.calls.push({method: 'animate', values}); + if (values.opacity === 0) { + await new Promise(resolve => { + state.finishFade = resolve; + }); + } + }, + // A thrown card coasts for seconds: this one never stops on its own. + decay: async (values: unknown) => { + state.calls.push({method: 'decay', values}); + await new Promise(() => {}); + }, + set: (values: unknown) => { + state.calls.push({method: 'set', values}); + }, + get: () => ({x: 0, y: 0, scale: 1, rotate: 0, opacity: 1}), + getVelocity: () => ({x: 0, y: 0, scale: 0, rotate: 0, opacity: 0}), + stop: () => {}, + destroy: () => {}, + }; + return {state, animator}; +}); + +vi.mock('@astryxdesign/core/hooks', async importOriginal => ({ + ...(await importOriginal()), + useAnimator: () => recorder.animator, +})); +vi.mock('@astryxdesign/core', async importOriginal => ({ + ...(await importOriginal()), + createAnimator: () => recorder.animator, +})); + +/** Dispatches a pointer event at a given time stamp (ms). */ +function pointer( + target: Element, + type: 'pointerdown' | 'pointermove' | 'pointerup', + x: number, + t: number, +) { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: x, + clientY: 0, + }); + Object.defineProperty(event, 'pointerId', {value: 1}); + Object.defineProperty(event, 'timeStamp', {value: t}); + act(() => { + target.dispatchEvent(event); + }); +} + +/** A 150 px drag over 100 ms, released at `releasedAt`. */ +function drag(card: Element, releasedAt: number) { + pointer(card, 'pointerdown', 0, 1000); + for (let step = 1; step <= 10; step++) { + pointer(card, 'pointermove', step * 15, 1000 + step * 10); + } + pointer(card, 'pointerup', 150, releasedAt); +} + +const methods = () => recorder.state.calls.map(call => call.method); + +const EXAMPLES = [ + { + name: 'the useAnimator example', + mount: () => render(), + card: () => screen.getByLabelText('Ace of spades'), + discardButton: 'Discard Ace of spades', + announced: 'Ace of spades discarded', + }, + { + name: 'the createAnimator example', + mount: () => render(), + card: () => screen.getByLabelText('Top card'), + discardButton: 'Discard the top card', + announced: 'A\u2660 discarded', + }, +]; + +describe.each(EXAMPLES)('$name', ({mount, card, discardButton, announced}) => { + beforeEach(() => { + recorder.state.calls = []; + HTMLElement.prototype.setPointerCapture = () => {}; + }); + afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'setPointerCapture'); + }); + + it('a drag held still for 600 ms before its release springs back, not thrown', () => { + mount(); + drag(card(), 1100 + 600); + expect(methods()).not.toContain('decay'); + expect(recorder.state.calls.at(-1)).toMatchObject({ + method: 'animate', + values: {x: 0, y: 0, rotate: 0}, + }); + }); + + it('a flick deals and announces the next card once the thrown one fades, during its coast', async () => { + mount(); + drag(card(), 1100); + expect(methods()).toContain('decay'); + expect(screen.queryByText(announced)).toBeNull(); + await act(async () => { + recorder.state.finishFade(); + }); + expect(screen.getByText(announced)).toBeTruthy(); + expect(recorder.state.calls).toContainEqual({ + method: 'set', + values: {x: 0, y: 0, rotate: 0, scale: 0.9, opacity: 0}, + }); + }); + + it('the Discard button moves the card off with animate, never decay, so Reduce Motion fades it in place (design:reduced-motion DR2)', async () => { + mount(); + act(() => { + screen.getByRole('button', {name: discardButton}).click(); + }); + // A coast keeps its momentum under Reduce Motion, as a person's throw; + // a button's move is planned, so it fades where it is. + expect(methods()).not.toContain('decay'); + expect(recorder.state.calls[0]).toEqual({ + method: 'animate', + values: {x: 400, opacity: 0}, + }); + await act(async () => { + recorder.state.finishFade(); + }); + expect(screen.getByText(announced)).toBeTruthy(); + }); +}); + +describe('the createAnimator example', () => { + it('colours its card from the theme\u2019s surface and text tokens, so it reads in a dark theme', () => { + render(); + const {style} = screen.getByLabelText('Top card'); + expect(style.backgroundColor).toBe('var(--color-background-surface)'); + expect(style.color).toBe('var(--color-text-primary)'); + }); +}); diff --git a/packages/core/src/animator/publicSurface.test.ts b/packages/core/src/animator/publicSurface.test.ts new file mode 100644 index 0000000000000..cb5cee6b4ab29 --- /dev/null +++ b/packages/core/src/animator/publicSurface.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file publicSurface.test.ts + * @input The package root, the hooks barrel, Core's package.json + * @output Checks `spec:AST-107` FR4 and FR6 for the animator: the package + * root hands out `createAnimator`, and only the root (no subpath of its + * own, one API with one entry), the hooks barrel hands out + * `useAnimator` and nothing of the motion module beneath, and no internal + * factory takes the public factory's name + * @position Public API test; enforced by Vitest and Core typecheck + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; +import * as root from '..'; +import * as hooks from '../hooks'; + +const SRC = path.join(__dirname, '..'); + +/** Names of the motion module beneath the public animator. */ +const INTERNAL = [ + 'createMotionAnimator', + 'createAttachableAnimator', + 'animatorFor', + 'coast', + 'resolveMotion', + 'registerSpringsTable', + 'springs', +]; + +describe('the public animator surface', () => { + it('the package root exports createAnimator, and nothing of the motion module', () => { + expect(typeof root.createAnimator).toBe('function'); + for (const name of INTERNAL) { + expect(root).not.toHaveProperty(name); + } + }); + + it('the hooks barrel exports useAnimator, and nothing of the motion module', () => { + expect(typeof hooks.useAnimator).toBe('function'); + for (const name of INTERNAL) { + expect(hooks).not.toHaveProperty(name); + } + }); + + it('the package root names createAnimator and re-exports no motion or gesture module whole', () => { + const source = fs.readFileSync(path.join(SRC, 'index.ts'), 'utf8'); + expect(source).toContain("from './animator/createAnimator';"); + // A public factory may be named from its module, as createPanGesture is + // from gestures/; the runtime case above keeps the internals out. + expect(source).not.toMatch(/export \* from '\.\/(motion|gestures)(\/|')/); + }); + + it('createAnimator ships from the package root only: no `./animator` subpath (one API, one entry)', () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(SRC, '..', 'package.json'), 'utf8'), + ) as {exports: Record}; + expect(Object.keys(pkg.exports)).not.toContain('./animator'); + expect(fs.existsSync(path.join(__dirname, 'index.ts'))).toBe(false); + }); + + it('no internal factory takes the public factory\u2019s name', () => { + const motion = fs.readFileSync( + path.join(SRC, 'motion', 'animator.ts'), + 'utf8', + ); + expect(motion).not.toMatch(/export function createAnimator\b/); + expect(motion).toMatch(/export function createMotionAnimator\b/); + }); +}); diff --git a/packages/core/src/dragToSnapPoints/__tests__/createDragToSnapPoints.interaction.browser.spec.ts b/packages/core/src/dragToSnapPoints/__tests__/createDragToSnapPoints.interaction.browser.spec.ts new file mode 100644 index 0000000000000..3d4d7344550dc --- /dev/null +++ b/packages/core/src/dragToSnapPoints/__tests__/createDragToSnapPoints.interaction.browser.spec.ts @@ -0,0 +1,130 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createDragToSnapPoints.interaction.browser.spec.ts + * @input The public snap-point drag bundled with esbuild onto a blank page; + * real touches through the interaction harness's touch driver; Chromium and + * WebKit on the phone profile + * @output Proof of `spec:AST-107` FR2 in a real engine: a slow drag settles on + * the nearest snap point, a quick flick carries to the next one, a release + * moving back returns to the snap point the drag started from, and + * `settleTo` from a key settles without a drag; each reports `onSettle` + * once, and the element rests on the snap point + * @position Browser proof for dragToSnapPoints/createDragToSnapPoints.ts, run + * by `pnpm test:interaction` + */ + +import {fileURLToPath} from 'node:url'; +import {touchDriver, type TouchDriver} from '@astryxdesign/interaction-harness'; +import {expect, test, type Page} from '@playwright/test'; +import {build} from 'esbuild'; + +let script = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [fileURLToPath(new URL('./snapBundle.ts', import.meta.url))], + bundle: true, + write: false, + format: 'iife', + globalName: 'PublicSnap', + target: 'es2022', + }); + script = result.outputFiles[0].text; +}); + +const POINTS = [0, 240, 480]; + +// A real navigation: touch emulation exposes the Touch Events API to +// documents it creates. +async function open(page: Page): Promise { + const html = ` + +
+ +`; + await page.route('http://snap.test/', async route => + route.fulfill({contentType: 'text/html', body: html}), + ); + await page.goto('http://snap.test/'); + return touchDriver(page); +} + +/** A finger from (x, y) moving by `dy` over `steps` steps of `ms`, then lifting. */ +function dragScript( + x: number, + y: number, + moves: ReadonlyArray, + ms: number, +) { + const steps = [{t: 0, fingers: [{x, y, id: 1}]}]; + moves.forEach((dy, i) => { + steps.push({t: (i + 1) * ms, fingers: [{x, y: y + dy, id: 1}]}); + }); + steps.push({t: (moves.length + 1) * ms, fingers: []}); + return steps; +} + +async function settled(page: Page): Promise { + await page.waitForTimeout(1200); + return page.evaluate( + () => (window as unknown as {__settled: number[]}).__settled, + ); +} + +async function panelTop(page: Page): Promise { + return page.evaluate( + () => + (document.getElementById('panel') as HTMLElement).getBoundingClientRect() + .top, + ); +} + +const linear = (to: number, count: number) => + Array.from({length: count}, (_, i) => (to * (i + 1)) / count); + +test('a slow drag settles on the nearest snap point', async ({page}) => { + const driver = await open(page); + // 300 px over about a second: slow, nearer 240 than 480. + await driver.play(dragScript(120, 100, linear(300, 30), 33)); + expect(await settled(page)).toEqual([1]); + expect(await panelTop(page)).toBeCloseTo(40 + 240, 0); +}); + +test('a quick flick carries to the next snap point', async ({page}) => { + const driver = await open(page); + // 110 px in about 80 ms: past 1008 px/s, yet nearer 0 than 240. + await driver.play(dragScript(120, 100, linear(110, 5), 16)); + expect(await settled(page)).toEqual([1]); + expect(await panelTop(page)).toBeCloseTo(40 + 240, 0); +}); + +test('a release moving back returns to the snap point the drag started from', async ({ + page, +}) => { + const driver = await open(page); + // Down 300 px, then back up 40 px over the last frames before the lift. + const moves = [...linear(300, 20), 290, 280, 270, 260]; + await driver.play(dragScript(120, 100, moves, 33)); + expect(await settled(page)).toEqual([0]); + expect(await panelTop(page)).toBeCloseTo(40, 0); +}); + +test('settleTo settles without a drag, the keyboard path', async ({page}) => { + await open(page); + await page.evaluate(() => + (window as unknown as {snap: {settleTo(i: number): void}}).snap.settleTo(2), + ); + expect(await settled(page)).toEqual([2]); + expect(await panelTop(page)).toBeCloseTo(40 + 480, 0); +}); diff --git a/packages/core/src/dragToSnapPoints/__tests__/snapBundle.ts b/packages/core/src/dragToSnapPoints/__tests__/snapBundle.ts new file mode 100644 index 0000000000000..181521cda438f --- /dev/null +++ b/packages/core/src/dragToSnapPoints/__tests__/snapBundle.ts @@ -0,0 +1,11 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file snapBundle.ts + * @input The public snap-point drag + * @output Re-exports it as one entry point + * @position The esbuild entry createDragToSnapPoints.interaction.browser.spec.ts + * bundles into its page (global `PublicSnap`) + */ + +export {createDragToSnapPoints} from '../createDragToSnapPoints'; diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.doc.mjs b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.doc.mjs new file mode 100644 index 0000000000000..169fc21ea92a1 --- /dev/null +++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.doc.mjs @@ -0,0 +1,139 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').HookDoc} */ +export const docs = { + name: 'createDragToSnapPoints', + displayName: 'createDragToSnapPoints', + // A factory documented as a hook: listed with the utilities, beside its + // hook, not as a tile in the components gallery. + group: 'Utilities', + keywords: [ + 'drag', + 'snap', + 'snap points', + 'detents', + 'drawer', + 'flick', + 'swipe', + 'gesture', + 'vanilla', + 'factory', + ], + params: [ + { + name: 'element', + type: 'HTMLElement', + description: + 'The element a drag moves. It moves along `axis` only, by translation, so lay it out at its resting position as usual.', + required: true, + }, + { + name: 'options.axis', + type: "'x' | 'y'", + description: 'The axis the element moves along.', + required: true, + }, + { + name: 'options.snapPoints', + type: 'number[]', + description: + "Offsets in px along `axis` from the element's resting position, such as `[0, 240, 480]`, sorted before use (`onSettle` and `settleTo` count in that order); an empty list makes the drag do nothing. Unlike BottomSheet's `snapPoints`, they are not fractions of the viewport.", + required: true, + }, + { + name: 'options.flickThreshold', + type: 'number', + description: + "A release faster than this, in px/s, carries to the next snap point in its direction. The default is the flick speed measured for UIKit's sheet.", + default: '1008', + required: false, + }, + { + name: 'options.onSettle', + type: '(index: number) => void', + description: + "Called with the snap point's index once the element settles on it, after a drag or `settleTo`. A settle that a new drag interrupts calls nothing.", + required: true, + }, + ], + returns: [ + { + name: 'settleTo', + type: '(index: number) => void', + description: + 'Settles the element on the snap point at `index` without a drag, then calls `onSettle`. Wire it to keys and buttons: it is the keyboard path. An index outside the snap points does nothing and warns in development.', + }, + { + name: 'destroy', + type: '() => void', + description: + 'Removes every listener and stops the element where it is. Call it when you remove the element.', + }, + ], + usage: { + description: + "Lets a drag move an element along one axis and settles it on snap points when the finger lifts, the way an iOS sheet settles on its detents: on the nearest snap point to where it is, on the next one in its direction after a quick flick, and back where it started when the finger was moving back. The settle continues the release's velocity. For a bottom sheet, use BottomSheet's `snapPoints`; for a scrolling row, Carousel's `hasSnap`. Use the `useDragToSnapPoints` hook in React.", + bestPractices: [ + { + guidance: true, + description: + 'Offer the same positions without a drag: buttons or keys that call `settleTo`.', + }, + { + guidance: true, + description: + 'Keep the snap points few and far apart, so a release clearly picks one.', + }, + { + guidance: false, + description: + 'Use it for a sheet or a carousel: their own props already settle the way people expect.', + }, + ], + accessibility: [ + { + name: 'Keyboard and assistive technology', + category: 'Keyboard', + description: + 'A drag is one way in, never the only one. Give each snap point a control a keyboard and a screen reader can reach, such as Expand and Collapse buttons, and call `settleTo` from it. When a position changes what the element shows, announce the change.', + }, + { + name: 'Reduce Motion', + category: 'Content', + criterion: '2.3.3 Animation from Interactions', + description: + 'When the person asks for reduced motion, the settle after a touch drag becomes a short cross-fade into the snap point, as does any settle on a device whose primary pointer is coarse, such as a tablet; after a mouse or pen drag elsewhere it lands at once. `settleTo` fades on such a device and lands at once with a fine primary pointer. While the finger is down, the element still follows it.', + }, + ], + }, + relatedComponents: ['BottomSheet', 'Carousel'], + relatedHooks: ['useDragToSnapPoints', 'createPanGesture', 'createAnimator'], + importPath: '@astryxdesign/core', + category: 'interaction', +}; + +/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */ +export const docsDense = { + description: + 'A drag that settles an element on snap points (px offsets) at the release, without React.', + paramDescriptions: { + element: 'The element a drag moves.', + 'options.axis': "'x' or 'y'.", + 'options.snapPoints': 'Ascending px offsets from the resting position.', + 'options.flickThreshold': + 'px/s past which a release carries one snap point; 1008.', + 'options.onSettle': '(index) once settled.', + }, + returnDescriptions: { + settleTo: '(index) settles without a drag: the keyboard path.', + destroy: '() removes every listener.', + }, + usage: { + description: + 'Nearest snap point, the next on a flick, the start when moving back; the settle keeps the release velocity. Sheets and carousels: use their props.', + bestPractices: [ + {guidance: true, description: 'Wire settleTo to buttons and keys.'}, + {guidance: false, description: 'Rebuild a sheet or carousel with it.'}, + ], + }, +}; diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.finePointer.test.ts b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.finePointer.test.ts new file mode 100644 index 0000000000000..1f5deb8e796eb --- /dev/null +++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.finePointer.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createDragToSnapPoints.finePointer.test.ts + * @input vitest (jsdom, fake clock), the factory driven by mouse and pen + * pointer events through the document's gesture host where the primary + * pointer is fine, a recording stand-in for Element.animate + * @output Checks `spec:AST-094` FR43 for the snap-point drag (`spec:AST-107` + * FR2): on a page whose primary pointer is fine, a mouse or pen drag moves + * the element and settles it on a snap point, as a finger's does + * @position Testing; validates createDragToSnapPoints.ts through the host. Its + * own file, so the document's host is created where the pointer is fine. + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {createDragToSnapPoints} from './createDragToSnapPoints'; + +const POINTS = [0, 240, 480]; + +beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'performance', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ], + }); + HTMLElement.prototype.animate = function () { + let start: number | null = null; + const animation = { + get startTime() { + return start; + }, + set startTime(value: number | null) { + start = value; + }, + playState: 'running', + ready: new Promise(() => {}), + finish() {}, + cancel() {}, + }; + requestAnimationFrame(() => { + start ??= performance.now(); + }); + return animation as unknown as Animation; + }; +}); + +afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + vi.useRealTimers(); + document.body.replaceChildren(); +}); + +// The document's host outlives a test, so event times only move forward. +let clock = 1_000_000; + +/** A `pointerType` drag down by `dy` in `steps` steps of `ms`, then a release. */ +function dragY( + target: Element, + pointerType: 'mouse' | 'pen', + dy: number, + steps = 4, + ms = 16, +): void { + clock += 10_000; + const start = clock; + const send = (type: string, y: number, time: number, buttons: number) => { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 1, + pointerType, + isPrimary: true, + clientX: 10, + clientY: y, + button: 0, + buttons, + }); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); + }; + send('pointerdown', 10, start, 1); + for (let step = 1; step <= steps; step++) { + send('pointermove', 10 + (dy * step) / steps, start + step * ms, 1); + } + send('pointerup', 10 + dy, start + steps * ms + 4, 0); +} + +describe('where the primary pointer is fine (spec:AST-094 FR43)', () => { + it('a mouse or pen drag moves the element and settles it on the nearest snap point', async () => { + expect(window.matchMedia?.('(pointer: coarse)').matches ?? false).toBe( + false, + ); + for (const pointerType of ['mouse', 'pen'] as const) { + const element = document.createElement('div'); + document.body.append(element); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle, + }); + // 300 px over a second: slow, nearer 240 than 480. + dragY(element, pointerType, 300, 20, 50); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle, pointerType).toHaveBeenCalledTimes(1); + expect(onSettle, pointerType).toHaveBeenCalledWith(1); + snap.destroy(); + } + }); +}); diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.reducedMotion.test.ts b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.reducedMotion.test.ts new file mode 100644 index 0000000000000..e62d12ea581c7 --- /dev/null +++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.reducedMotion.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createDragToSnapPoints.reducedMotion.test.ts + * @input vitest (jsdom, fake clock), the snap-point drag with the public pan + * replaced by a stand-in that hands its pan events to the test, Reduce + * Motion on with a fine primary pointer, a recording stand-in for + * Element.animate + * @output Checks `design:reduced-motion` DR2 and DR3 for the snap-point + * drag's release (`spec:AST-107` FR2): a touch drag's release cross-fades + * into the snap point, a mouse or pen drag's release lands at once, and + * `settleTo` lands at once on a fine pointer + * @position Testing; validates createDragToSnapPoints.ts's release plan + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type { + PanGestureEvent, + PanGestureOptions, +} from '../gestures/createPanGesture'; + +let onPan: ((event: PanGestureEvent) => void) | null = null; + +vi.mock('../gestures/createPanGesture', () => ({ + createPanGesture: (_element: Element, options: PanGestureOptions) => { + onPan = options.onPan; + return {destroy: () => {}}; + }, +})); + +const {createDragToSnapPoints} = await import('./createDragToSnapPoints'); + +interface Recorded { + keyframes: Keyframe[]; +} + +const recorded: Recorded[] = []; +const originalMatchMedia = window.matchMedia; + +beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'performance', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ], + }); + // Reduce Motion on, with a fine primary pointer. + window.matchMedia = (query: string) => + ({ + matches: query.includes('prefers-reduced-motion: reduce'), + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList; + HTMLElement.prototype.animate = function (keyframes: Keyframe[]) { + let start: number | null = null; + const animation = { + get startTime() { + return start; + }, + set startTime(value: number | null) { + start = value; + }, + playState: 'running', + ready: new Promise(() => {}), + finish() {}, + cancel() {}, + }; + requestAnimationFrame(() => { + start ??= performance.now(); + }); + recorded.push({keyframes: Array.isArray(keyframes) ? keyframes : []}); + return animation as unknown as Animation; + }; +}); + +afterEach(() => { + window.matchMedia = originalMatchMedia; + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + vi.useRealTimers(); + recorded.length = 0; + onPan = null; + document.body.replaceChildren(); +}); + +function drawer() { + const element = document.createElement('div'); + document.body.append(element); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: [0, 240, 480], + onSettle, + }); + return {element, onSettle, snap}; +} + +/** + * A press by `pointerType` on `element`, then a drag down by `dy` released + * at `vy` px/s: the pan reports values only, so the factory reads the + * pointer from the press. + */ +function release( + element: HTMLElement, + dy: number, + vy: number, + pointerType: 'touch' | 'mouse' | 'pen', +): void { + element.dispatchEvent( + new PointerEvent('pointerdown', {bubbles: true, pointerType}), + ); + const at = (state: PanGestureEvent['state'], y: number, v: number) => + onPan?.({ + state, + translation: {x: 0, y}, + velocity: {x: 0, y: v}, + }); + at('began', 0, 0); + at('changed', dy, 0); + at('ended', dy, vy); +} + +const fades = () => + recorded.some(({keyframes}) => + keyframes.some(frame => frame.opacity !== undefined), + ); + +/** Whether a recorded animation moves the element (FR2: no travel). */ +const travels = () => + recorded.some(({keyframes}) => { + const values = keyframes + .map(frame => frame.translate) + .filter(value => value !== undefined); + return new Set(values).size > 1; + }); + +describe('the release under Reduce Motion on a fine pointer (DR2, DR3)', () => { + it('a touch drag\u2019s release cross-fades into the snap point', async () => { + const {element, onSettle} = drawer(); + release(element, 300, 0, 'touch'); + expect(fades()).toBe(true); + // It goes to its snap point without travel. + expect(travels()).toBe(false); + await vi.advanceTimersByTimeAsync(1000); + expect(onSettle).toHaveBeenCalledWith(1); + }); + + it.each(['mouse', 'pen'] as const)( + 'a %s drag\u2019s release lands at once', + async pointerType => { + const {element, onSettle} = drawer(); + release(element, 300, 0, pointerType); + expect(fades()).toBe(false); + await vi.advanceTimersByTimeAsync(0); + expect(onSettle).toHaveBeenCalledWith(1); + }, + ); + + it('settleTo lands at once', async () => { + const {onSettle, snap} = drawer(); + snap.settleTo(2); + expect(fades()).toBe(false); + await vi.advanceTimersByTimeAsync(0); + expect(onSettle).toHaveBeenCalledWith(2); + }); +}); diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.server.test.ts b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.server.test.ts new file mode 100644 index 0000000000000..8c873deb0da9e --- /dev/null +++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.server.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createDragToSnapPoints.server.test.ts + * @input esbuild, a fresh Node process with no window or document + * @output Checks `spec:AST-107` FR6: importing the snap-point factory and + * its hook does no DOM work, so both are safe on the server + * @position Testing; validates createDragToSnapPoints.ts and + * hooks/useDragToSnapPoints.ts + */ + +import {execFileSync} from 'node:child_process'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; + +/** Bundles `entry` and imports it in a fresh Node process; prints its export names. */ +function importOnServer(entry: string): string[] { + const out = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + [ + "import {build} from 'esbuild';", + `const result = await build({entryPoints: [${JSON.stringify(entry)}], bundle: true, write: false, format: 'esm', platform: 'node', logLevel: 'silent'});`, + "const source = Buffer.from(result.outputFiles[0].text).toString('base64');", + "if (typeof window !== 'undefined' || typeof document !== 'undefined') throw new Error('not a server');", + "const mod = await import('data:text/javascript;base64,' + source);", + 'process.stdout.write(JSON.stringify(Object.keys(mod).sort()));', + ].join('\n'), + ], + {cwd: __dirname, encoding: 'utf8'}, + ); + return JSON.parse(out) as string[]; +} + +describe('the snap-point drag on the server', () => { + it('the factory imports with no window or document', () => { + expect( + importOnServer(path.join(__dirname, 'createDragToSnapPoints.ts')), + ).toContain('createDragToSnapPoints'); + }); + + it('the hook imports with no window or document', () => { + expect( + importOnServer(path.join(__dirname, '../hooks/useDragToSnapPoints.ts')), + ).toContain('useDragToSnapPoints'); + }); +}); diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.test.ts b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.test.ts new file mode 100644 index 0000000000000..446d8f06e042f --- /dev/null +++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.test.ts @@ -0,0 +1,518 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createDragToSnapPoints.test.ts + * @input vitest (jsdom, fake clock), the release rule, the factory driven by + * touches through the document's gesture host, a recording stand-in for + * Element.animate + * @output Checks `spec:AST-107` FR2 and FR6 for the factory: the nearest snap + * point after a slow release, the next one after a flick past + * `flickThreshold` and the default 1008 px/s, the starting one after a + * release moving back (`spec:AST-094` FR37) however far the drag went, + * `settleTo` from a key, `onSettle` once the element settles and never for + * an interrupted settle, bad snap points refused, and no listener left + * after `destroy` + * @position Testing; validates createDragToSnapPoints.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {coarsePrimaryPointer} from '../gestures/__tests__/harness'; +import type * as AnimatorModule from '../motion/animator'; +import {resolveSnapPoint} from '../motion/resolveSnapPoint'; +import {springs} from '../motion/uikit/springs'; +import { + createDragToSnapPoints, + DEFAULT_FLICK_THRESHOLD, +} from './createDragToSnapPoints'; + +// Every animate call the factory's animator makes, as the factory made it. +const animates = vi.hoisted( + () => + [] as {target: unknown; options: {spring?: unknown; velocity?: unknown}}[], +); +vi.mock('../motion/animator', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createMotionAnimator: ( + ...args: Parameters + ) => { + const animator = actual.createMotionAnimator(...args); + const animate = animator.animate.bind(animator); + animator.animate = (target, options, ...rest) => { + animates.push({target, options: {...options}}); + return animate(target, options, ...rest); + }; + return animator; + }, + }; +}); + +const POINTS = [0, 240, 480]; + +// The factory settles where Core's one release policy says, called with +// the drag's start: these are FR2's and FR37's cases through that call. +describe('the release policy the factory calls (FR2, AST-094 FR37)', () => { + const at = (offset: number, velocity: number, startIndex = 0) => + resolveSnapPoint({ + points: POINTS, + position: offset, + velocity, + flickThreshold: DEFAULT_FLICK_THRESHOLD, + start: POINTS[startIndex], + }).index; + + it('a slow release settles on the nearest snap point', () => { + expect(at(100, 300)).toBe(0); + expect(at(130, 300)).toBe(1); + expect(at(400, 0, 1)).toBe(2); + }); + + it('a release past the default flick speed carries to the next snap point in its direction', () => { + expect(DEFAULT_FLICK_THRESHOLD).toBe(1008); + expect(at(30, 1009)).toBe(1); + expect(at(30, 1007)).toBe(0); + expect(at(260, -1500, 2)).toBe(1); + }); + + it('flickThreshold changes the speed that carries', () => { + expect( + resolveSnapPoint({ + points: POINTS, + position: 30, + velocity: 600, + flickThreshold: 500, + start: 0, + }).index, + ).toBe(1); + }); + + it('a release moving back toward the start returns to the snap point it started from, however far it went', () => { + expect(at(200, -50)).toBe(0); + expect(at(470, -3000)).toBe(0); + expect(at(10, 2000, 2)).toBe(2); + }); + + it('a flick past the last snap point stays on the last', () => { + expect(at(500, 2000, 1)).toBe(2); + expect(at(-20, -2000, 1)).toBe(0); + }); +}); + +interface Recorded { + startTime: number | null; + cancelled: boolean; + readonly playState: string; + ready: Promise; + finish(): void; + cancel(): void; +} + +let restorePointer: () => void = () => {}; +const recorded: Recorded[] = []; + +beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'performance', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ], + }); + restorePointer = coarsePrimaryPointer(); + HTMLElement.prototype.animate = function () { + let start: number | null = null; + const animation: Recorded = { + get startTime() { + return start; + }, + set startTime(value) { + start = value; + }, + cancelled: false, + get playState() { + return this.cancelled ? 'idle' : 'running'; + }, + ready: new Promise(() => {}), + finish() {}, + cancel() { + this.cancelled = true; + }, + }; + requestAnimationFrame(() => { + if (start == null) { + start = performance.now(); + } + }); + recorded.push(animation); + return animation as unknown as Animation; + }; +}); + +afterEach(() => { + restorePointer(); + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + vi.useRealTimers(); + document.body.replaceChildren(); +}); + +function touch( + type: string, + target: EventTarget, + contact: {x: number; y: number}, + time: number, + isLift = false, +): void { + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = {identifier: 1, clientX: contact.x, clientY: contact.y, target}; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', {value: isLift ? [] : [entry]}); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); +} + +// The document's host outlives a test, so event times only move forward. +let clock = 1_000_000; + +/** A finger dragging down by `dy` in `steps` steps of `ms`, then lifting. */ +function dragY(target: Element, dy: number, steps = 4, ms = 16): void { + clock += 10_000; + const start = clock; + touch('touchstart', target, {x: 10, y: 10}, start); + for (let step = 1; step <= steps; step++) { + touch( + 'touchmove', + target, + {x: 10, y: 10 + (dy * step) / steps}, + start + step * ms, + ); + } + touch('touchend', target, {x: 10, y: 10 + dy}, start + steps * ms + 4, true); +} + +function sheet(): HTMLElement { + const element = document.createElement('div'); + document.body.append(element); + return element; +} + +describe('createDragToSnapPoints', () => { + it('a slow drag settles on the nearest snap point and reports it once settled', async () => { + const element = sheet(); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle, + }); + // 300 px over a second: slow, nearer 240 than 480. + dragY(element, 300, 20, 50); + expect(onSettle).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle).toHaveBeenCalledTimes(1); + expect(onSettle).toHaveBeenCalledWith(1); + snap.destroy(); + }); + + it('a quick flick carries to the next snap point', async () => { + const element = sheet(); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle, + }); + // 100 px in 64 ms: about 1500 px/s, past 1008, yet nearer 0 than 240. + dragY(element, 100); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle).toHaveBeenCalledWith(1); + snap.destroy(); + }); + + it('flickThreshold sets the speed that carries a drag to the next snap point', async () => { + // The same quick, short drag down from 0, released under the default + // flick speed and past 600 px/s. + const settle = async (flickThreshold?: number) => { + const element = sheet(); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + ...(flickThreshold == null ? {} : {flickThreshold}), + onSettle, + }); + animates.length = 0; + dragY(element, 30, 3, 12); + const release = (animates[0]?.options.velocity as {y: number}).y; + await vi.advanceTimersByTimeAsync(3000); + snap.destroy(); + element.remove(); + return {release, settled: onSettle.mock.calls.at(-1)?.[0] as unknown}; + }; + const byDefault = await settle(); + const at600 = await settle(600); + expect(byDefault.release).toBeGreaterThan(600); + expect(byDefault.release).toBeLessThan(DEFAULT_FLICK_THRESHOLD); + expect(at600.release).toBeCloseTo(byDefault.release, 6); + // Nearer 0 and under 1008 px/s: back to 0. Past 600: on to 240. + expect(byDefault.settled).toBe(0); + expect(at600.settled).toBe(1); + }); + + it('the settle starts at the release velocity, a commit and a spring-back alike (spec:AST-094 FR38)', async () => { + const element = sheet(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle: () => {}, + }); + // A flick down commits to the next snap point. + animates.length = 0; + dragY(element, 100); + expect(animates).toHaveLength(1); + const commit = animates[0]; + expect(commit.target).toEqual({y: 240}); + expect(commit.options.spring).toBe(springs.settle); + const committed = (commit.options.velocity as {y: number}).y; + expect(committed).toBeGreaterThan(DEFAULT_FLICK_THRESHOLD); + await vi.advanceTimersByTimeAsync(3000); + // From 240, down 120 px and back up 60 quickly: the spring-back to 240 + // starts at that upward speed. + animates.length = 0; + clock += 10_000; + const start = clock; + touch('touchstart', element, {x: 10, y: 10}, start); + for (let step = 1; step <= 6; step++) { + touch( + 'touchmove', + element, + {x: 10, y: 10 + 20 * step}, + start + step * 50, + ); + } + touch('touchmove', element, {x: 10, y: 90}, start + 316); + touch('touchmove', element, {x: 10, y: 70}, start + 332); + touch('touchend', element, {x: 10, y: 70}, start + 336, true); + expect(animates).toHaveLength(1); + const back = animates[0]; + expect(back.target).toEqual({y: 240}); + expect(back.options.spring).toBe(springs.settle); + expect((back.options.velocity as {y: number}).y).toBeLessThan(0); + snap.destroy(); + }); + + it('settleTo settles without a drag, the keyboard path', async () => { + const element = sheet(); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle, + }); + snap.settleTo(2); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle).toHaveBeenCalledWith(2); + snap.destroy(); + }); + + it('a settle interrupted by another reports only the last', async () => { + const element = sheet(); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle, + }); + snap.settleTo(2); + await vi.advanceTimersByTimeAsync(50); + snap.settleTo(1); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle.mock.calls).toEqual([[1]]); + snap.destroy(); + }); + + it('sorts the snap points before use', async () => { + const element = sheet(); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: [480, 0, 240], + onSettle, + }); + // 300 px over about a second: slow, nearer 240, the second snap point. + dragY(element, 300, 20, 50); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle).toHaveBeenCalledWith(1); + snap.destroy(); + }); + + it('an empty list does nothing and warns in development', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const element = sheet(); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: [], + onSettle, + }); + expect(warn).toHaveBeenCalledTimes(1); + dragY(element, 300, 20, 50); + snap.settleTo(0); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle).not.toHaveBeenCalled(); + expect(element.style.translate).toBe(''); + snap.destroy(); + warn.mockRestore(); + }); + + it('settleTo with an index outside the snap points does nothing and warns in development', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const element = sheet(); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle, + }); + snap.settleTo(9); + snap.settleTo(-1); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledTimes(2); + snap.destroy(); + warn.mockRestore(); + }); + + it('a drag created again starts where its predecessor left the element (spec:AST-107 FR2)', async () => { + const element = sheet(); + const first = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle: () => {}, + }); + first.settleTo(2); + await vi.advanceTimersByTimeAsync(3000); + first.destroy(); + // Re-created with new snap points (the handle has no update): a slow + // 30 px drag moves on from 480, so it settles back on 480, not on 0. + const onSettle = vi.fn(); + const again = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: [0, 240, 480, 720], + onSettle, + }); + animates.length = 0; + dragY(element, 30, 10, 60); + expect(animates.at(-1)?.target).toEqual({y: 480}); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle).toHaveBeenCalledWith(2); + again.destroy(); + }); + + it('destroyed during a settle, the element stays where it was, not where the settle began (spec:AST-107 FR6)', async () => { + const element = sheet(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle: () => {}, + }); + snap.settleTo(2); + await vi.advanceTimersByTimeAsync(150); + snap.destroy(); + const [, y = '0'] = element.style.translate.split(' '); + const at = Number.parseFloat(y); + // Partway to 480, held there: neither back at 0 nor jumped to 480. + expect(at).toBeGreaterThan(0); + expect(at).toBeLessThan(480); + }); + + it('destroyed mid-drag, the finger moves nothing and nothing reports (spec:AST-107 FR6)', async () => { + const element = sheet(); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle, + }); + clock += 10_000; + const start = clock; + touch('touchstart', element, {x: 10, y: 10}, start); + for (let step = 1; step <= 4; step++) { + touch( + 'touchmove', + element, + {x: 10, y: 10 + step * 20}, + start + step * 16, + ); + } + const during = element.style.translate; + snap.destroy(); + for (let step = 5; step <= 8; step++) { + touch( + 'touchmove', + element, + {x: 10, y: 10 + step * 20}, + start + step * 16, + ); + } + touch('touchend', element, {x: 10, y: 170}, start + 8 * 16 + 4, true); + await vi.advanceTimersByTimeAsync(3000); + expect(element.style.translate).toBe(during); + expect(onSettle).not.toHaveBeenCalled(); + }); + + it('destroy removes every listener it added (spec:AST-107 FR6)', () => { + const added = new Map(); + const key = (target: EventTarget, type: string, capture: boolean) => + `${target === document ? 'document' : target === window ? 'window' : 'element'}:${type}:${capture}`; + const captureOf = (options?: boolean | AddEventListenerOptions) => + typeof options === 'boolean' ? options : (options?.capture ?? false); + const add = vi.spyOn(EventTarget.prototype, 'addEventListener'); + const remove = vi.spyOn(EventTarget.prototype, 'removeEventListener'); + const element = sheet(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle: () => {}, + }); + for (const call of add.mock.calls) { + const k = key( + add.mock.contexts.at(add.mock.calls.indexOf(call)) as EventTarget, + call[0], + captureOf(call[2]), + ); + added.set(k, (added.get(k) ?? 0) + 1); + } + expect(added.size).toBeGreaterThan(0); + snap.destroy(); + for (const [index, call] of remove.mock.calls.entries()) { + const k = key( + remove.mock.contexts[index] as EventTarget, + call[0], + captureOf(call[2]), + ); + added.set(k, (added.get(k) ?? 0) - 1); + } + expect([...added].filter(([, count]) => count !== 0)).toEqual([]); + add.mockRestore(); + remove.mockRestore(); + }); + + it('after destroy no drag moves the element and nothing reports', async () => { + const element = sheet(); + const onSettle = vi.fn(); + const snap = createDragToSnapPoints(element, { + axis: 'y', + snapPoints: POINTS, + onSettle, + }); + snap.destroy(); + dragY(element, 300, 20, 50); + snap.settleTo(2); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle).not.toHaveBeenCalled(); + expect(element.style.translate).toBe(''); + }); +}); diff --git a/packages/core/src/dragToSnapPoints/createDragToSnapPoints.ts b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.ts new file mode 100644 index 0000000000000..e64ef6b71ad2d --- /dev/null +++ b/packages/core/src/dragToSnapPoints/createDragToSnapPoints.ts @@ -0,0 +1,274 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file createDragToSnapPoints.ts + * @input An element; the public pan (gestures/createPanGesture.ts) and the + * motion animator with the `springs.settle` role + * @output The public snap-point drag (`spec:AST-107` FR2, FR6): + * `createDragToSnapPoints`, its options and handle + * @position Public entry point, re-exported from the package root. The + * `useDragToSnapPoints` hook (hooks/useDragToSnapPoints.ts) is a thin + * wrapper over it. + * + * A drag moves the element along one axis; at the release it settles on a + * snap point (offsets in px from the element's resting position, ascending): + * the nearest one to where it is, except that a release faster than + * `flickThreshold` carries to the next one in its direction, and a release + * moving back toward where the drag started returns to the snap point it + * started from (`spec:AST-094` FR37): Core's one release policy, + * `resolveSnapPoint`, called with the drag's start. The settle plays the `springs.settle` + * motion role from the release velocity (FR38); under Reduce Motion a touch + * drag's release cross-fades and a mouse or pen drag's lands at once + * (`design:reduced-motion` DR2, DR3). `settleTo(index)` settles without a drag: the keyboard path. + * Importing this module does no DOM work. + * + * SYNC: When modified, update: + * - /packages/core/src/dragToSnapPoints/createDragToSnapPoints.test.ts + * - /packages/core/src/dragToSnapPoints/createDragToSnapPoints.doc.mjs + * - /packages/core/src/hooks/useDragToSnapPoints.ts + */ + +import { + createPanGesture, + type PanGestureEvent, +} from '../gestures/createPanGesture'; +import {createMotionAnimator} from '../motion/animator'; +import {resolveSnapPoint} from '../motion/resolveSnapPoint'; +import {devWarn} from '../utils/devWarning'; +import {registerSpringsTable, springs} from '../motion/uikit/springs'; + +/** + * The release speed, in px/s, above which a drag carries to the next snap + * point: the flick speed measured for UIKit's sheet on iOS 26.2. + */ +export const DEFAULT_FLICK_THRESHOLD = 1008; + +/** Options of `createDragToSnapPoints`. */ +export interface DragToSnapPointsOptions { + /** The axis the element moves along. */ + axis: 'x' | 'y'; + /** + * Offsets in px along `axis` from the element's resting position, sorted + * before use (`onSettle` and `settleTo` count in that order). Not viewport + * fractions, unlike BottomSheet's `snapPoints`. An empty list makes the + * drag do nothing and warns in development. + */ + snapPoints: ReadonlyArray; + /** + * A release faster than this, in px/s, carries to the next snap point in + * its direction. Default 1008. + */ + flickThreshold?: number; + /** Called with the snap point's index once the element settles on it. */ + onSettle: (index: number) => void; +} + +/** What `createDragToSnapPoints` returns. */ +export interface DragToSnapPoints { + /** + * Settles the element on the snap point at `index` without a drag (the + * keyboard path), then calls `onSettle`. An index outside the snap points + * does nothing and warns in development. + */ + settleTo(index: number): void; + /** Removes every listener and stops the element where it is. */ + destroy(): void; +} + +/** + * Where `element` is drawn now, from the inline `translate` an animator + * left on it (a drag destroyed to be created again leaves the element where + * it stopped); 0 on an axis it does not set. + */ +function drawnAt(element: HTMLElement): {x: number; y: number} { + const [x = 0, y = 0] = element.style + .getPropertyValue('translate') + .trim() + .split(/\s+/) + .map(part => Number.parseFloat(part)) + .map(value => (Number.isFinite(value) ? value : 0)); + return {x, y}; +} + +/** The usable snap points, ascending: `snapPoints` is sorted before use. */ +function sortedSnapPoints(snapPoints: ReadonlyArray): number[] { + const usable = snapPoints.filter(point => Number.isFinite(point)); + if (usable.length < snapPoints.length) { + devWarn( + 'createDragToSnapPoints', + 'snapPoints must be numbers; the others are ignored.', + ); + } + return usable.sort((a, b) => a - b); +} + +/** + * Lets a drag move `element` along `axis` and settles it on `snapPoints` at + * the release, then calls `onSettle(index)`. + * + * @example + * ``` + * const snap = createDragToSnapPoints(drawer, { + * axis: 'y', + * snapPoints: [0, 240, 480], + * onSettle: index => console.log('settled on', index), + * }); + * button.addEventListener('click', () => snap.settleTo(0)); + * // Later: + * snap.destroy(); + * ``` + */ +export function createDragToSnapPoints( + element: HTMLElement, + options: DragToSnapPointsOptions, +): DragToSnapPoints { + const {axis, onSettle} = options; + const flickThreshold = options.flickThreshold ?? DEFAULT_FLICK_THRESHOLD; + const points = sortedSnapPoints(options.snapPoints); + if (points.length === 0) { + // Nothing to settle on: the drag does nothing (spec:AST-107 FR2). + devWarn( + 'createDragToSnapPoints', + 'snapPoints is empty, so the drag does nothing.', + ); + return {settleTo() {}, destroy() {}}; + } + // It starts where the element is drawn, so a drag created again to + // change its options moves on from there instead of jumping. + const drawn = drawnAt(element); + const animator = createMotionAnimator(element, {initial: drawn}); + // The snap point the element rests on, or is settling toward. + let index = resolveSnapPoint({ + points, + position: drawn[axis], + velocity: 0, + flickThreshold, + }).index; + // Each settle and each drag takes a turn; only the latest settle reports. + let turn = 0; + let drag: { + startOffset: number; + startIndex: number; + pointerType: string; + } | null = null; + let destroyed = false; + // The pointer of the press a drag starts from, read from the press itself: + // only a touch drag's release cross-fades under Reduce Motion, and the + // public pan reports values only (spec:AST-107 FR1). A drag with no + // pointer event before it (a test's bare touches) counts as a touch. + let pressPointerType = 'touch'; + const onPointerDown = (event: PointerEvent) => { + if (drag == null) { + pressPointerType = event.pointerType; + } + }; + element.addEventListener('pointerdown', onPointerDown, { + capture: true, + passive: true, + }); + + const settle = ( + target: number, + velocity: number, + touchRelease: boolean, + ): void => { + registerSpringsTable(); + index = target; + const mine = ++turn; + const handle = animator.animate( + {[axis]: points[target]}, + { + spring: springs.settle, + ...(velocity !== 0 ? {velocity: {[axis]: velocity}} : {}), + }, + {touchRelease}, + ); + void handle.finished.then(arrived => { + if (arrived && mine === turn && !destroyed) { + onSettle(index); + } + }); + }; + + const pan = createPanGesture(element, { + axis, + onPan: ({state, translation, velocity}: PanGestureEvent) => { + if (destroyed) { + return; + } + if (state === 'began') { + // Catch a settle in flight where it is on screen. + turn += 1; + animator.stop(); + drag = { + startOffset: animator.get(axis), + startIndex: index, + pointerType: pressPointerType, + }; + } + if (drag == null) { + return; + } + const offset = drag.startOffset + translation[axis]; + if (state === 'began' || state === 'changed') { + animator.set({[axis]: offset}); + return; + } + const {startIndex, startOffset, pointerType} = drag; + drag = null; + // Only a touch drag's release cross-fades under Reduce Motion; a mouse + // or pen drag's lands at once (design:reduced-motion DR2, DR3). + const touchRelease = pointerType === 'touch'; + if (state === 'cancelled') { + settle(startIndex, 0, touchRelease); + return; + } + const release = velocity[axis]; + // Core's one release policy: the nearest snap point, the next one in + // its direction past the flick speed, and back to where the drag + // started when the release moves back toward it (spec:AST-094 FR37). + const {index: target} = resolveSnapPoint({ + points, + position: offset, + velocity: release, + flickThreshold, + start: startOffset, + }); + settle(target, release, touchRelease); + }, + }); + + const snap: DragToSnapPoints = { + settleTo(target) { + if (destroyed) { + return; + } + if (!Number.isInteger(target) || target < 0 || target >= points.length) { + devWarn( + 'createDragToSnapPoints', + `settleTo(${target}) is outside the ${points.length} snap points, so it does nothing.`, + ); + return; + } + drag = null; + settle(target, 0, false); + }, + destroy() { + if (destroyed) { + return; + } + destroyed = true; + element.removeEventListener('pointerdown', onPointerDown, { + capture: true, + }); + pan.destroy(); + // Held where it is: disposing cancels a running settle, which would + // otherwise leave the element where that settle began. + animator.stop(); + animator.dispose(); + }, + }; + return snap; +} diff --git a/packages/core/src/dragToSnapPoints/publicSurface.test.ts b/packages/core/src/dragToSnapPoints/publicSurface.test.ts new file mode 100644 index 0000000000000..1c0816b8fe696 --- /dev/null +++ b/packages/core/src/dragToSnapPoints/publicSurface.test.ts @@ -0,0 +1,34 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file publicSurface.test.ts + * @input Core's package.json, the package root's source, this directory + * @output Checks `spec:AST-107` FR2 and FR6 for the snap-point drag's entry: + * `createDragToSnapPoints` ships from the package root only (no subpath of + * its own, one API with one entry), as `createAnimator` does, and its hook + * from the hooks barrel + * @position Public API test; enforced by Vitest + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; + +const SRC = path.join(__dirname, '..'); + +describe('the snap-point drag\u2019s entry', () => { + it('createDragToSnapPoints ships from the package root only: no `./dragToSnapPoints` subpath', () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(SRC, '..', 'package.json'), 'utf8'), + ) as {exports: Record}; + expect(Object.keys(pkg.exports)).not.toContain('./dragToSnapPoints'); + expect(fs.existsSync(path.join(__dirname, 'index.ts'))).toBe(false); + const root = fs.readFileSync(path.join(SRC, 'index.ts'), 'utf8'); + expect(root).toContain("from './dragToSnapPoints/createDragToSnapPoints';"); + }); + + it('the hooks barrel hands out useDragToSnapPoints', () => { + const hooks = fs.readFileSync(path.join(SRC, 'hooks', 'index.ts'), 'utf8'); + expect(hooks).toMatch(/useDragToSnapPoints/); + }); +}); diff --git a/packages/core/src/dragToSnapPoints/snapTemplates.test.tsx b/packages/core/src/dragToSnapPoints/snapTemplates.test.tsx new file mode 100644 index 0000000000000..ea87b955731fc --- /dev/null +++ b/packages/core/src/dragToSnapPoints/snapTemplates.test.tsx @@ -0,0 +1,154 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file snapTemplates.test.tsx + * @input The CLI's two playing-card examples of the snap-point drag (the + * `useDragToSnapPoints` one and the plain-JavaScript `createDragToSnapPoints` + * one), rendered left to right and right to left, with a recording stand-in + * for Element.animate + * @output Tests `design:bidirectionality` DR13 for the examples: the places run + * in reading order, so a card sent to the next place moves toward the inline + * end, rightward or leftward, and its place is named the same either way + * @position The examples ship to products through the CLI, so their direction + * handling is checked here, beside the drag they demonstrate. + */ + +import type {ComponentType, ReactNode} from 'react'; +import {act, fireEvent, render, screen} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {InternationalizationProvider} from '../i18n'; + +// The examples live in the CLI's templates, outside Core's type-checked +// sources, so they load at run time; the CLI type-checks them itself. +const TEMPLATES = '../../../cli/assets/templates/blocks/components'; +async function loadExample(file: string): Promise { + const module = (await import(/* @vite-ignore */ `${TEMPLATES}/${file}`)) as { + default: ComponentType; + }; + return module.default; +} +const DragToSnapPointsCard = await loadExample( + 'useDragToSnapPoints/DragToSnapPointsCard', +); +const DragToSnapPointsCardVanilla = await loadExample( + 'createDragToSnapPoints/DragToSnapPointsCardVanilla', +); + +beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'performance', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ], + }); + HTMLElement.prototype.animate = function () { + let start: number | null = null; + const animation = { + get startTime() { + return start; + }, + set startTime(value: number | null) { + start = value; + }, + playState: 'running', + ready: new Promise(() => {}), + finish() {}, + cancel() {}, + }; + requestAnimationFrame(() => { + start ??= performance.now(); + }); + return animation as unknown as Animation; + }; + // jsdom computes no inherited direction; a browser takes an element's from + // the nearest dir attribute, which this reads the same way. + const computed = window.getComputedStyle.bind(window); + vi.spyOn(window, 'getComputedStyle').mockImplementation((element, pseudo) => { + const style = computed(element, pseudo); + const dir = element.closest('[dir]')?.getAttribute('dir') ?? 'ltr'; + Object.defineProperty(style, 'direction', {value: dir, configurable: true}); + return style; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + vi.useRealTimers(); +}); + +function inDirection(dir: 'ltr' | 'rtl', example: ReactNode) { + return ( +
+ + {example} + +
+ ); +} + +/** Sends the card to `place` with its button; returns where it settled, in px. */ +async function sendTo(place: string, card: () => HTMLElement): Promise { + fireEvent.click(screen.getByRole('button', {name: place})); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + const [x = '0'] = (card().style.translate ?? '').split(' '); + // At rest on its starting place the card carries no translate at all. + const offset = Number.parseFloat(x); + return Number.isFinite(offset) ? offset : 0; +} + +describe('the snap-point examples follow the reading direction (design:bidirectionality DR13)', () => { + const examples: [string, ComponentType, () => HTMLElement][] = [ + [ + 'useDragToSnapPoints', + DragToSnapPointsCard, + () => screen.getByLabelText(/^Queen of hearts/), + ], + [ + 'createDragToSnapPoints', + DragToSnapPointsCardVanilla, + () => screen.getByText('Q\u2665'), + ], + ]; + for (const [name, Example, card] of examples) { + it(`${name}: the next place is rightward left to right, leftward right to left, and named the same`, async () => { + for (const [dir, sign] of [ + ['ltr', 1], + ['rtl', -1], + ] as const) { + const {unmount} = render(inDirection(dir, )); + expect(await sendTo('Table', card), dir).toBe(sign * 110); + expect(await sendTo('Discard pile', card), dir).toBe(sign * 220); + expect( + screen.getByText('The card is on the discard pile.'), + ).toBeTruthy(); + expect(await sendTo('Hand', card), dir).toBe(0); + unmount(); + } + }); + + it(`${name}: without an internationalization provider, the places follow the direction the card renders in (architecture:internationalization INV11)`, async () => { + const {unmount} = render( +
+ +
, + ); + expect(await sendTo('Table', card)).toBe(-110); + unmount(); + }); + } +}); + +describe('the plain-JavaScript example', () => { + it('colours its card from the theme\u2019s surface and text tokens, so it reads in a dark theme', () => { + render(); + const {style} = screen.getByText('Q\u2665'); + expect(style.backgroundColor).toBe('var(--color-background-surface)'); + expect(style.color).toBe('var(--color-text-primary)'); + }); +}); diff --git a/packages/core/src/gestures/GestureArena.ts b/packages/core/src/gestures/GestureArena.ts index 1d11b2df6a563..a3731a5d1b149 100644 --- a/packages/core/src/gestures/GestureArena.ts +++ b/packages/core/src/gestures/GestureArena.ts @@ -228,6 +228,7 @@ export class GestureArena { target: input.target, scroll: input.scroll ?? null, takenByBrowser: false, + ...(input.scrollType == null ? {} : {scrollType: input.scrollType}), }; const participants: GestureRecognizer[] = []; for (const [depth, recognizer] of input.recognizers.entries()) { diff --git a/packages/core/src/gestures/GestureHost.ts b/packages/core/src/gestures/GestureHost.ts index b90fd81a8c473..4732e7e9a7767 100644 --- a/packages/core/src/gestures/GestureHost.ts +++ b/packages/core/src/gestures/GestureHost.ts @@ -140,9 +140,14 @@ import { import type {GestureRecognizer, SelectionLock} from './GestureRecognizer'; import type {PressBounds} from './PressTracker'; import type {PanGestureRecognizer} from './PanGestureRecognizer'; -import {createNativeScrollProbe, parentAcrossShadow} from './nativeScroll'; +import { + canElementScroll, + createNativeScrollProbe, + parentAcrossShadow, +} from './nativeScroll'; import {containsAlongFlatTree} from '../utils/flatTree'; import {isIosWebKit} from '../utils/touchQueries'; +import {type WheelSegment, WheelSegmenter} from './wheelSegmenter'; import { gestureHostStyle, type GestureHostStyle, @@ -158,6 +163,7 @@ import type { GestureEnvironment, GestureTouch, PointerKind, + ScrollType, TouchBeganInput, TouchConsumer, TouchInput, @@ -186,6 +192,8 @@ interface Registration { hasTouchEndListener: boolean; /** Whether the element carries the host's shared blocking touchmove handler. */ hasTouchMoveListener: boolean; + /** Whether the element carries the host's wheel handler (FR43). */ + hasWheelListener: boolean; /** Inline values the host replaced, to restore on detach. */ readonly savedStyle: Map; /** @@ -305,6 +313,16 @@ function claimsWhileMoving(recognizer: GestureRecognizer): boolean { ); } +/** + * Whether a recognizer takes a contact that comes with Touch Events and a + * long press's contextmenu: a finger's or a pen's. A scroll-only recognizer + * (`allowedPointerTypes: []`) and a mouse-only one take neither. + */ +function takesTouchContacts(recognizer: GestureRecognizer): boolean { + const types = recognizer.allowedPointerTypes; + return types == null || types.has('touch') || types.has('pen'); +} + /** * 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. @@ -381,6 +399,216 @@ function landedOn( return event.composedPath()[0] ?? retargeted; } +/** + * How long after its last event a pinch or a mouse wheel's notches end, ms: + * the web reports no wheel phases, so a quiet wheel is a lifted one + * (`spec:AST-094` FR43). + */ +const WHEEL_END_MS = 100; +/** + * How long after its last event a vertical trackpad scroll ends, ms (a + * sheet's pull), whoever keeps it: a slow drag's stream pauses longer than + * WHEEL_END_MS between events (`spec:AST-094` FR43, DEC-24). + */ +const WHEEL_FOLLOW_END_V_MS = 180; +/** + * The same horizontally, ms (a pager's or a row's swipe), whose slow + * swipes pause longer still (`spec:AST-094` FR43, DEC-24). + */ +const WHEEL_FOLLOW_END_MS = 220; +/** A line-mode wheel's line, px, as the platforms scroll it. */ +const WHEEL_LINE_PX = 40; +/** The span a ctrl-wheel pinch's two contacts start at, px. */ +const WHEEL_PINCH_SPAN = 100; + +/** A wheel gesture: the contacts it drives in the arena, or none where the page keeps it. */ +interface WheelGesture { + readonly kind: 'pan' | 'pinch'; + /** A mouse wheel's notches, or a trackpad's continuous scroll. */ + readonly scrollType: ScrollType; + readonly ids: ReadonlyArray; + /** A pan's contact, or a pinch's center. */ + x: number; + y: number; + /** A pinch's span between its two contacts. */ + span: number; + /** The latest wheel's time. */ + last: number; + /** + * How long after its last wheel it ends, ms: a trackpad pan stream's pause + * bound for its axis, whoever keeps it, and 100 ms for a pinch or a mouse + * wheel's notches. + */ + readonly endMs: number; + timer: unknown; + /** The recognizers that took it; none where the page keeps it. */ + readonly takers: ReadonlyArray; + /** A trackpad scroll a pan follows, until its fingers lift. */ + follow?: WheelFollow; +} + +/** A trackpad scroll a pan follows: where its fingers lift, and what waits until then. */ +interface WheelFollow { + /** The fingers lifted: the momentum after them never moves the pan. */ + lifted: boolean; + /** + * Shrinking deltas held until the splitter decides: the fingers' when + * larger ones follow, the start of momentum when it reads the lift. + */ + heldX: number; + heldY: number; + /** The time of the fingers' last move the pan took. */ + lastMove: number; + /** The fingers' travel as of that move: the contact without any held delta. */ + trueX: number; + trueY: number; + /** Where the pan was shown at that move, which the undecided stretch moves on from. */ + baseX: number; + baseY: number; + /** The fingers' speed at that move, content px per ms. */ + speedX: number; + speedY: number; +} + +/** + * How long after its last wheel a wheel gesture ends, ms (`spec:AST-094` + * FR43): a trackpad's two-finger scroll waits out a slow drag's pauses for + * its axis, whoever keeps the stream, while a pinch and a mouse wheel's + * notches end 100 ms after their last wheel. + */ +function wheelEndMs( + kind: 'pan' | 'pinch', + scrollType: ScrollType, + isHorizontal: boolean, +): number { + if (kind === 'pinch' || scrollType !== 'continuous') { + return WHEEL_END_MS; + } + return isHorizontal ? WHEEL_FOLLOW_END_MS : WHEEL_FOLLOW_END_V_MS; +} + +/** + * A provisional lead `debt` (what a followed pan shows past the fingers' + * travel, or short of it) after a move of `moved`: half the move is + * absorbed, so the pan keeps moving the way the fingers do. + */ +function absorbedDebt(debt: number, moved: number): number { + return Math.sign(debt) * Math.max(0, Math.abs(debt) - Math.abs(moved) / 2); +} + +/** + * `target` where it is ahead of `shown` in the direction of `speed`, else + * `shown`: a reconcile that never steps back. + */ +function ahead(shown: number, target: number, speed: number): number { + return speed === 0 || (target - shown) * Math.sign(speed) > 0 + ? target + : shown; +} + +/** + * A wheel's deltas in CSS px: a line is `WHEEL_LINE_PX`, a page the + * viewport. + */ +function wheelPixels( + wheel: WheelEvent, + view: Window | null, +): {dx: number; dy: number} { + if (wheel.deltaMode === 1) { + return {dx: wheel.deltaX * WHEEL_LINE_PX, dy: wheel.deltaY * WHEEL_LINE_PX}; + } + if (wheel.deltaMode === 2) { + return { + dx: wheel.deltaX * (view?.innerWidth ?? 0), + dy: wheel.deltaY * (view?.innerHeight ?? 0), + }; + } + return {dx: wheel.deltaX, dy: wheel.deltaY}; +} + +/** + * The web's best reading of what scrolled: a mouse wheel's notches + * (`discrete`) or a trackpad's scroll (`continuous`). What it cannot tell + * apart counts as a mouse wheel's, which a pan that takes only a trackpad's + * scroll leaves to the page. A mouse wheel's: a line- or page-mode delta; a + * scroll with Shift held (Shift+wheel); a pixel delta a hair off a whole pixel + * (a mouse wheel on Windows); a scroll + * that carries no legacy `wheelDelta` (an engine that sends none); a delta + * that is not a third of its legacy `wheelDelta`; or a 120 tick over a 40 px + * delta. A trackpad's precise deltas, a third of their legacy `wheelDelta` + * (Chromium and WebKit), are a trackpad's. + */ +function scrollTypeOf(wheel: WheelEvent): ScrollType { + if (wheel.deltaMode !== 0 || wheel.shiftKey) { + return 'discrete'; + } + const remainder = (value: number) => { + const fraction = Math.abs(value - Math.round(value)); + return fraction > 0 && fraction < 0.01; + }; + if (remainder(wheel.deltaX) || remainder(wheel.deltaY)) { + return 'discrete'; + } + const legacy = wheel as WheelEvent & { + wheelDeltaX?: number; + wheelDeltaY?: number; + }; + if ( + typeof legacy.wheelDeltaY !== 'number' || + typeof legacy.wheelDeltaX !== 'number' + ) { + return 'discrete'; + } + const precise = (delta: number, legacyDelta: number) => + delta === 0 + ? legacyDelta === 0 + : Math.abs(Math.abs(legacyDelta) - 3 * Math.abs(delta)) <= + Math.max(1.5, 0.05 * Math.abs(delta) * 3); + if ( + !precise(wheel.deltaY, legacy.wheelDeltaY) || + !precise(wheel.deltaX, legacy.wheelDeltaX) + ) { + return 'discrete'; + } + const tick = (delta: number, legacyDelta: number) => + legacyDelta !== 0 && legacyDelta % 120 === 0 && delta % 40 === 0; + return tick(wheel.deltaY, legacy.wheelDeltaY) || + tick(wheel.deltaX, legacy.wheelDeltaX) + ? 'discrete' + : 'continuous'; +} + +/** Whether a recognizer is a scroll-edge pan (`spec:AST-094` FR19). */ +function isScrollEdgePan(recognizer: GestureRecognizer): boolean { + return (recognizer as {scrollEdge?: boolean}).scrollEdge === true; +} + +/** Whether a pan's `direction` takes a contact moving by (cx, cy). */ +function panTakesMove( + recognizer: GestureRecognizer, + cx: number, + cy: number, +): boolean { + const direction = (recognizer as {direction?: string}).direction ?? 'any'; + const isHorizontal = Math.abs(cx) > Math.abs(cy); + switch (direction) { + case 'horizontal': + return isHorizontal; + case 'vertical': + return !isHorizontal; + case 'left': + return isHorizontal && cx < 0; + case 'right': + return isHorizontal && cx > 0; + case 'up': + return !isHorizontal && cy < 0; + case 'down': + return !isHorizontal && cy > 0; + default: + return true; + } +} + /** * An event's time on the arena's clock (`performance.now()`). Some engines * and DOM emulators stamp events in epoch milliseconds instead. @@ -530,6 +758,17 @@ export class GestureHost { */ private fingerTargets: ReadonlyArray = []; private nextId = 1; + /** The wheel gesture in progress (FR43). */ + private wheel: WheelGesture | null = null; + /** Whether the document routes every wheel to the live wheel gesture. */ + /** The wheel latch's subscription to the shared input stream, while a gesture is latched. */ + private stopWheelLatch: (() => void) | null = null; + /** Splits the wheel stream into gestures and reads a trackpad's lift. */ + private readonly segmenter = new WheelSegmenter(); + /** The latest wheel's segment. */ + private wheelSegment: WheelSegment | null = null; + /** Wheels already taken: each element on the path with a listener hears one. */ + private readonly wheelsSeen = new WeakSet(); private isInstalled = false; /** * Whether the primary pointer is coarse (`spec:AST-094` FR19), read when @@ -648,6 +887,16 @@ export class GestureHost { if (recognizer.state !== 'possible' || recognizer.isInvolved()) { recognizer.cancel(); } + const wheel = this.wheel; + if ( + wheel != null && + wheel.takers.includes(recognizer) && + !this.wheelHasTaker(wheel) + ) { + // At its last wheel: a later stamp would move the arena's clock past + // the next input's own time (FR16). + this.endWheel(wheel.last); + } this.refresh(element, registration); // Even when the element keeps other attachments: one left disabled // keeps no listener or observer (FR24). @@ -1055,6 +1304,7 @@ export class GestureHost { consumer: null, hasTouchEndListener: false, hasTouchMoveListener: false, + hasWheelListener: false, savedStyle: new Map(), authorTouchAction: null, manageStyle: true, @@ -1135,6 +1385,26 @@ export class GestureHost { element.removeEventListener('touchmove', this.onBlockingTouchMove); registration.hasTouchMoveListener = false; } + // Wheel and trackpad input, only where an enabled recognizer takes it + // (FR43): a non-passive wheel listener makes the browser's scrolling over + // the element wait for the main thread. + const needsWheel = registration.recognizers.some( + recognizer => + recognizer.isEnabled && + // A watcher never takes the wheel: the page keeps it (FR20, DEC-7). + recognizer.claimsTouches && + recognizer.allowedScrollTypes.size > 0 && + // A wheel drives a pan or a pinch only. + (internals(recognizer).family === 'pan' || + internals(recognizer).family === 'pinch'), + ); + if (needsWheel && !registration.hasWheelListener) { + element.addEventListener('wheel', this.onWheel, {passive: false}); + registration.hasWheelListener = true; + } else if (!needsWheel && registration.hasWheelListener) { + element.removeEventListener('wheel', this.onWheel); + registration.hasWheelListener = false; + } } private applyStyle(element: HTMLElement, registration: Registration): void { @@ -1290,6 +1560,9 @@ export class GestureHost { if (registration.hasTouchMoveListener) { element.removeEventListener('touchmove', this.onBlockingTouchMove); } + if (registration.hasWheelListener) { + element.removeEventListener('wheel', this.onWheel); + } this.registry.delete(element); this.uninstallIfIdle(); } @@ -1394,7 +1667,13 @@ export class GestureHost { for (const [element, registration] of this.registry) { const consumer = registration.consumer; if ( - registration.recognizers.some(recognizer => recognizer.isEnabled) || + registration.recognizers.some( + recognizer => + recognizer.isEnabled && + // One that takes only scroll input has no press to arm. + (recognizer.allowedPointerTypes == null || + recognizer.allowedPointerTypes.size > 0), + ) || (consumer != null && consumer.isEnabled?.() !== false) ) { wanted.add(element); @@ -1545,8 +1824,10 @@ export class GestureHost { /** * 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). + * consumer, or a recognizer that claims touches and takes a finger or a + * pen. With only watchers, or only scroll-only recognizers + * (`allowedPointerTypes: []`), the host's listeners are all passive + * (`spec:AST-094` FR20, FR24). */ private hasClaimer(): boolean { for (const registration of this.registry.values()) { @@ -1554,7 +1835,10 @@ export class GestureHost { if ( (consumer != null && consumer.isEnabled?.() !== false) || registration.recognizers.some( - recognizer => recognizer.isEnabled && recognizer.claimsTouches, + recognizer => + recognizer.isEnabled && + recognizer.claimsTouches && + takesTouchContacts(recognizer), ) ) { return true; @@ -1827,6 +2111,439 @@ export class GestureHost { } } + /** + * Wheel and trackpad input (`spec:AST-094` FR43). A wheel gesture drives + * the recognizers that allow its scroll type as a contact moving with the + * content: a pan's along its own axis, after every scroller between the + * pointer and the pan's element can no longer scroll that way, as decided + * at the gesture's first wheel; a pinch's (a ctrl-wheel, as a trackpad + * pinches) as two contacts whose span follows exp(-deltaY / 100). A wheel + * the page keeps is never prevented; one a recognizer takes always is. It + * ends `WHEEL_END_MS` after its last wheel, at that wheel's time, so its + * release keeps its velocity. A gesture a recognizer took is latched to it, + * as a browser latches a scroller: while it lasts, the document hands this + * every wheel first, wherever it lands, so a pan whose element moves from + * under a still pointer keeps it (FR24: a page listener only during a + * gesture). + */ + private readonly onWheel = (event: Event): void => { + const wheel = event as WheelEvent; + if (this.wheelsSeen.has(wheel)) { + return; + } + this.wheelsSeen.add(wheel); + const time = eventTime(wheel); + const kind = wheel.ctrlKey ? 'pinch' : 'pan'; + let gesture = this.wheel; + if ( + gesture != null && + (gesture.kind !== kind || time - gesture.last >= gesture.endMs) + ) { + this.endWheel(time); + gesture = null; + } + if ( + gesture != null && + gesture.ids.length > 0 && + gesture.follow?.lifted !== true && + !this.wheelHasTaker(gesture) + ) { + // Every recognizer that took it has left (detached, disabled or + // cancelled): the rest of the gesture is the page's. A followed pan's + // own lift does not count: its momentum stays latched and prevented + // until the splitter's next gesture or the end timer, and moves + // nothing (FR43). The release is stamped at its last wheel (FR16). + this.endWheel(gesture.last); + const left = wheelPixels(wheel, this.doc.defaultView); + gesture = { + kind, + scrollType: scrollTypeOf(wheel), + ids: [], + x: wheel.clientX, + y: wheel.clientY, + span: WHEEL_PINCH_SPAN, + last: time, + endMs: wheelEndMs( + kind, + scrollTypeOf(wheel), + Math.abs(left.dx) > Math.abs(left.dy), + ), + timer: null, + takers: [], + }; + this.wheel = gesture; + } + const {dx, dy} = wheelPixels(wheel, this.doc.defaultView); + const horizontal = Math.abs(dx) > Math.abs(dy); + // The splitter reads a pan's stream only: a ctrl-wheel pinch has no + // fingers' phase or momentum to tell apart, and ends 100 ms after its + // last wheel (FR43). + let seg: WheelSegment | null = null; + if (kind === 'pan') { + seg = this.segmenter.push( + time, + horizontal ? dx : dy, + horizontal ? WHEEL_FOLLOW_END_MS : WHEEL_FOLLOW_END_V_MS, + ); + } else { + this.segmenter.end(); + } + this.wheelSegment = seg; + if (seg?.boundary != null) { + if (gesture != null) { + this.endWheel(time); + gesture = null; + } + } + if (gesture == null) { + gesture = this.beginWheel(wheel, kind, dx, dy, time); + this.wheel = gesture; + if (gesture.ids.length > 0) { + this.latchWheel(); + } + } + gesture.last = time; + if (gesture.timer != null) { + this.clock.clearTimeout(gesture.timer); + } + // A trackpad scroll waits out a slow drag's pauses (DEC-24), whoever + // keeps it; a pinch or a mouse wheel's notches 100 ms. + gesture.timer = this.clock.setTimeout(() => { + this.segmenter.end(); + this.endWheel(); + }, gesture.endMs); + if (gesture.ids.length === 0) { + return; + } + wheel.preventDefault(); + if (gesture.kind === 'pan' && gesture.follow) { + const f = gesture.follow; + if (f.lifted) { + return; + } + if (this.wheelSegment?.liftNow === true) { + // Momentum began: the fingers lifted before the held deltas. The pan + // keeps the travel it showed through the undecided stretch and lets + // go here, still moving at the fingers' speed, so its release carries + // on from it; the rest of the momentum never moves the pan. + this.followProvisionally(gesture, f, time); + f.lifted = true; + this.arena.ended([ + {id: gesture.ids[0], x: gesture.x, y: gesture.y, time}, + ]); + return; + } + if (this.wheelSegment?.decaying === true) { + // Undecided: the real deltas wait for the splitter while the + // fingers' last speed carries the pan on. + f.heldX += dx; + f.heldY += dy; + this.followProvisionally(gesture, f, time); + return; + } + // The fingers' own delta; the held ones were theirs too. What the pan + // shows ahead of their travel, or behind it, is absorbed by their moves + // half a move at a time, so it never steps back and ends on their + // travel. + const beforeX = f.trueX - f.heldX; + const beforeY = f.trueY - f.heldY; + const debtX = absorbedDebt(gesture.x - beforeX, dx); + const debtY = absorbedDebt(gesture.y - beforeY, dy); + const trueX = beforeX - dx; + const trueY = beforeY - dy; + const elapsed = time - f.lastMove; + if (elapsed > 0) { + f.speedX = (trueX - f.trueX) / elapsed; + f.speedY = (trueY - f.trueY) / elapsed; + } + f.trueX = trueX; + f.trueY = trueY; + f.heldX = 0; + f.heldY = 0; + f.lastMove = time; + gesture.x = trueX + debtX; + gesture.y = trueY + debtY; + f.baseX = gesture.x; + f.baseY = gesture.y; + this.arena.moved([ + {id: gesture.ids[0], x: gesture.x, y: gesture.y, time}, + ]); + return; + } + if (gesture.kind === 'pan') { + gesture.x -= dx; + gesture.y -= dy; + this.arena.moved([ + {id: gesture.ids[0], x: gesture.x, y: gesture.y, time}, + ]); + } else { + gesture.span *= Math.exp(-dy / 100); + this.arena.moved(this.pinchContacts(gesture, time)); + } + }; + + /** + * Through the splitter's undecided stretch, a followed pan moves on from + * where it was shown at the fingers' last move at their last speed, never + * past what that speed projects for the time since. + */ + private followProvisionally( + gesture: WheelGesture, + f: WheelFollow, + time: number, + ): void { + const elapsed = Math.max(0, time - f.lastMove); + const x = f.baseX + f.speedX * elapsed; + const y = f.baseY + f.speedY * elapsed; + if (x === gesture.x && y === gesture.y) { + return; + } + gesture.x = x; + gesture.y = y; + this.arena.moved([{id: gesture.ids[0], x, y, time}]); + } + + /** Start a wheel gesture: who takes it, decided once, or nobody. */ + private beginWheel( + wheel: WheelEvent, + kind: 'pan' | 'pinch', + dx: number, + dy: number, + time: number, + ): WheelGesture { + const type = scrollTypeOf(wheel); + const isHorizontal = Math.abs(dx) > Math.abs(dy); + const kept: WheelGesture = { + kind, + scrollType: type, + ids: [], + x: wheel.clientX, + y: wheel.clientY, + span: WHEEL_PINCH_SPAN, + last: time, + endMs: wheelEndMs(kind, type, isHorizontal), + timer: null, + takers: [], + }; + // A contact already down keeps its gesture; the wheel stays the page's. + // So does a first wheel another handler already prevented, or one that + // cannot be cancelled: no recognizer could take it from the page. + if ( + this.arena.touches.length > 0 || + (dx === 0 && dy === 0) || + wheel.defaultPrevented || + !wheel.cancelable + ) { + return kept; + } + // Every scroller under the pointer, the page included, as a finger's + // probe reads them, without touch-action, which a wheel ignores. + // What the wheel landed on: at a shadow host's listener the event's + // target is the host, and a scroller inside its tree must count. + const target = landedOn(wheel); + const scroll = createNativeScrollProbe(target, { + readsTouchAction: false, + }); + const takers: GestureRecognizer[] = []; + for ( + let node: Node | null = target instanceof Node ? target : null; + node != null; + node = parentAcrossShadow(node) + ) { + if (!(node instanceof Element)) { + continue; + } + for (const recognizer of this.registry.get(node)?.recognizers ?? []) { + // A pan follows a trackpad, never a mouse wheel's notches, which + // scroll what is under the pointer (spec:AST-094 FR43, DEC-24). + if ( + !recognizer.isEnabled || + !recognizer.claimsTouches || + !recognizer.allowedScrollTypes.has(type) || + internals(recognizer).family !== kind || + (kind === 'pan' && type !== 'continuous') + ) { + continue; + } + if ( + kind === 'pan' && + (!panTakesMove(recognizer, -dx, -dy) || + this.scrollerTakes(target, node, dx, dy, isHorizontal) || + // A scroll-edge pan only past every scroller's edge (FR19). + (isScrollEdgePan(recognizer) && scroll.canScroll(-dx, -dy))) + ) { + continue; + } + takers.push(recognizer); + } + } + if (takers.length === 0) { + return kept; + } + const base = { + pointerType: 'mouse' as const, + target, + scroll, + scrollType: type, + recognizers: takers, + consumer: null, + }; + const ids = + kind === 'pan' ? [this.nextId++] : [this.nextId++, this.nextId++]; + const gesture: WheelGesture = {...kept, ids, takers}; + if (kind === 'pan' && type === 'continuous') { + gesture.follow = { + lifted: false, + heldX: 0, + heldY: 0, + lastMove: time, + trueX: kept.x, + trueY: kept.y, + baseX: kept.x, + baseY: kept.y, + speedX: 0, + speedY: 0, + }; + } + this.arena.began( + kind === 'pan' + ? [{...base, id: ids[0], x: wheel.clientX, y: wheel.clientY, time}] + : this.pinchContacts(gesture, time).map(input => ({ + ...base, + ...input, + })), + ); + // Every recognizer it was offered to may decline it (its own + // shouldReceiveTouch): then nobody took the wheel, and it scrolls the + // page from its first step, as with no gesture system. + if (ids.every(id => this.arena.participantsOf(id).length === 0)) { + this.arena.cancelled(ids, time); + return kept; + } + return gesture; + } + + /** + * Whether a scroller between `target` and `boundary` (both included) + * scrolls this wheel now, by the edge test a finger's probe uses + * (canElementScroll), along the wheel's major axis. + */ + private scrollerTakes( + target: unknown, + boundary: Element, + dx: number, + dy: number, + isHorizontal: boolean, + ): boolean { + for ( + let node: Node | null = target instanceof Node ? target : null; + node != null; + node = parentAcrossShadow(node) + ) { + if ( + node instanceof Element && + canElementScroll(node, isHorizontal ? -dx : 0, isHorizontal ? 0 : -dy) + ) { + return true; + } + if (node === boundary) { + break; + } + } + return false; + } + + /** A ctrl-wheel pinch's two contacts, `span` apart around its center. */ + private pinchContacts( + gesture: WheelGesture, + time: number, + ): {id: number; x: number; y: number; time: number}[] { + return [ + {id: gesture.ids[0], x: gesture.x - gesture.span / 2, y: gesture.y, time}, + {id: gesture.ids[1], x: gesture.x + gesture.span / 2, y: gesture.y, time}, + ]; + } + + /** Whether a recognizer that took this wheel gesture still takes part in it. */ + private wheelHasTaker(gesture: WheelGesture): boolean { + // A cancelled or failed recognizer keeps that state until its contacts + // end, which a wheel gesture's do only here. + return gesture.takers.some(recognizer => { + const {state} = recognizer; + return ( + recognizer.isEnabled && + (state === 'began' || + state === 'changed' || + (state === 'possible' && recognizer.isInvolved())) + ); + }); + } + + /** + * Route every wheel on the page to the live wheel gesture, from its claim: + * through the shared input stream's capture listener on the window, before + * any document listener (architecture:react-component-runtime INV13), and + * non-passive only while it is subscribed. + */ + private latchWheel(): void { + const view = this.doc.defaultView; + if (this.stopWheelLatch == null && view != null) { + this.stopWheelLatch = subscribeInput(view, 'wheel', this.onWheel, { + cancels: true, + }); + } + } + + /** + * End the wheel gesture in progress. On its own timer it ends its end + * window after its last wheel; a taker leaving (a detach, a disable) + * passes that last wheel's time, and an input that ends it sooner (a + * press, the next gesture's first wheel) its own time. Both are on the wheels' time base (eventTime), never the + * host's clock, which need not share it. + */ + private endWheel(endTime?: number): void { + const gesture = this.wheel; + this.stopWheelLatch?.(); + this.stopWheelLatch = null; + if (gesture == null) { + return; + } + this.wheel = null; + if (gesture.timer != null) { + this.clock.clearTimeout(gesture.timer); + } + if (gesture.ids.length === 0 || gesture.follow?.lifted) { + return; + } + // Fingers that stop without a lift end on their travel where it runs + // ahead of what the pan shows; what the undecided stretch showed past it + // stays, as a step back would show. + if (gesture.follow != null) { + const f = gesture.follow; + const x = ahead(gesture.x, f.trueX - f.heldX, f.speedX); + const y = ahead(gesture.y, f.trueY - f.heldY, f.speedY); + if (x !== gesture.x || y !== gesture.y) { + gesture.x = x; + gesture.y = y; + this.arena.moved([{id: gesture.ids[0], x, y, time: gesture.last}]); + } + } + // A trackpad scroll whose fingers stop releases still, past the + // velocity's stillness interval, and a pinch 100 ms after its last wheel + // (`spec:AST-094` FR43, DEC-24); a lift released the pan already. Never + // later than the input that ends it (a press, the next gesture's first + // wheel): the arena's clock only moves forward, so a later stamp would + // move that input's times too (FR16). + const end = gesture.last + gesture.endMs; + const time = + endTime == null ? end : Math.max(gesture.last, Math.min(end, endTime)); + this.arena.ended( + gesture.kind === 'pan' + ? [{id: gesture.ids[0], x: gesture.x, y: gesture.y, time}] + : this.pinchContacts(gesture, time), + ); + } + /** Undo `lockSelection` for a contact that ended; idempotent. */ private unlockSelection(id: number): void { this.lockedBy.get(id)?.(); @@ -1869,6 +2586,7 @@ export class GestureHost { } private readonly onTouchStart = (event: TouchEvent): void => { + this.endWheel(eventTime(event)); 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. @@ -2554,6 +3272,7 @@ export class GestureHost { // --- Pointer events -------------------------------------------------------- private readonly onPointerDown = (event: PointerEvent): void => { + this.endWheel(eventTime(event)); // A new press: whatever click is still owed to the last one is not this. disarmClickGuard(this.doc); if (!isPointerKind(event.pointerType)) { diff --git a/packages/core/src/gestures/GestureRecognizer.ts b/packages/core/src/gestures/GestureRecognizer.ts index e68007b6b78ea..4ed8c272af099 100644 --- a/packages/core/src/gestures/GestureRecognizer.ts +++ b/packages/core/src/gestures/GestureRecognizer.ts @@ -36,6 +36,7 @@ import type { GestureTouch, Point, PointerKind, + ScrollType, } from './types'; import {DEFAULT_ENVIRONMENT} from './types'; @@ -81,6 +82,17 @@ export interface GestureRecognizerOptions { delaysTouchesEnded?: boolean; /** Pointer kinds this recognizer listens to (UIKit's `allowedTouchTypes`). @default all */ allowedPointerTypes?: ReadonlyArray; + /** + * Scroll input this recognizer takes, as UIKit's `allowedScrollTypesMask`. + * A pan follows a trackpad's two-finger scroll (`continuous`), never a + * mouse wheel's notches, as a contact moving with the content, along its + * own axis and after the scrollers under the pointer; a pinch takes a + * ctrl-wheel of either, as a trackpad pinches (`spec:AST-094` FR43, + * DEC-24). + * @default none; a pinch takes both, the screen-edge pan none whatever a + * caller passes + */ + allowedScrollTypes?: ReadonlyArray; /** Ignore a touch of a different kind than the ones already tracked. @default true */ requiresExclusiveTouchType?: boolean; /** @@ -202,6 +214,8 @@ export abstract class GestureRecognizer { delaysTouchesBegan: boolean; delaysTouchesEnded: boolean; allowedPointerTypes: ReadonlySet | null; + /** See GestureRecognizerOptions.allowedScrollTypes. */ + readonly allowedScrollTypes: ReadonlySet; requiresExclusiveTouchType: boolean; /** * The element (or any object) the recognizer is attached to. The DOM host @@ -247,6 +261,9 @@ export abstract class GestureRecognizer { options.allowedPointerTypes == null ? null : new Set(options.allowedPointerTypes); + this.allowedScrollTypes = new Set( + options.allowedScrollTypes ?? this.defaultScrollTypes, + ); this.requiresExclusiveTouchType = options.requiresExclusiveTouchType ?? true; } @@ -479,6 +496,11 @@ export abstract class GestureRecognizer { /** Clear per-gesture state before the next attempt. */ protected reset(): void {} + /** The scroll types taken when the options name none (UIKit's: none). */ + protected get defaultScrollTypes(): ReadonlyArray { + return []; + } + /** * Whether a touch may join. The base checks enabled, pointer kind and * exclusivity of kind; subclasses narrow it (a one-finger pan ignores a @@ -488,6 +510,13 @@ export abstract class GestureRecognizer { if (!this.isEnabled) { return false; } + if (touch.scrollType != null) { + // Scroll input is no pointer: only the scroll types allowed count. + return ( + this.allowedScrollTypes.has(touch.scrollType) && + (this.options.shouldReceiveTouch?.(this, touch) ?? true) + ); + } if ( this.allowedPointerTypes != null && !this.allowedPointerTypes.has(touch.pointerType) diff --git a/packages/core/src/gestures/PanGestureRecognizer.ts b/packages/core/src/gestures/PanGestureRecognizer.ts index ac21d5637a7fd..28a3f24862771 100644 --- a/packages/core/src/gestures/PanGestureRecognizer.ts +++ b/packages/core/src/gestures/PanGestureRecognizer.ts @@ -73,6 +73,14 @@ export interface PanGestureRecognizerOptions extends GestureRecognizerOptions; /** Begin only when the native scrollers under the touch are at the edge. */ scrollEdge?: boolean; } diff --git a/packages/core/src/gestures/PinchGestureRecognizer.ts b/packages/core/src/gestures/PinchGestureRecognizer.ts index 6ca75bb627714..f5f5014af137b 100644 --- a/packages/core/src/gestures/PinchGestureRecognizer.ts +++ b/packages/core/src/gestures/PinchGestureRecognizer.ts @@ -27,7 +27,12 @@ import { type GestureRecognizerOptions, type NativeTouchBehavior, } from './GestureRecognizer'; -import type {GestureEventContext, GestureTouch, Point} from './types'; +import type { + GestureEventContext, + GestureTouch, + Point, + ScrollType, +} from './types'; import {VelocityTracker} from './VelocityTracker'; export interface PinchGestureRecognizerOptions extends GestureRecognizerOptions { @@ -51,6 +56,16 @@ export class PinchGestureRecognizer extends GestureRecognizer { this.hysteresis = options.hysteresis ?? UIKIT_PINCH.hysteresis.value; } + /** @internal The host's brand: a ctrl-wheel drives a pinch (recognizerInternals). */ + protected get family(): 'pinch' { + return 'pinch'; + } + + /** A pinch takes a trackpad's pinch and ctrl and a mouse wheel (FR43). */ + protected override get defaultScrollTypes(): ReadonlyArray { + return ['continuous', 'discrete']; + } + /** Current span over the span the fingers started at. */ get scale(): number { return this.scaleValue; diff --git a/packages/core/src/gestures/README.md b/packages/core/src/gestures/README.md index 57b3bbf7a7ad5..7071f6d65e01a 100644 --- a/packages/core/src/gestures/README.md +++ b/packages/core/src/gestures/README.md @@ -452,6 +452,63 @@ 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. +## Wheel and trackpad + +A recognizer takes scroll input only where it allows that scroll type +(`allowedScrollTypes`, UIKit's `allowedScrollTypesMask`): `continuous` +for a trackpad's two-finger scroll or pinch, `discrete` for a mouse wheel's +notches. A pan follows a trackpad, never a mouse wheel (`spec:AST-094` FR43, +DEC-24): it allows only `continuous`, and none by default, so it takes a +trackpad's scroll only where its owner allows it, its element then getting +the one wheel listener, and a mouse wheel's notches always scroll what is +under the pointer. A pinch allows both, for a ctrl-wheel from a trackpad or +a mouse. The screen-edge pan takes none, as it takes no mouse, trackpad or +pen (FR43). A control's thumb, a slider's or a switch's, tracks as a control, +not a pan, and takes no wheel, so a page scroll over it never changes its +value (DEC-23). The web reports no wheel phases or device, so the deltas +tell, and what they cannot tell apart reads as a mouse wheel: a line- or +page-mode delta; a scroll with Shift held (Shift+wheel); a pixel delta a hair +off a whole pixel (a mouse wheel on Windows); a scroll with no legacy `wheelDelta` (an engine that +sends none); a delta that is not a third of its legacy `wheelDelta`; or a 120 +tick over a 40 px delta. A trackpad's precise deltas, a third of their legacy +`wheelDelta` (Chromium and WebKit), read as a trackpad. + +A wheel gesture drives a pan as a contact moving with the content, along the +pan's own axis, and only once every scroller between the pointer and the +pan's element is at its edge that way, decided at the gesture's first wheel: +a wheel the pan does not take is never prevented. A watcher +(`claimsTouches: false`) never takes one, and a gesture whose first wheel +another handler already prevented, or that cannot be cancelled, stays the +page's. A scroll-edge pan (a pull to +refresh) takes one only if no scroller under the pointer could scroll for it, +its own scroll owner and the page included, as its finger's rule reads them +(`spec:AST-094` FR19); `touch-action` narrows only a finger's pan, so a wheel +reads every scroller past it. A ctrl-wheel (a trackpad +pinch, or Control and a mouse wheel) drives a pinch, whose scale follows +`exp(-deltaY / 100)`; a pinch takes it by default and ends 100 ms after its +last wheel. While the fingers move, a trackpad scroll a pan follows ends +180 ms after its last wheel vertically and 220 ms horizontally, as a slow +drag pauses between wheels (DEC-24). A gesture's release is never stamped +later than an input that ends it sooner (FR16). + +The web reports no trackpad contact or phase either, so one splitter reads +every trackpad stream (`wheelSegmenter.ts`, DEC-24): at the fourth or later +delta of a shrinking run, once the run has lost a fifth of its size, the +fingers lifted and momentum began. Until it decides, +the shrinking deltas wait, and a pan it follows moves on at the fingers' last +speed, never past what that speed projects. At the lift the pan keeps that +travel and lets go there, still moving at that speed, and the momentum after +it never moves it; when the fingers carry on instead, their moves absorb +what the pan showed past their travel, or short of it, so it never steps +back and ends on their travel. Fingers that stop release still, once the end +time passes. In momentum, a reversal, a delta that grows +against the decay, or a 50 ms pause starts a new gesture. A finger, mouse or +pen that comes down ends it, and a wheel claim keeps the page's text +selection. A gesture a +recognizer took is latched to it, as a browser latches a scroller: while it +lasts, a document `wheel` listener routes every step to it wherever it lands, +so a pan whose element moves from under a still pointer keeps the gesture. + ## Time Every input carries its own timestamp (`event.timeStamp`), and timers are diff --git a/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts b/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts index d9ca6f5c4d1a1..5312c0d057df2 100644 --- a/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts +++ b/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts @@ -71,12 +71,13 @@ interface EdgeLayout { } /** - * A screen-edge pan is a finger's: it takes no `allowedPointerTypes` - * (`spec:AST-094` FR43). + * A screen-edge pan is a finger's: it takes no `allowedPointerTypes` and no + * `allowedScrollTypes`, so neither a mouse, a trackpad nor a pen drives it + * (`spec:AST-094` FR43). An edge pan on web input is a record change first. */ export interface ScreenEdgePanGestureRecognizerOptions extends Omit< PanGestureRecognizerOptions, - 'direction' | 'allowedPointerTypes' + 'direction' | 'allowedPointerTypes' | 'allowedScrollTypes' > { /** The edges a touch may start from. @default ['leading'] */ edges?: ReadonlyArray; @@ -110,8 +111,10 @@ export class ScreenEdgePanGestureRecognizer extends PanGestureRecognizer { 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. + // pan stays off (`spec:AST-094` FR43), whatever a caller passes, and + // it takes no wheel or trackpad scrolling. allowedPointerTypes: ['touch'], + allowedScrollTypes: [], }); this.edges = options.edges ?? ['leading']; this.edgeRegion = diff --git a/packages/core/src/gestures/__tests__/GestureHost.test.ts b/packages/core/src/gestures/__tests__/GestureHost.test.ts index 590ff8a26edd7..d4c8265481261 100644 --- a/packages/core/src/gestures/__tests__/GestureHost.test.ts +++ b/packages/core/src/gestures/__tests__/GestureHost.test.ts @@ -566,7 +566,9 @@ describe('GestureHost', () => { .filter(t => /^(touch|pointer)/.test(t)); // A fine primary pointer: nothing on the page while idle, and on the // element the one listener its press needs, plus the claiming pan's - // blocking touchmove, which FR24 counts outside that budget (FR19). + // blocking touchmove, which FR24 counts outside that budget (FR19). A pan + // takes no scroll unless its owner allows a trackpad's, so no wheel + // listener (FR43, DEC-24). const fine = new GestureHost(document, { clock, isPrimaryPointerCoarse: () => false, diff --git a/packages/core/src/gestures/__tests__/browser/bareFixture.ts b/packages/core/src/gestures/__tests__/browser/bareFixture.ts index ed73146690d74..eabd17f666ef1 100644 --- a/packages/core/src/gestures/__tests__/browser/bareFixture.ts +++ b/packages/core/src/gestures/__tests__/browser/bareFixture.ts @@ -15,6 +15,7 @@ import {getGestureHost} from '../../GestureHost'; import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer'; import {PanGestureRecognizer} from '../../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../../PinchGestureRecognizer'; import {ScreenEdgePanGestureRecognizer} from '../../ScreenEdgePanGestureRecognizer'; import {lockSelection} from '../../../utils/selectionLock'; import {TapGestureRecognizer} from '../../TapGestureRecognizer'; @@ -22,10 +23,12 @@ import {TapGestureRecognizer} from '../../TapGestureRecognizer'; declare global { interface Window { __pageTouch: number; + __wheelLog: string[]; __api: { host: ReturnType; LongPressGestureRecognizer: typeof LongPressGestureRecognizer; PanGestureRecognizer: typeof PanGestureRecognizer; + PinchGestureRecognizer: typeof PinchGestureRecognizer; ScreenEdgePanGestureRecognizer: typeof ScreenEdgePanGestureRecognizer; TapGestureRecognizer: typeof TapGestureRecognizer; lockSelection: typeof lockSelection; @@ -36,6 +39,7 @@ declare global { // The touch listeners on the window and the document, as the engine sees // them: what makes the page pay for touches. window.__pageTouch = 0; +window.__wheelLog = []; const live = new Set(); const listenerIds = new WeakMap(); let nextListenerId = 1; @@ -92,6 +96,7 @@ window.__api = { host: getGestureHost(document), LongPressGestureRecognizer, PanGestureRecognizer, + PinchGestureRecognizer, ScreenEdgePanGestureRecognizer, TapGestureRecognizer, lockSelection, diff --git a/packages/core/src/gestures/__tests__/browser/fixture.ts b/packages/core/src/gestures/__tests__/browser/fixture.ts index b10c5beddde52..d61cd87d76a32 100644 --- a/packages/core/src/gestures/__tests__/browser/fixture.ts +++ b/packages/core/src/gestures/__tests__/browser/fixture.ts @@ -14,6 +14,7 @@ * test-only */ +import {createPanGesture} from '../../createPanGesture'; import {getGestureHost} from '../../GestureHost'; import type {GestureRecognizer} from '../../GestureRecognizer'; import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer'; @@ -163,6 +164,7 @@ document.body.innerHTML = `
feed row (tap)
feed content
+
public pan (drag sideways)
page
passive control at the page's end
`; @@ -280,6 +282,19 @@ for (const id of ['edgeZone', 'edgeWide', 'edgeZoneRtl', 'edgeWideRtl']) { record(edge, () => ({x: edge.translation().x})); host.attach(element(id), edge); } +// The public pan (spec:AST-107 FR1): a drag reported as values, on its axis. +createPanGesture(element('publicPan'), { + axis: 'x', + onPan: ({state, translation}) => { + window.__gestures.push({ + name: 'publicPan', + state, + time: performance.now(), + x: translation.x, + y: translation.y, + }); + }, +}); // A control with no recognizer of its own (no blocking listener): only a // press consumer, under pan-y, as a passive switch is. diff --git a/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts b/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts index 85340e6ebc954..ed1502863b7a0 100644 --- a/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts +++ b/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts @@ -25,6 +25,16 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {build} from 'esbuild'; +import {webInputDriver, WHEEL_SKIP} from '@astryxdesign/interaction-harness'; + +/** + * Chromium's driver sends each wheel as one notch, `wheelDeltaY` -120 + * whatever its delta, which the host reads as a mouse wheel's (FR43); + * WebKit's sends a trackpad's precise deltas (`wheelDeltaY` three times the + * delta), so it carries the trackpad-only cases. + */ +const TRACKPAD_SKIP = + "Chromium's driver sends each wheel as a mouse wheel's notch (wheelDeltaY -120 whatever its delta); WebKit's driver sends a trackpad's precise deltas"; import {expect, test, type Page} from '@playwright/test'; interface Entry { @@ -400,6 +410,7 @@ test('only elements whose recognizers claim a moving touch carry a blocking touc 'card', 'onePan', 'kept', + 'publicPan', 'authorNone', 'authorPanY', 'authorPinchZoom', @@ -564,6 +575,123 @@ test('a one-way overlay pan keeps its direction from the page, and leaves the ot expect(states(await log(page), 'card')).toEqual(['failed', 'possible']); }); +test('the public pan reports a finger\u2019s drag along its axis as values (spec:AST-107 FR1)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await page.evaluate(() => { + document.getElementById('publicPan')?.scrollIntoView({block: 'center'}); + }); + const start = await center(page, 'publicPan'); + await driver.play(drag(start, {x: start.x + 120, y: start.y + 6})); + const entries = (await log(page)).filter(entry => entry.name === 'publicPan'); + expect(entries[0]?.state).toBe('began'); + expect(entries.at(-1)?.state).toBe('ended'); + // Nothing across its axis. + expect(entries.every(entry => entry.y === 0)).toBe(true); + // As in UIKit, the travel the drag needed to begin is not part of it. + expect( + entries.filter(entry => entry.state === 'changed').at(-1)?.x, + ).toBeCloseTo(120 - 10, 0); +}); + +test('the public pan is driven by a mouse drag and a pen drag where the primary pointer is fine (spec:AST-107 FR1, AST-094 FR43)', async ({ + browser, + browserName, +}) => { + // A desktop context: a fine primary pointer and no touch. + const context = await browser.newContext({ + hasTouch: false, + isMobile: false, + viewport: {width: 800, height: 900}, + }); + try { + const page = await context.newPage(); + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bundle}); + await page.evaluate(() => { + document.getElementById('publicPan')?.scrollIntoView({block: 'center'}); + }); + const start = await center(page, 'publicPan'); + const publicPan = async () => + (await log(page)) + .filter(entry => entry.name === 'publicPan') + .map(entry => entry.state); + // A mouse drag. + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + for (let step = 1; step <= 8; step++) { + await page.mouse.move(start.x + step * 15, start.y + 2); + } + await page.mouse.up(); + const byMouse = await publicPan(); + expect(byMouse[0]).toBe('began'); + expect(byMouse.at(-1)).toBe('ended'); + await clearLog(page); + // A pen drag, where the engine can make one (Chromium's input domain). + if (browserName === 'chromium') { + const cdp = await context.newCDPSession(page); + const pen = async ( + type: 'mousePressed' | 'mouseMoved' | 'mouseReleased', + x: number, + buttons: number, + ): Promise => { + await cdp.send('Input.dispatchMouseEvent', { + type, + x, + y: start.y, + button: 'left', + buttons, + clickCount: 1, + pointerType: 'pen', + }); + }; + await pen('mousePressed', start.x, 1); + for (let step = 1; step <= 8; step++) { + await pen('mouseMoved', start.x + step * 15, 1); + } + await pen('mouseReleased', start.x + 120, 0); + const byPen = await publicPan(); + expect(byPen[0]).toBe('began'); + expect(byPen.at(-1)).toBe('ended'); + } + } finally { + await context.close(); + } +}); + +test('a tap, a long press and a drag across its axis on the public pan report nothing (spec:AST-107 FR1)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await page.evaluate(() => { + document.getElementById('publicPan')?.scrollIntoView({block: 'center'}); + }); + const at = await center(page, 'publicPan'); + const finger = {...at, id: 1}; + // A tap. + await driver.play([ + {t: 0, down: [finger]}, + {t: 60, down: []}, + ]); + // A long press: still well past the long-press duration. + await driver.play([ + {t: 0, down: [finger]}, + {t: 800, down: [finger]}, + {t: 816, down: []}, + ]); + // A drag across its axis. + await driver.play(drag(at, {x: at.x, y: at.y + 60}, 20)); + expect((await log(page)).filter(entry => entry.name === 'publicPan')).toEqual( + [], + ); +}); + test('a held finger turns text selection off only where the hold does something (a pan that asks), until it lifts; a press-tracked control, a mouse and other pans keep native selection (spec:AST-094 FR41)', async ({ page, browserName, @@ -1710,6 +1838,284 @@ test.describe('under a fine primary pointer (spec:AST-094 FR43, FR24)', () => { test.describe('where the primary pointer is fine', () => { test.use({hasTouch: false, isMobile: false}); + test('a scroll-edge pan takes a trackpad scroll only at the top of the page that scrolls it, pulling the content down; mid-page the page scrolls (spec:AST-094 FR43, FR19)', async ({ + page, + browserName, + }) => { + test.skip(browserName === 'chromium', TRACKPAD_SKIP); + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bareBundle}); + await page.evaluate(() => { + const {host, PanGestureRecognizer} = window.__api; + document.body.innerHTML = + '
content
'; + const pull = new PanGestureRecognizer({ + direction: 'vertical', + scrollEdge: true, + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + pull.addTransitionListener((_recognizer, _from, to) => + window.__wheelLog.push(`pull:${to}`), + ); + host.attach(document.getElementById('content') as HTMLElement, pull); + }); + const driver = await webInputDriver(page); + test.skip(!driver.wheels, WHEEL_SKIP); + const at = {x: 100, y: 200}; + // Mid-page, a scroll up is the page's. + await page.evaluate(() => window.scrollTo(0, 400)); + await page.waitForTimeout(100); + for (let step = 0; step < 4; step += 1) { + await driver.wheel({...at, dy: -16}); + } + await page.waitForTimeout(250); + expect(await page.evaluate(() => window.scrollY)).toBeLessThan(400); + expect(await page.evaluate(() => window.__wheelLog)).toEqual([]); + // At the top, scrolling down is the page's too. + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(100); + await driver.wheel({...at, dy: 16}); + await page.waitForTimeout(250); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + expect(await page.evaluate(() => window.__wheelLog)).toEqual([]); + // At the top, pulling the content down: the pan's, and the page holds. + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(100); + for (let step = 0; step < 4; step += 1) { + await driver.wheel({...at, dy: -16}); + } + await page.waitForTimeout(250); + const pull = await page.evaluate(() => window.__wheelLog); + expect(pull[0]).toBe('pull:began'); + expect(pull).toContain('pull:ended'); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + }); + + test('a wheel gesture stays with the pan that took it when the pan moves its element from under a still cursor, as a browser latches a scroller (spec:AST-094 FR43)', async ({ + page, + browserName, + }) => { + test.skip(browserName === 'chromium', TRACKPAD_SKIP); + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bareBundle}); + await page.evaluate(() => { + const {host, PanGestureRecognizer} = window.__api; + document.body.innerHTML = + '
card
'; + const card = document.getElementById('card') as HTMLElement; + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + // The card follows the pan, as a swipe would move it. + pan.addTransitionListener((_recognizer, _from, to) => { + card.style.transform = `translateX(${pan.translation().x}px)`; + window.__wheelLog.push(`pan:${to}:${Math.round(pan.translation().x)}`); + }); + host.attach(card, pan); + }); + const driver = await webInputDriver(page); + test.skip(!driver.wheels, WHEEL_SKIP); + const card = await center(page, 'card'); + // Six 16 px steps at a still cursor: the card leaves it after two. + for (let step = 0; step < 6; step += 1) { + await driver.wheel({...card, dx: 16}); + } + await page.waitForTimeout(250); + const pan = await page.evaluate(() => + window.__wheelLog.filter(entry => entry.startsWith('pan:')), + ); + const moved = pan.filter(entry => entry.startsWith('pan:changed:')); + // Every step after the first moved the card, and none scrolled the page. + expect(moved).toHaveLength(5); + expect(pan.slice(-2).map(entry => entry.split(':')[1])).toEqual([ + 'ended', + 'possible', + ]); + expect(await page.evaluate(() => window.scrollX)).toBe(0); + }); + + /** A page with a horizontal pan that takes only a trackpad's scroll; its moves land on `window.__wheelLog`. */ + async function trackpadPanPage(page: Page) { + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bareBundle}); + await page.evaluate(() => { + const {host, PanGestureRecognizer} = window.__api; + document.body.innerHTML = + '
card
'; + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + pan.addTransitionListener((_recognizer, _from, to) => { + window.__wheelLog.push( + `pan:${to}:${pan.translation().x.toFixed(2)}:${Math.round(pan.velocity().x)}`, + ); + }); + host.attach(document.getElementById('card') as HTMLElement, pan); + // After every listener of the host's: whether each wheel reached the + // page (not prevented) or was the pan's. + window.addEventListener('wheel', event => { + window.__wheelLog.push(`wheel:${String(event.defaultPrevented)}`); + }); + }); + } + + /** The pan's log entries after `deltas`, one wheel each, and a quiet 300 ms. */ + async function trackpadRun(page: Page, deltas: number[]) { + const driver = await webInputDriver(page); + test.skip(!driver.wheels, WHEEL_SKIP); + const at = await center(page, 'card'); + for (const dx of deltas) { + await driver.wheel({...at, dx}); + } + await page.waitForTimeout(300); + const log = await page.evaluate(() => + window.__wheelLog.filter(entry => entry.startsWith('pan:')), + ); + const moves = log + .filter(entry => /^pan:(changed|ended):/.test(entry)) + .map(entry => Number(entry.split(':')[2])); + return {log, moves}; + } + + test("keeps a trackpad pan moving at the fingers' speed through the splitter's undecided stretch and lets it go at the lift still moving: no stall (spec:AST-094 FR43)", async ({ + page, + browserName, + }) => { + test.skip(browserName === 'chromium', TRACKPAD_SKIP); + await trackpadPanPage(page); + // Four steady wheels, four shrinking ones (the lift is read at the + // fourth), then momentum. + const {log, moves} = await trackpadRun( + page, + [12, 12, 12, 12, 11, 10, 9, 8, 7, 6, 5, 4], + ); + const ended = log.filter(entry => entry.startsWith('pan:ended:')); + expect(ended).toHaveLength(1); + // The fingers' travel at their last steady wheel is -38 (48 px past the + // 10 px hysteresis); the pan moved on past it through the shrinking + // wheels, at least a wheel and a half's worth, never stepping back. + for (const [i, x] of moves.entries()) { + if (i > 0) { + expect(x).toBeLessThanOrEqual(moves[i - 1]); + } + } + expect(moves.at(-1)).toBeLessThan(-38 - 18); + // Let go still moving, and the momentum after it moved nothing. + expect(Math.abs(Number(ended[0].split(':')[3]))).toBeGreaterThan(100); + expect( + log.slice(log.indexOf(ended[0]) + 1).map(entry => entry.split(':')[1]), + ).toEqual(['possible']); + // No wheel reached the page, the momentum after the lift included + // (spec:AST-094 FR43). + const wheels = await page.evaluate(() => + window.__wheelLog.filter(entry => entry.startsWith('wheel:')), + ); + expect(wheels).toEqual(Array.from({length: 12}, () => 'wheel:true')); + }); + + test('absorbs what the undecided stretch showed when the fingers carry on: never a step back, and the pan ends on their travel (spec:AST-094 FR43)', async ({ + page, + browserName, + }) => { + test.skip(browserName === 'chromium', TRACKPAD_SKIP); + await trackpadPanPage(page); + const deltas = [12, 12, 12, 12, 11, 10, 12, 12, 12, 12, 12, 12]; + const {moves} = await trackpadRun(page, deltas); + for (const [i, x] of moves.entries()) { + if (i > 0) { + expect(x).toBeLessThanOrEqual(moves[i - 1]); + } + } + const travel = deltas.reduce((sum, delta) => sum + delta, 0) - 10; + expect(moves.at(-1)).toBeCloseTo(-travel, 1); + }); + + test("a pan that takes a trackpad's scroll follows a real trackpad scroll along its axis and keeps the page still, never a mouse wheel's notches, which scroll the page; a wheel across it scrolls the page, and a pinch follows a ctrl-wheel (spec:AST-094 FR43, DEC-24)", async ({ + page, + browserName, + }) => { + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bareBundle}); + await page.evaluate(() => { + const {host, PanGestureRecognizer, PinchGestureRecognizer} = window.__api; + document.body.innerHTML = + '
strip
photo
'; + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + pan.addTransitionListener((_recognizer, _from, to) => + window.__wheelLog.push(`pan:${to}:${Math.round(pan.translation().x)}`), + ); + host.attach(document.getElementById('strip') as HTMLElement, pan); + const pinch = new PinchGestureRecognizer(); + pinch.addTransitionListener((_recognizer, _from, to) => + window.__wheelLog.push(`pinch:${to}:${pinch.scale.toFixed(2)}`), + ); + host.attach(document.getElementById('photo') as HTMLElement, pinch); + }); + const driver = await webInputDriver(page); + test.skip(!driver.wheels, WHEEL_SKIP); + const strip = await center(page, 'strip'); + for (let step = 0; step < 6; step += 1) { + await driver.wheel({...strip, dx: 8}); + } + await page.waitForTimeout(250); + const pan = await page.evaluate(() => + window.__wheelLog.filter(entry => entry.startsWith('pan:')), + ); + if (browserName === 'chromium') { + // Chromium's driver sends a mouse wheel's notches (wheelDeltaX -120 + // whatever the delta): a pan never takes them, and they scroll the page. + expect(pan).toEqual([]); + expect(await page.evaluate(() => window.scrollX)).toBeGreaterThan(0); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(250); + } else { + // WebKit's sends a trackpad's precise deltas, which the pan follows. + expect(pan[0]).toMatch(/^pan:began:/); + expect(pan.some(entry => entry.startsWith('pan:ended:'))).toBe(true); + // The content moves the way the fingers do: a scroll right drags it left. + const moved = pan.filter(entry => entry.startsWith('pan:changed:')); + expect(Number(moved.at(-1)?.split(':')[2])).toBeLessThan(0); + expect(await page.evaluate(() => window.scrollX)).toBe(0); + } + // Across the pan's axis the wheel is the page's. + await driver.wheel({...strip, dy: 60}); + await page.waitForTimeout(250); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(250); + const photo = await center(page, 'photo'); + for (let step = 0; step < 5; step += 1) { + await driver.wheel({...photo, dy: -10, ctrl: true}); + } + await page.waitForTimeout(250); + const pinch = await page.evaluate(() => + window.__wheelLog.filter(entry => entry.startsWith('pinch:')), + ); + expect(pinch[0]).toMatch(/^pinch:began:/); + expect(pinch.some(entry => entry.startsWith('pinch:ended:'))).toBe(true); + expect( + Math.max(...pinch.map(entry => Number(entry.split(':')[2]))), + ).toBeGreaterThan(1); + }); test("the host registers no page touch listener while idle and warns for nothing, though the page attaches recognizers: an element keeps only a claiming recognizer's blocking touchmove; a mouse drag pans (spec:AST-094 FR43, FR24, FR19)", async ({ page, @@ -1761,6 +2167,7 @@ test.describe('where the primary pointer is fine', () => { 'edgeWide', 'edgeZoneRtl', 'edgeWideRtl', + 'publicPan', 'slider', 'switchRow', 'onePan', diff --git a/packages/core/src/gestures/__tests__/browser/presentedFixture.ts b/packages/core/src/gestures/__tests__/browser/presentedFixture.ts index 84e24830d4ba0..cc28188bf15eb 100644 --- a/packages/core/src/gestures/__tests__/browser/presentedFixture.ts +++ b/packages/core/src/gestures/__tests__/browser/presentedFixture.ts @@ -22,7 +22,7 @@ * @position Browser fixture of presentedHitTesting.interaction.browser.spec.ts; test-only */ -import {createAnimator} from '../../../motion/animator'; +import {createMotionAnimator} from '../../../motion/animator'; import {presentedOffsetOf} from '../../../motion/presentation'; import {GestureHost} from '../../GestureHost'; import {PanGestureRecognizer} from '../../PanGestureRecognizer'; @@ -101,7 +101,7 @@ const TRAVEL = 444; */ const SPRING = {response: 6, dampingRatio: 1} as const; -const animator = createAnimator(sheet); +const animator = createMotionAnimator(sheet); const drawn = (target: Element): DOMRect => Element.prototype.getBoundingClientRect.call(target); diff --git a/packages/core/src/gestures/__tests__/createPanGesture.test.tsx b/packages/core/src/gestures/__tests__/createPanGesture.test.tsx new file mode 100644 index 0000000000000..16c7e1683bfca --- /dev/null +++ b/packages/core/src/gestures/__tests__/createPanGesture.test.tsx @@ -0,0 +1,490 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createPanGesture.test.tsx + * @input createPanGesture and usePanGesture driven by touches through the + * document's gesture host + * @output Tests for spec:AST-107 FR1 and FR6: a drag reported as values, on + * each axis, cancelled, and never for a press or a long press; one API at + * two layers; no listener left after destroy + * @position Tests of the public pan factory and its hook + */ + +import {act, render} from '@testing-library/react'; +import {StrictMode, useRef, useState} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + createPanGesture, + type PanGestureEvent, + type PanGestureOptions, +} from '../createPanGesture'; +import {usePanGesture} from '../../hooks/usePanGesture'; +import {armClickGuard, disarmClickGuard} from '../../utils/clickGuard'; +import {coarsePrimaryPointer} from './harness'; + +function touch( + type: string, + target: EventTarget, + contact: {id: number; x: number; y: number}, + time: number, + isLift = false, +): Event { + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = { + identifier: contact.id, + clientX: contact.x, + clientY: contact.y, + target, + }; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', {value: isLift ? [] : [entry]}); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); + return event; +} + +// The document's host outlives a test, so event times only move forward. +let clock = 0; +function later(): number { + clock += 10_000; + return clock; +} + +/** A finger dragging from (10, 10) by `dx`, `dy` in four 16 ms steps, then lifting. */ +function drag( + target: Element, + dx: number, + dy: number, + {lift = true, start = later()}: {lift?: boolean; start?: number} = {}, +): number { + const id = 1; + touch('touchstart', target, {id, x: 10, y: 10}, start); + for (let step = 1; step <= 4; step++) { + touch( + 'touchmove', + target, + {id, x: 10 + (dx * step) / 4, y: 10 + (dy * step) / 4}, + start + step * 16, + ); + } + if (lift) { + touch('touchend', target, {id, x: 10 + dx, y: 10 + dy}, start + 80, true); + } + return start; +} + +/** A drag through Pointer Events only, as a mouse or a pen makes it. */ +function pointerDrag( + target: Element, + pointerType: 'mouse' | 'pen', + dx: number, +): void { + const start = later(); + const send = (type: string, x: number, time: number, buttons: number) => { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 7, + pointerType, + clientX: x, + clientY: 10, + button: 0, + buttons, + }); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); + }; + send('pointerdown', 10, start, 1); + for (let step = 1; step <= 4; step++) { + send('pointermove', 10 + (dx * step) / 4, start + step * 16, 1); + } + send('pointerup', 10 + dx, start + 80, 0); +} + +/** The states in order, a run of 'changed' counted once. */ +function phases(events: PanGestureEvent[]): string[] { + return events + .map(event => event.state) + .filter((state, index, all) => index === 0 || state !== all[index - 1]); +} + +/** Every listener added to the document, the window and `element`, net of removals. */ +function trackListeners(element?: Element): () => number { + const live = new Map(); + const restores: (() => void)[] = []; + const targets: EventTarget[] = + element == null ? [document, window] : [document, window, element]; + for (const target of targets) { + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + const key = ( + type: string, + listener: unknown, + options?: boolean | EventListenerOptions, + ) => { + const capture = + typeof options === 'boolean' ? options : options?.capture === true; + return `${target === document ? 'd' : target === window ? 'w' : 'e'}:${type}:${capture}:${listenerId(listener)}`; + }; + target.addEventListener = ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ) => { + live.set(key(type, listener, options), 1); + add(type, listener, options); + }; + target.removeEventListener = ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ) => { + live.delete(key(type, listener, options)); + remove(type, listener, options); + }; + restores.push(() => { + target.addEventListener = add; + target.removeEventListener = remove; + }); + } + afterEachRestore.push(...restores); + return () => live.size; +} + +const ids = new WeakMap(); +let nextId = 0; +function listenerId(listener: unknown): number { + if ( + listener == null || + (typeof listener !== 'object' && typeof listener !== 'function') + ) { + return -1; + } + let id = ids.get(listener); + if (id == null) { + id = nextId++; + ids.set(listener, id); + } + return id; +} + +const afterEachRestore: (() => void)[] = []; +let restorePointer: () => void = () => {}; +const surfaces: HTMLElement[] = []; +const destroys: (() => void)[] = []; + +function surface(): HTMLElement { + const element = document.createElement('div'); + document.body.append(element); + surfaces.push(element); + return element; +} + +function pan(element: Element, options: PanGestureOptions) { + const gesture = createPanGesture(element, options); + destroys.push(() => gesture.destroy()); + return gesture; +} + +// A phone: the document's host listens only where the pointer is coarse. +beforeEach(() => { + restorePointer = coarsePrimaryPointer(); +}); +afterEach(() => { + for (const destroy of destroys.splice(0)) { + destroy(); + } + for (const element of surfaces.splice(0)) { + element.remove(); + } + for (const restore of afterEachRestore.splice(0)) { + restore(); + } + restorePointer(); + vi.useRealTimers(); +}); + +describe('createPanGesture (spec:AST-107 FR1, FR6)', () => { + it('reports a horizontal drag as began, changed and ended values, and hands out no recognizer', () => { + const element = surface(); + const events: PanGestureEvent[] = []; + const gesture = pan(element, { + axis: 'x', + onPan: event => events.push(event), + }); + expect(Object.keys(gesture)).toEqual(['destroy']); + drag(element, 40, 0); + expect(phases(events)).toEqual(['began', 'changed', 'ended']); + for (const event of events) { + // Exactly the record's values (spec:AST-107 FR1): no pointer kind. + expect(Object.keys(event).sort()).toEqual([ + 'state', + 'translation', + 'velocity', + ]); + expect(event.translation.y).toBe(0); + expect(event.velocity.y).toBe(0); + } + // The threshold's travel is not part of the translation: the step that + // reached 10 px begins the drag at 0, as UIKit's pan does. + expect(events[0].translation.x).toBe(0); + expect(events.at(-1)?.translation.x).toBe(30); + expect(events.at(-1)?.velocity.x).toBeGreaterThan(0); + }); + + it('is driven by a finger, a mouse and a pen alike (spec:AST-107 FR1, AST-094 FR43)', () => { + const element = surface(); + const events: PanGestureEvent[] = []; + pan(element, {axis: 'x', onPan: event => events.push(event)}); + drag(element, 60, 0); + expect(phases(events)).toEqual(['began', 'changed', 'ended']); + for (const pointerType of ['mouse', 'pen'] as const) { + events.length = 0; + pointerDrag(element, pointerType, 60); + expect(phases(events)).toEqual(['began', 'changed', 'ended']); + } + }); + + it('reports a vertical drag on the y axis, with nothing across it', () => { + const element = surface(); + const events: PanGestureEvent[] = []; + pan(element, {axis: 'y', onPan: event => events.push(event)}); + drag(element, 4, 40); + expect(phases(events)).toEqual(['began', 'changed', 'ended']); + for (const event of events) { + expect(event.translation.x).toBe(0); + expect(event.velocity.x).toBe(0); + } + expect(events.at(-1)?.translation.y).toBe(30); + expect(events.at(-1)?.velocity.y).toBeGreaterThan(0); + }); + + it('reports nothing for a drag off its axis', () => { + const element = surface(); + const onPan = vi.fn(); + pan(element, {axis: 'x', onPan}); + drag(element, 0, 40); + expect(onPan).not.toHaveBeenCalled(); + }); + + it('reports both axes without an axis', () => { + const element = surface(); + const events: PanGestureEvent[] = []; + pan(element, {onPan: event => events.push(event)}); + drag(element, 40, 20); + expect(events.at(-1)?.state).toBe('ended'); + expect(events.at(-1)?.translation.x).toBeGreaterThan(0); + expect(events.at(-1)?.translation.y).toBeGreaterThan(0); + }); + + it('reports a cancelled drag as cancelled, with no velocity', () => { + const element = surface(); + const events: PanGestureEvent[] = []; + pan(element, {axis: 'x', onPan: event => events.push(event)}); + const start = drag(element, 40, 0, {lift: false}); + touch('touchcancel', element, {id: 1, x: 50, y: 10}, start + 96, true); + expect(phases(events)).toEqual(['began', 'changed', 'cancelled']); + expect(events.at(-1)?.velocity).toEqual({x: 0, y: 0}); + }); + + it('reports nothing for a press or a long press', () => { + vi.useFakeTimers(); + const element = surface(); + const onPan = vi.fn(); + pan(element, {axis: 'x', onPan}); + // A press: down and up in place. + const press = later(); + touch('touchstart', element, {id: 1, x: 10, y: 10}, press); + touch('touchend', element, {id: 1, x: 10, y: 10}, press + 80, true); + // A long press: a finger held still well past the long-press duration. + const hold = later(); + touch('touchstart', element, {id: 2, x: 10, y: 10}, hold); + vi.advanceTimersByTime(1000); + touch('touchend', element, {id: 2, x: 11, y: 10}, hold + 1000, true); + expect(onPan).not.toHaveBeenCalled(); + }); + + it('removes every listener it added on destroy, and a destroy mid-drag cancels the drag once', () => { + const element = surface(); + const live = trackListeners(element); + const before = live(); + const events: PanGestureEvent[] = []; + const gesture = createPanGesture(element, { + axis: 'x', + onPan: event => events.push(event), + }); + const start = drag(element, 40, 0, {lift: false}); + gesture.destroy(); + expect(phases(events)).toEqual(['began', 'changed', 'cancelled']); + expect(live()).toBe(before); + // Nothing reaches it any more. + touch('touchmove', element, {id: 1, x: 80, y: 10}, start + 120); + touch('touchend', element, {id: 1, x: 80, y: 10}, start + 136, true); + const reported = events.length; + drag(element, 40, 0); + expect(events).toHaveLength(reported); + // A second destroy does nothing. + gesture.destroy(); + expect(live()).toBe(before); + }); +}); + +describe('createPanGesture on the server (spec:AST-107 FR6)', () => { + it('does no DOM work at import', async () => { + vi.resetModules(); + const touched: string[] = []; + const realDocument: Document = document; + const realMatchMedia: typeof window.matchMedia = window.matchMedia; + const documentDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'document', + ); + const matchMediaDescriptor = Object.getOwnPropertyDescriptor( + window, + 'matchMedia', + ); + Object.defineProperty(globalThis, 'document', { + configurable: true, + get(): Document { + touched.push('document'); + return realDocument; + }, + }); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + get(): typeof window.matchMedia { + touched.push('matchMedia'); + return realMatchMedia; + }, + }); + try { + const module = await import('../createPanGesture'); + expect(typeof module.createPanGesture).toBe('function'); + const hooks = await import('../../hooks/usePanGesture'); + expect(typeof hooks.usePanGesture).toBe('function'); + } finally { + if (documentDescriptor) { + Object.defineProperty(globalThis, 'document', documentDescriptor); + } + if (matchMediaDescriptor) { + Object.defineProperty(window, 'matchMedia', matchMediaDescriptor); + } + } + expect(touched).toEqual([]); + }); +}); + +function Draggable({ + axis, + onPan, +}: { + axis?: 'x' | 'y'; + onPan: (event: PanGestureEvent) => void; +}) { + const ref = useRef(null); + usePanGesture(ref, {axis, onPan}); + return
; +} + +describe('usePanGesture (spec:AST-107 FR1, FR6)', () => { + it('reports what its factory reports, through the latest onPan', () => { + const first = vi.fn(); + const events: PanGestureEvent[] = []; + const {getByTestId, rerender} = render( + , + ); + rerender( events.push(event)} />); + drag(getByTestId('surface'), 40, 0); + expect(first).not.toHaveBeenCalled(); + expect(phases(events)).toEqual(['began', 'changed', 'ended']); + expect(events.at(-1)?.translation).toEqual({x: 30, y: 0}); + }); + + it('calls onPan from no effect: a drag in progress at unmount ends without a cancelled event (architecture:react-component-runtime INV2)', () => { + const events: PanGestureEvent[] = []; + const {getByTestId, unmount} = render( + events.push(event)} />, + ); + drag(getByTestId('surface'), 40, 0, {lift: false}); + expect(phases(events)).toEqual(['began', 'changed']); + unmount(); + expect(phases(events)).toEqual(['began', 'changed']); + }); + + it('calls onPan from no effect: a drag in progress when the axis changes ends without a cancelled event (architecture:react-component-runtime INV2)', () => { + const events: PanGestureEvent[] = []; + const onPan = (event: PanGestureEvent) => events.push(event); + const {getByTestId, rerender} = render( + , + ); + drag(getByTestId('surface'), 40, 0, {lift: false}); + expect(phases(events)).toEqual(['began', 'changed']); + rerender(); + expect(phases(events)).toEqual(['began', 'changed']); + }); + + it('attaches once under StrictMode and removes every listener at unmount', () => { + const events: PanGestureEvent[] = []; + const holder = document.createElement('div'); + document.body.append(holder); + surfaces.push(holder); + // React keeps its own root listeners on the container after unmount. + // The page's once-per-document listeners go in first, so the count is + // the pan's alone: React's own, and the click guard's window click + // listener, which the guard keeps once a gesture first arms it + // (utils/clickGuard.ts). + const warmup = document.createElement('div'); + document.body.append(warmup); + surfaces.push(warmup); + render(
, {container: warmup}).unmount(); + armClickGuard(document); + disarmClickGuard(document); + const live = trackListeners(); + const before = live(); + const {getByTestId, unmount} = render( + + events.push(event)} /> + , + {container: holder}, + ); + drag(getByTestId('surface'), 40, 0); + expect(phases(events)).toEqual(['began', 'changed', 'ended']); + unmount(); + // The drag's claimed lift armed the click guard, which outlives the pan + // on purpose, to swallow the browser's click after it, until the next + // press or its window ends: end it here. + disarmClickGuard(document); + expect(live()).toBe(before); + }); + + it('follows the element when the ref moves to a new node', () => { + const events: PanGestureEvent[] = []; + function Swapping() { + const ref = useRef(null); + const [which, setWhich] = useState<'a' | 'b'>('a'); + usePanGesture(ref, {axis: 'x', onPan: event => events.push(event)}); + return ( + <> + + {which === 'a' ? ( +
+ ) : ( +
+ )} + + ); + } + const {getByTestId, getByRole} = render(); + const first = getByTestId('a'); + act(() => { + getByRole('button').click(); + }); + drag(first, 40, 0); + expect(events).toHaveLength(0); + drag(getByTestId('b'), 40, 0); + expect(phases(events)).toEqual(['began', 'changed', 'ended']); + }); +}); diff --git a/packages/core/src/gestures/__tests__/harness.ts b/packages/core/src/gestures/__tests__/harness.ts index cf68c863b8940..37f8796de614d 100644 --- a/packages/core/src/gestures/__tests__/harness.ts +++ b/packages/core/src/gestures/__tests__/harness.ts @@ -20,16 +20,49 @@ import type { TouchConsumer, } from '../types'; -/** A clock that only moves when the test says so. */ +/** + * A clock that only moves when the test says so. Its timers run only when a + * test calls `runDue`. + */ export class ManualClock implements GestureClock { time = 0; + private readonly pending = new Map< + number, + {deadline: number; callback: () => void} + >(); + private nextHandle = 1; now(): number { return this.time; } - setTimeout(): number { - return 0; + setTimeout(callback: () => void, ms: number): number { + const handle = this.nextHandle++; + this.pending.set(handle, {deadline: this.time + ms, callback}); + return handle; + } + clearTimeout(handle: unknown): void { + this.pending.delete(handle as number); + } + /** Move to `time`, running every timer due by then, earliest first. */ + runDue(time: number): void { + for (;;) { + let due: [number, {deadline: number; callback: () => void}] | null = null; + for (const entry of this.pending) { + if ( + entry[1].deadline <= time && + (due == null || entry[1].deadline < due[1].deadline) + ) { + due = entry; + } + } + if (due == null) { + break; + } + this.pending.delete(due[0]); + this.time = Math.max(this.time, due[1].deadline); + due[1].callback(); + } + this.time = time; } - clearTimeout(): void {} } export interface Harness { diff --git a/packages/core/src/gestures/__tests__/wheel.test.ts b/packages/core/src/gestures/__tests__/wheel.test.ts new file mode 100644 index 0000000000000..900ce98c488d2 --- /dev/null +++ b/packages/core/src/gestures/__tests__/wheel.test.ts @@ -0,0 +1,1302 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file wheel.test.ts + * @input A GestureHost on a manual clock, with pans and pinches that allow + * scroll input, driven by synthetic WheelEvents + * @output Tests for spec:AST-094 FR43's wheel and trackpad input: a pan + * takes scrolling only where it allows that scroll type (UIKit's + * allowedScrollTypesMask, none by default), along its own axis, after the + * scrollers under the pointer; a pinch takes a ctrl-wheel; a wheel gesture + * ends 100 ms after its last event + * @position Unit tests of the host's wheel input (GestureHost.ts) + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {GestureHost} from '../GestureHost'; +import {createNativeScrollProbe} from '../nativeScroll'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../PinchGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {ManualClock} from './harness'; + +let clock: ManualClock; +let host: GestureHost; +let detachers: (() => void)[]; + +function mount(html: string): Record { + document.body.innerHTML = html; + const elements: Record = {}; + for (const element of document.querySelectorAll('[id]')) { + elements[element.id] = element; + } + return elements; +} + +function wheel( + target: EventTarget, + init: { + x?: number; + y?: number; + dx?: number; + dy?: number; + mode?: number; + ctrl?: boolean; + /** The legacy `wheelDeltaX`/`wheelDeltaY` Chromium and WebKit send. */ + legacy?: {x: number; y: number}; + /** A trackpad's wheel: its legacy delta three times its delta. */ + trackpad?: boolean; + /** Shift held, as Shift+wheel sends. */ + shift?: boolean; + time: number; + }, +): WheelEvent { + clock.time = init.time; + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + composed: true, + clientX: init.x ?? 20, + clientY: init.y ?? 20, + deltaX: init.dx ?? 0, + deltaY: init.dy ?? 0, + deltaMode: init.mode ?? 0, + ctrlKey: init.ctrl ?? false, + shiftKey: init.shift ?? false, + }); + Object.defineProperty(event, 'timeStamp', {value: init.time}); + const legacy = + init.legacy ?? + (init.trackpad === true + ? {x: -3 * (init.dx ?? 0), y: -3 * (init.dy ?? 0)} + : null); + if (legacy != null) { + Object.defineProperty(event, 'wheelDeltaX', {value: legacy.x}); + Object.defineProperty(event, 'wheelDeltaY', {value: legacy.y}); + } + target.dispatchEvent(event); + return event; +} + +function statesOf(recognizer: PanGestureRecognizer | PinchGestureRecognizer) { + const states: string[] = []; + recognizer.addTransitionListener((_recognizer, _from, to) => states.push(to)); + return states; +} + +/** jsdom has no layout: give a scroller its horizontal metrics. */ +function scrollableX( + element: HTMLElement, + metrics: {scrollLeft: number; scrollWidth: number; clientWidth: number}, +): void { + element.style.overflowX = 'auto'; + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(element, key, {configurable: true, value}); + } +} + +/** jsdom has no layout: give a scroller its vertical metrics. */ +function scrollableY( + element: HTMLElement, + metrics: {scrollTop: number; scrollHeight: number; clientHeight: number}, +): void { + element.style.overflowY = 'auto'; + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(element, key, {configurable: true, value}); + } +} + +beforeEach(() => { + clock = new ManualClock(); + host = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + detachers = []; +}); + +afterEach(() => { + for (const detach of detachers.splice(0)) { + detach(); + } + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('wheel and trackpad input (spec:AST-094 FR43)', () => { + it('reads a right-to-left scroller under the pointer by its own range: at its start a scroll toward it is the pan’s, a scroll away from it the scroller’s', () => { + const {strip, scroller, item} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(strip, pan)); + // Right to left, scrollLeft runs from 0 at the start to minus the + // overflow: at 0 the scroller cannot scroll right any further. + scrollableX(scroller, {scrollLeft: 0, scrollWidth: 300, clientWidth: 200}); + expect( + wheel(item, {trackpad: true, dx: -20, time: 0}).defaultPrevented, + ).toBe(false); + expect(states).toEqual([]); + // The scroller's stream ends 220 ms after its last wheel (FR43); the + // next one is decided afresh. + expect( + wheel(item, {trackpad: true, dx: 20, time: 400}).defaultPrevented, + ).toBe(true); + expect( + wheel(item, {trackpad: true, dx: 20, time: 416}).defaultPrevented, + ).toBe(true); + expect(states).toEqual(['began', 'changed']); + }); + + it("reads a right-to-left scroller by a finger's edge test: one WebKit leaves a pixel past its reported range is at its end, and a scroll on toward it is the pan’s", () => { + const {strip, scroller, item} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(strip, pan)); + // A fractional width rounds the reported overflow down to 99, while + // WebKit scrolls to -100 (measured): past the reported end, so at it. + scrollableX(scroller, { + scrollLeft: -100, + scrollWidth: 299, + clientWidth: 200, + }); + expect( + wheel(item, {trackpad: true, dx: -20, time: 0}).defaultPrevented, + ).toBe(true); + expect( + wheel(item, {trackpad: true, dx: -20, time: 16}).defaultPrevented, + ).toBe(true); + expect(states).toEqual(['began', 'changed']); + }); + + it("reads a scroller's ends as a finger's probe does: 0.75 px short of its end it can still scroll, so the stream is the scroller's; within half a pixel, the pan's (spec:AST-094 FR43, FR19)", () => { + const {sheet, list, row} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(sheet, pan)); + scrollableY(list, { + scrollTop: 299.25, + scrollHeight: 400, + clientHeight: 100, + }); + expect( + createNativeScrollProbe(row, {readsTouchAction: false}).canScroll(0, -8), + ).toBe(true); + expect(wheel(row, {trackpad: true, dy: 8, time: 0}).defaultPrevented).toBe( + false, + ); + expect(states).toEqual([]); + Object.defineProperty(list, 'scrollTop', { + configurable: true, + value: 299.6, + }); + expect( + createNativeScrollProbe(row, {readsTouchAction: false}).canScroll(0, -8), + ).toBe(false); + expect( + wheel(row, {trackpad: true, dy: 8, time: 400}).defaultPrevented, + ).toBe(true); + expect( + wheel(row, {trackpad: true, dy: 8, time: 416}).defaultPrevented, + ).toBe(true); + expect(states[0]).toBe('began'); + }); + + it('finds a scroller inside a shadow tree between the pointer and the pan, from what the wheel landed on', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
'; + const scroller = shadow.getElementById('scroller') as HTMLElement; + const item = shadow.getElementById('item') as HTMLElement; + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(outer, pan)); + scrollableX(scroller, {scrollLeft: 50, scrollWidth: 300, clientWidth: 200}); + expect(wheel(item, {dx: 20, time: 0}).defaultPrevented).toBe(false); + expect(wheel(item, {dx: 20, time: 16}).defaultPrevented).toBe(false); + expect(states).toEqual([]); + }); + + it("takes a wheel on a scroll-edge pan only while no scroller under the pointer could scroll for it, its own scroll owner above it included, as a finger's (FR19)", () => { + const {owner, content} = mount( + '
', + ); + const pull = new PanGestureRecognizer({ + direction: 'vertical', + scrollEdge: true, + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pull); + detachers.push(host.attach(content, pull)); + // Mid-scroll: a scroll up is the owner's. + scrollableY(owner, {scrollTop: 50, scrollHeight: 300, clientHeight: 200}); + expect( + wheel(content, {trackpad: true, dy: -20, time: 0}).defaultPrevented, + ).toBe(false); + expect( + wheel(content, {trackpad: true, dy: -20, time: 16}).defaultPrevented, + ).toBe(false); + // At the top, a scroll down is the owner's too. + scrollableY(owner, {scrollTop: 0, scrollHeight: 300, clientHeight: 200}); + expect( + wheel(content, {trackpad: true, dy: 20, time: 200}).defaultPrevented, + ).toBe(false); + expect(states).toEqual([]); + // At the top, pulling the content down: the pan's. + expect( + wheel(content, {trackpad: true, dy: -20, time: 400}).defaultPrevented, + ).toBe(true); + expect( + wheel(content, {trackpad: true, dy: -20, time: 416}).defaultPrevented, + ).toBe(true); + expect(states).toEqual(['began', 'changed']); + }); + + it("reads a wheel's scrollers past touch-action, which only narrows a finger's pan: content with touch-action none still leaves a mid-scroll wheel to its owner", () => { + const {owner, content} = mount( + '
', + ); + const pull = new PanGestureRecognizer({ + direction: 'vertical', + scrollEdge: true, + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pull); + detachers.push(host.attach(content, pull)); + scrollableY(owner, {scrollTop: 50, scrollHeight: 300, clientHeight: 200}); + expect(wheel(content, {dy: -20, time: 0}).defaultPrevented).toBe(false); + expect(wheel(content, {dy: -20, time: 16}).defaultPrevented).toBe(false); + expect(states).toEqual([]); + }); + + it('latches a wheel gesture to the pan that took it, as a browser latches a scroller: a step that lands outside the moved element still drives the pan and is prevented, until the gesture ends', () => { + const {wrapper, card} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(card, pan)); + const added = vi.spyOn(window, 'addEventListener'); + expect( + wheel(card, {trackpad: true, dx: 16, time: 0}).defaultPrevented, + ).toBe(true); + expect( + wheel(card, {trackpad: true, dx: 16, time: 16}).defaultPrevented, + ).toBe(true); + expect(states).toEqual(['began', 'changed']); + const afterTwo = pan.translation().x; + // The card has moved out from under a still cursor: the next steps land + // on the wrapper. + for (const time of [32, 48, 64, 80]) { + expect( + wheel(wrapper, {trackpad: true, dx: 16, time}).defaultPrevented, + ).toBe(true); + } + expect(pan.translation().x).toBe(afterTwo - 64); + expect(states.at(-1)).toBe('changed'); + // One capturing, non-passive wheel listener on the window: the shared + // input stream's (architecture:react-component-runtime INV13). + expect( + (added.mock.calls as unknown[][]).filter( + call => + call[0] === 'wheel' && + (call[2] as AddEventListenerOptions | undefined)?.capture === true && + (call[2] as AddEventListenerOptions | undefined)?.passive === false, + ), + ).toHaveLength(1); + const removed = vi.spyOn(window, 'removeEventListener'); + // A followed trackpad swipe ends 220 ms after its last wheel. + clock.runDue(80 + 220); + expect(states.slice(-2)).toEqual(['ended', 'possible']); + expect( + (removed.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toHaveLength(1); + // Over: a wheel on the wrapper is the page's again. + expect( + wheel(wrapper, {trackpad: true, dx: 16, time: 400}).defaultPrevented, + ).toBe(false); + }); + + it("hears a latched wheel through the shared input stream, so an app's window-capture stopPropagation of a wheel outside the moved element cannot hand it to the page (architecture:react-component-runtime INV13)", () => { + const {card, outside} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + detachers.push(host.attach(card, pan)); + // The app stops every wheel that does not land on the card, on the + // window's capture phase. + const stop = (event: Event) => { + if (event.target !== card) { + event.stopPropagation(); + } + }; + window.addEventListener('wheel', stop, {capture: true}); + try { + wheel(card, {trackpad: true, dx: 16, time: 0}); + wheel(card, {trackpad: true, dx: 16, time: 16}); + expect(pan.state).toBe('changed'); + // The card moved from under the cursor: the next step lands outside. + const step = wheel(outside, {trackpad: true, dx: 16, time: 32}); + expect(step.defaultPrevented).toBe(true); + expect(pan.translation().x).toBeLessThan(-16); + } finally { + window.removeEventListener('wheel', stop, {capture: true}); + } + }); + + it("ends a wheel gesture whose pan is detached mid-gesture at once, and takes the window's wheel listener with it (FR24)", () => { + const {card} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const detach = host.attach(card, pan); + wheel(card, {trackpad: true, dx: 16, time: 0}); + wheel(card, {trackpad: true, dx: 16, time: 16}); + expect(pan.state).toBe('changed'); + const removed = vi.spyOn(window, 'removeEventListener'); + detach(); + // The latch leaves the shared stream, and its window listener goes with + // its last subscriber. + expect( + (removed.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toHaveLength(1); + expect( + wheel(document.body, {trackpad: true, dx: 16, time: 32}).defaultPrevented, + ).toBe(false); + }); + + it('arms nothing else for a recognizer that takes only scroll input (no pointer types): where the primary pointer is fine its element gets the wheel listener alone, and no finger, mouse or pen starts it', () => { + const {content} = mount('
'); + const added = vi.spyOn(content, 'addEventListener'); + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(content, pan)); + expect( + (added.mock.calls as unknown[][]).map(call => String(call[0])), + ).toEqual(['wheel']); + for (const pointerType of ['mouse', 'pen', 'touch']) { + for (const [type, y] of [ + ['pointerdown', 10], + ['pointermove', 40], + ['pointermove', 80], + ['pointerup', 80], + ] as const) { + content.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 7, + pointerType, + clientX: 10, + clientY: y, + buttons: type === 'pointerup' ? 0 : 1, + }), + ); + } + } + expect(states).toEqual([]); + }); + + it('makes no touch listener cancelling for a scroll-only claimer: with it beside a watcher, the touchend stays passive and the contextmenu off (spec:AST-094 FR20, FR24)', () => { + const {watched, pull} = mount( + '
', + ); + const added = vi.spyOn(window, 'addEventListener'); + detachers.push(() => added.mockRestore()); + const coarse = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + // A watcher installs the host's touch listeners, all passive (FR20). + detachers.push( + coarse.attach( + watched, + new PanGestureRecognizer({claimsTouches: false, direction: 'vertical'}), + ), + ); + added.mockClear(); + // A pull that takes only the wheel claims no touch. + detachers.push( + coarse.attach( + pull, + new PanGestureRecognizer({ + direction: 'vertical', + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }), + ), + ); + const calls = added.mock.calls as unknown as [ + string, + unknown, + AddEventListenerOptions | undefined, + ][]; + expect( + calls.filter( + ([type, , options]) => + type === 'touchend' && options?.passive === false, + ), + ).toEqual([]); + expect(calls.filter(([type]) => type === 'contextmenu')).toEqual([]); + }); + + it('listens for the wheel only where a recognizer a wheel can drive allows it: a tap that allows scroll input adds no wheel listener', () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const tap = new TapGestureRecognizer({allowedScrollTypes: ['continuous']}); + detachers.push(host.attach(box, tap)); + expect( + (added.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toEqual([]); + }); + + it("takes no scroll on a pan by default: a pan follows a trackpad's scroll only where its owner allows it, so its element gets no wheel listener (spec:AST-094 FR43, DEC-24)", () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + expect(pan.allowedScrollTypes.size).toBe(0); + expect( + (added.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toEqual([]); + expect(wheel(box, {trackpad: true, dx: 30, time: 0}).defaultPrevented).toBe( + false, + ); + expect(states).toEqual([]); + }); + + it("never takes a mouse wheel's notches on a pan, even one a caller passes 'discrete': they scroll what is under the pointer (spec:AST-094 FR43, DEC-24)", () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + // @ts-expect-error a pan takes only a trackpad's scroll + allowedScrollTypes: ['continuous', 'discrete'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + // A notch in lines (Firefox's), a whole 100 px notch, and a 120 tick over + // 40 px (Chromium's). + expect(wheel(box, {dx: 1, mode: 1, time: 0}).defaultPrevented).toBe(false); + clock.runDue(300); + expect(wheel(box, {dx: 100, time: 400}).defaultPrevented).toBe(false); + clock.runDue(700); + expect( + wheel(box, {dx: 40, legacy: {x: -120, y: 0}, time: 800}).defaultPrevented, + ).toBe(false); + expect(states).toEqual([]); + }); + + it('leaves a wheel every taker declined to the page from its first step, as with no gesture system: a pan whose shouldReceiveTouch refuses it prevents nothing (spec:AST-094 FR43, FR19)', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + // A Toast declines a wheel a scroller under it can still take. + shouldReceiveTouch: () => false, + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + const prevented = [0, 16, 32, 48, 64, 80].map( + time => wheel(box, {trackpad: true, dx: 16, time}).defaultPrevented, + ); + expect(prevented).toEqual([false, false, false, false, false, false]); + expect(states).toEqual([]); + clock.runDue(300); + // A notch, too. + expect(wheel(box, {dx: 1, mode: 1, time: 400}).defaultPrevented).toBe( + false, + ); + }); + + it('takes no wheel for a pan whose caller allows no scroll type: its element gets no wheel listener, and the page scrolls', () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: [], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + expect(added.mock.calls.map(call => call[0])).not.toContain('wheel'); + expect(wheel(box, {dx: 30, time: 0}).defaultPrevented).toBe(false); + expect(states).toEqual([]); + }); + + it('takes no wheel on a screen-edge pan, which FR43 keeps off under a mouse and a trackpad: a widened allowedScrollTypes is refused (spec:AST-094 FR43)', () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const edge = new ScreenEdgePanGestureRecognizer({ + edges: ['leading'], + // @ts-expect-error the screen-edge pan takes no allowedScrollTypes + allowedScrollTypes: ['continuous', 'discrete'], + }); + expect(edge.allowedScrollTypes.size).toBe(0); + detachers.push(host.attach(box, edge)); + expect(added.mock.calls.map(call => call[0])).not.toContain('wheel'); + expect(wheel(box, {dx: 30, time: 0}).defaultPrevented).toBe(false); + }); + + it('takes a trackpad scroll along its axis on a pan that allows continuous scrolling: it begins past 10 px, follows the content, and ends 220 ms after the last wheel, a slow swipe pausing between its wheels', () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + expect( + (added.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toEqual([['wheel', expect.any(Function), {passive: false}]]); + const events = [0, 16, 32, 48].map(time => + wheel(box, {trackpad: true, dx: 6.5, dy: 0.5, time}), + ); + expect(events.every(event => event.defaultPrevented)).toBe(true); + expect(states[0]).toBe('began'); + // The content moves the way the fingers do: a scroll right drags it left. + expect(pan.translation().x).toBeLessThan(0); + expect(states).not.toContain('ended'); + clock.runDue(48 + 219); + expect(states).not.toContain('ended'); + clock.runDue(48 + 220); + expect(states).toContain('ended'); + }); + + it("releases a trackpad's scroll with the fingers' speed where they lift into momentum, and still where they stop (spec:AST-094 FR43, DEC-24)", () => { + const {smooth, stopped} = mount( + '
', + ); + const released: Record = {}; + for (const [key, element] of [ + ['smooth', smooth], + ['stopped', stopped], + ] as const) { + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + pan.addTransitionListener((current, _from, to) => { + if (to === 'ended') { + released[key] = { + velocity: current.velocity().y, + scroll: current.scrollVelocity().y, + }; + } + }); + detachers.push(host.attach(element, pan)); + } + // A trackpad's fling: steady deltas, then shrinking ones as the fingers + // lift into momentum. It releases at the lift with the fingers' speed. + let time = 500; + for (const dy of [8, 8, 8, 8, 7.6, 7.2, 6.8, 6.4, 6, 5.6]) { + wheel(smooth, {trackpad: true, dy, time}); + time += 16; + } + clock.runDue(time + 200); + expect(Math.abs(released.smooth.velocity)).toBeGreaterThan(0); + expect(Math.abs(released.smooth.scroll)).toBeGreaterThan(0); + // Fingers that stop: the scroll ends 180 ms after its last wheel, still. + for (const at of [1500, 1516, 1532, 1548]) { + wheel(stopped, {trackpad: true, dy: 8, time: at}); + } + clock.runDue(1548 + 180); + expect(Math.abs(released.stopped.velocity)).toBe(0); + expect(Math.abs(released.stopped.scroll)).toBe(0); + }); + + it("releases a trackpad scroll whose fingers stop still whatever the host's clock reads: the release is stamped on the wheels' own times", () => { + // The host schedules a gesture's end on its clock, while the arena reads + // the wheels' own timestamps, which need not share the clock's base: a + // page whose clock a test fakes, as a sheet's tests do. + const {pad} = mount('
'); + const released: number[] = []; + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + pan.addTransitionListener((current, _from, to) => { + if (to === 'ended') { + released.push(current.velocity().y); + } + }); + detachers.push(host.attach(pad, pan)); + clock.time = 0; + for (const time of [1_000_000, 1_000_016, 1_000_032, 1_000_048]) { + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + composed: true, + clientX: 20, + clientY: 20, + deltaY: 8, + }); + Object.defineProperty(event, 'timeStamp', {value: time}); + // A trackpad's signature: a legacy delta three times the delta. + Object.defineProperty(event, 'wheelDeltaX', {value: 0}); + Object.defineProperty(event, 'wheelDeltaY', {value: -24}); + pad.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } + clock.runDue(200); + expect(released.map(Math.abs)).toEqual([0]); + }); + + it('starts a new wheel gesture after a quiet gap even when the end timer has not run yet (a blocked main thread)', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + for (const time of [0, 16, 32]) { + wheel(box, {trackpad: true, dx: 8, time}); + } + // The timer that would end it has not run: the next wheel, 250 ms later + // (past a swipe's 220 ms), ends it and begins another. + for (const time of [282, 298, 314]) { + wheel(box, {trackpad: true, dx: 8, time}); + } + expect(states.filter(state => state === 'began')).toHaveLength(2); + expect(states).toContain('ended'); + }); + + it('leaves a wheel across its axis to the page', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + const events = [0, 16, 32].map(time => + wheel(box, {trackpad: true, dy: 12, time}), + ); + expect(events.some(event => event.defaultPrevented)).toBe(false); + expect(states).toEqual([]); + }); + + it('leaves a wheel to a scroller under the pointer that can still scroll that way, for the whole wheel gesture, and takes the next one once it is at its edge', () => { + const {box, list} = mount( + '
row
', + ); + scrollableX(list, {scrollLeft: 0, scrollWidth: 600, clientWidth: 200}); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + const native = [0, 16, 32].map(time => + wheel(document.getElementById('row') as HTMLElement, { + trackpad: true, + dx: 8, + time, + }), + ); + // Decided once, at the gesture's first wheel: the list scrolls. + Object.defineProperty(list, 'scrollLeft', {configurable: true, value: 400}); + native.push( + wheel(document.getElementById('row') as HTMLElement, { + trackpad: true, + dx: 8, + time: 48, + }), + ); + expect(native.some(event => event.defaultPrevented)).toBe(false); + expect(states).toEqual([]); + clock.runDue(48 + 100); + const taken = [500, 516, 532].map(time => + wheel(document.getElementById('row') as HTMLElement, { + trackpad: true, + dx: 8, + time, + }), + ); + expect(taken.every(event => event.defaultPrevented)).toBe(true); + expect(states[0]).toBe('began'); + }); + + it("tells a mouse wheel's notches from a trackpad: a pan leaves lines and whole notches to the page and takes a trackpad's scroll (spec:AST-094 FR43, DEC-24)", () => { + const {smooth} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(smooth, pan)); + // Lines (Firefox's mouse wheel), and a whole notch in pixels (Chromium's). + expect(wheel(smooth, {dy: 3, mode: 1, time: 0}).defaultPrevented).toBe( + false, + ); + clock.runDue(200); + expect(wheel(smooth, {dy: 100, time: 300}).defaultPrevented).toBe(false); + clock.runDue(500); + expect(states).toEqual([]); + expect( + wheel(smooth, {trackpad: true, dy: 12, time: 600}).defaultPrevented, + ).toBe(true); + expect(states[0]).toBe('began'); + }); + + it('takes a ctrl-wheel on a pinch, a trackpad pinch or ctrl and a mouse wheel: the scale follows exp(-deltaY / 100), the page does not zoom, and it ends 100 ms after the last wheel', () => { + const {photo} = mount('
'); + const pinch = new PinchGestureRecognizer(); + const states = statesOf(pinch); + detachers.push(host.attach(photo, pinch)); + const events = [0, 16, 32, 48, 64].map(time => + wheel(photo, {dy: -10, ctrl: true, time}), + ); + expect(events.every(event => event.defaultPrevented)).toBe(true); + expect(states[0]).toBe('began'); + expect(pinch.scale).toBeGreaterThan(1); + expect(pinch.scale).toBeLessThanOrEqual(Math.exp(0.5) + 1e-6); + clock.runDue(64 + 100); + expect(states).toContain('ended'); + }); + + it("keeps a trackpad stream a scroller took for FR43's pause bound, not 100 ms: a slow drag that rests 130 ms at the list's end stays the list's, and the pan takes only the next stream (spec:AST-094 FR43)", () => { + const {sheet, list, row} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(sheet, pan)); + scrollableY(list, {scrollTop: 0, scrollHeight: 400, clientHeight: 100}); + const at = (time: number) => { + clock.runDue(time); + return wheel(row, {trackpad: true, dy: 8, time}); + }; + // Three wheels the list scrolls. + expect([0, 16, 32].map(at).some(event => event.defaultPrevented)).toBe( + false, + ); + // The list reaches its end; the fingers rest 130 ms and carry on: still + // the list's stream, so the pan never takes it midway. + Object.defineProperty(list, 'scrollTop', {configurable: true, value: 300}); + expect([162, 178, 194].map(at).some(event => event.defaultPrevented)).toBe( + false, + ); + expect(states).toEqual([]); + // Past the 180 ms bound the stream has ended; the next one is the pan's. + expect([600, 616].map(at).every(event => event.defaultPrevented)).toBe( + true, + ); + expect(states[0]).toBe('began'); + }); + + it('keeps a ctrl-wheel pinch out of the trackpad splitter: a pinch that slows and reverses, or rests 60 ms, stays one gesture, and ends 100 ms after its last wheel (spec:AST-094 FR43)', () => { + const {photo} = mount('
'); + const pinch = new PinchGestureRecognizer(); + const states = statesOf(pinch); + detachers.push(host.attach(photo, pinch)); + const at = (time: number, dy: number) => { + clock.runDue(time); + return wheel(photo, {dy, ctrl: true, time}); + }; + const deltas = [-3, -4, -5, -4, -3, -2.5, -2, -1.5, 1, 2, 3, 4]; + for (const [i, dy] of deltas.entries()) { + expect(at(i * 16, dy).defaultPrevented).toBe(true); + } + expect(states.filter(state => state === 'began')).toHaveLength(1); + expect(states).not.toContain('ended'); + // One pinch throughout: still magnified after the reversal, where a + // second pinch starting from 1 would read below it. + expect(pinch.scale).toBeGreaterThan(1); + clock.runDue(11 * 16 + 99); + expect(states).not.toContain('ended'); + clock.runDue(11 * 16 + 100); + expect(states.filter(state => state === 'ended')).toHaveLength(1); + // Slowing, then resting 60 ms, is still one pinch. + states.length = 0; + for (const [i, dy] of [-5, -4, -3, -2.5, -2].entries()) { + at(1000 + i * 16, dy); + } + at(1000 + 4 * 16 + 60, -2); + expect(states.filter(state => state === 'began')).toHaveLength(1); + expect(states).not.toContain('ended'); + }); + + it("keeps the page's text selection when a wheel pan claims, as a scroll does", () => { + const {box} = mount('
Some selected text
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + detachers.push(host.attach(box, pan)); + const selection = window.getSelection() as Selection; + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(box); + selection.addRange(range); + for (const time of [0, 16, 32, 48]) { + wheel(box, {trackpad: true, dx: 8, time}); + } + expect(pan.state).not.toBe('possible'); + expect(selection.rangeCount).toBe(1); + expect(box.style.getPropertyValue('user-select')).toBe(''); + }); + + it('keeps a watcher pan off the wheel: its element gets no wheel listener, and the page keeps the wheel (spec:AST-094 FR20, DEC-7)', () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const watcher = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + claimsTouches: false, + }); + const states = statesOf(watcher); + detachers.push(host.attach(box, watcher)); + expect( + (added.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toEqual([]); + expect(wheel(box, {trackpad: true, dx: 30, time: 0}).defaultPrevented).toBe( + false, + ); + expect(states).toEqual([]); + }); + + it('leaves a first wheel already prevented, or one that cannot be cancelled, to the page for its whole gesture', () => { + const {box, inner} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + // An app's own handler below the pan prevented the first wheel. + inner.addEventListener('wheel', event => event.preventDefault(), { + once: true, + }); + wheel(inner, {trackpad: true, dx: 30, time: 0}); + expect( + wheel(inner, {trackpad: true, dx: 30, time: 16}).defaultPrevented, + ).toBe(false); + expect(states).toEqual([]); + clock.runDue(400); + // A wheel the browser lets no one cancel. + const uncancelable = new WheelEvent('wheel', { + bubbles: true, + cancelable: false, + composed: true, + clientX: 20, + clientY: 20, + deltaX: 30, + }); + Object.defineProperty(uncancelable, 'timeStamp', {value: 500}); + Object.defineProperty(uncancelable, 'wheelDeltaX', {value: -90}); + Object.defineProperty(uncancelable, 'wheelDeltaY', {value: 0}); + clock.time = 500; + inner.dispatchEvent(uncancelable); + expect( + wheel(inner, {trackpad: true, dx: 30, time: 516}).defaultPrevented, + ).toBe(false); + expect(states).toEqual([]); + }); + + it("stamps a trackpad gesture's release no later than the press that ends it: a long press right after keeps its own clock (spec:AST-094 FR16)", () => { + const {box, other} = mount('
'); + detachers.push( + host.attach( + box, + new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }), + ), + ); + const press = new LongPressGestureRecognizer({name: 'press'}); + const began: number[] = []; + press.addListener(recognizer => { + if (recognizer.state === 'began') { + began.push(clock.time); + } + }); + detachers.push(host.attach(other, press)); + // A trackpad's scroll past the pan's 10 px start. + expect(wheel(box, {trackpad: true, dy: 12, time: 0}).defaultPrevented).toBe( + true, + ); + // A pen comes down 20 ms later, ending the wheel gesture. + clock.time = 20; + const down = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 5, + pointerType: 'pen', + clientX: 5, + clientY: 5, + buttons: 1, + }); + Object.defineProperty(down, 'timeStamp', {value: 20}); + other.dispatchEvent(down); + clock.runDue(519); + expect(began).toEqual([]); + clock.runDue(521); + // 500 ms after the pen landed, not after the scroll's release time. + expect(began).toHaveLength(1); + }); + + it.each(['detached', 'disabled'] as const)( + 'stamps the release of a trackpad pan %s mid-swipe at its last wheel: a long press right after keeps its own clock (spec:AST-094 FR16)', + how => { + const {box, other} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + const detach = host.attach(box, pan); + const press = new LongPressGestureRecognizer({name: 'press'}); + const began: number[] = []; + press.addListener(recognizer => { + if (recognizer.state === 'began') { + began.push(clock.time); + } + }); + detachers.push(host.attach(other, press)); + expect( + wheel(box, {trackpad: true, dy: 12, time: 0}).defaultPrevented, + ).toBe(true); + // 10 ms later the pan leaves mid-swipe, and the fingers' next wheel + // lands at 16 ms, the page's now. + clock.runDue(10); + if (how === 'detached') { + detach(); + } else { + pan.setEnabled(false); + detachers.push(detach); + } + clock.runDue(16); + expect( + wheel(box, {trackpad: true, dy: 12, time: 16}).defaultPrevented, + ).toBe(false); + // A pen comes down at 20 ms. + clock.runDue(20); + const down = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 6, + pointerType: 'pen', + clientX: 5, + clientY: 5, + buttons: 1, + }); + Object.defineProperty(down, 'timeStamp', {value: 20}); + other.dispatchEvent(down); + clock.runDue(519); + expect(began).toEqual([]); + clock.runDue(521); + // 500 ms after the pen landed, not after the scroll's end window. + expect(began).toHaveLength(1); + }, + ); + + it('removes the wheel listener when the pan is disabled or detached', () => { + const {box} = mount('
'); + const removed = vi.spyOn(box, 'removeEventListener'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const detach = host.attach(box, pan); + pan.isEnabled = false; + host.update(box); + expect(removed.mock.calls.map(call => call[0])).toContain('wheel'); + expect(wheel(box, {dx: 30, time: 0}).defaultPrevented).toBe(false); + detach(); + }); +}); + +describe("a mouse's wheel and a trackpad's lift (spec:AST-094 FR43)", () => { + /** A pan that takes only a trackpad's scroll, horizontally. */ + function trackpadPan(box: HTMLElement): PanGestureRecognizer { + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + detachers.push(host.attach(box, pan)); + return pan; + } + + for (const [name, init] of [ + [ + 'deltas a hair off whole pixels, as a mouse wheel on Windows sends', + {dx: 100.0000015}, + ], + [ + 'a legacy wheelDelta that is not three times the delta', + {dx: 10, legacy: {x: -120, y: 0}}, + ], + [ + 'a 120 legacy tick over a 40 px delta, a notch', + {dx: 40, legacy: {x: -120, y: 0}}, + ], + ] as const) { + it(`counts ${name} as a mouse's wheel: a pan that takes only a trackpad's scroll leaves it to the page`, () => { + const {box} = mount('
'); + const pan = trackpadPan(box); + const states = statesOf(pan); + for (let step = 0; step < 3; step++) { + const event = wheel(box, {...init, time: step * 16}); + expect(event.defaultPrevented).toBe(false); + } + expect(states).toEqual([]); + }); + } + + it("counts a pixel scroll that carries no legacy wheelDelta, as Firefox sends, as a mouse's wheel: a pan that takes only a trackpad's scroll leaves it to the page", () => { + const {box} = mount('
'); + const pan = trackpadPan(box); + const states = statesOf(pan); + for (let step = 0; step < 3; step++) { + const event = wheel(box, {dx: 7, time: step * 16}); + expect(event.defaultPrevented).toBe(false); + } + expect(states).toEqual([]); + }); + + it("counts a scroll with Shift held, as Shift+wheel sends, as a mouse's wheel even with a trackpad's deltas: a pan that takes only a trackpad's scroll leaves it to the page (spec:AST-094 FR43)", () => { + const {box} = mount('
'); + const pan = trackpadPan(box); + const states = statesOf(pan); + for (let step = 0; step < 3; step++) { + const event = wheel(box, { + trackpad: true, + shift: true, + dx: 7, + time: step * 16, + }); + expect(event.defaultPrevented).toBe(false); + } + expect(states).toEqual([]); + }); + + it("takes a trackpad's precise deltas, whose legacy wheelDelta is three times each", () => { + const {box} = mount('
'); + const pan = trackpadPan(box); + const states = statesOf(pan); + for (let step = 0; step < 3; step++) { + wheel(box, {dx: 7, legacy: {x: -21, y: 0}, time: step * 16}); + } + expect(states[0]).toBe('began'); + }); +}); + +describe("a trackpad scroll through the splitter's undecided stretch (spec:AST-094 FR43)", () => { + /** A horizontal pan that takes only a trackpad's scroll, and what it did. */ + function followed() { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + const seen: {state: string; x: number; velocity: number}[] = []; + pan.addTransitionListener((recognizer, _from, to) => { + seen.push({ + state: to, + x: recognizer.translation().x, + velocity: recognizer.velocity().x, + }); + }); + detachers.push(host.attach(box, pan)); + return {box, seen}; + } + + /** Each wheel 16 ms apart from 0; returns the time after the last. */ + function stream(box: HTMLElement, deltas: number[]): number { + let time = 0; + for (const dx of deltas) { + wheel(box, {trackpad: true, dx, time}); + time += 16; + } + return time; + } + + it("keeps a trackpad pan moving at the fingers' speed while the splitter is undecided, and lets go at the lift still moving at that speed: no stall", () => { + const {box, seen} = followed(); + // Four steady deltas, then four shrinking ones (the lift is read at the + // fourth), then momentum. + const end = stream(box, [10, 10, 10, 10, 9.5, 9, 8.5, 8, 7.5, 7, 6.5, 6]); + clock.runDue(end + 500); + const changed = seen.filter(entry => entry.state === 'changed'); + // The fingers' travel at their last move is -30 (40 px past the 10 px + // hysteresis); through the shrinking deltas the pan moves on 10 px a + // wheel at their speed. + expect(changed.map(entry => entry.x).slice(-4)).toEqual([ + -40, -50, -60, -70, + ]); + const ended = seen.filter(entry => entry.state === 'ended'); + expect(ended).toHaveLength(1); + expect(ended[0].x).toBeCloseTo(-70, 6); + // Released at the fingers' speed, 10 px a 16 ms wheel. + expect(ended[0].velocity).toBeCloseTo(-625, -1); + // The momentum after the lift moves nothing. + expect( + seen.slice(seen.indexOf(ended[0]) + 1).map(entry => entry.state), + ).toEqual(['possible']); + }); + + it('keeps the momentum after the lift prevented, latched to the pan it released: momentum never moves a surface or continues a gesture (spec:AST-094 FR43)', () => { + const {box, seen} = followed(); + // Four steady wheels, then sixteen shrinking ones 16 ms apart: the lift + // is read at the fourth shrinking wheel (112 ms), and twelve momentum + // wheels follow it. + const deltas = [ + 10, 10, 10, 10, 9.5, 9, 8.5, 8, 7.5, 7, 6.5, 6, 5.5, 5, 4.5, 4, 3.5, 3, + 2.5, 2, + ]; + const events = deltas.map((dx, i) => + wheel(box, {trackpad: true, dx, time: i * 16}), + ); + const lift = seen.findIndex(entry => entry.state === 'ended'); + expect(lift).toBeGreaterThan(-1); + // Every wheel was the pan's: none after the lift reached the page. + expect(events.slice(8).map(event => event.defaultPrevented)).toEqual( + Array.from({length: 12}, () => true), + ); + expect(events.every(event => event.defaultPrevented)).toBe(true); + // The momentum moved nothing and began nothing. + expect(seen.slice(lift + 1).map(entry => entry.state)).toEqual([ + 'possible', + ]); + // The fingers' next swipe, past the momentum's 50 ms pause, is a new + // gesture the pan takes. + const next = [0, 16, 32].map(t => + wheel(box, {trackpad: true, dx: 10, time: 20 * 16 + 60 + t}), + ); + expect(next.every(event => event.defaultPrevented)).toBe(true); + expect(seen.filter(entry => entry.state === 'began')).toHaveLength(2); + }); + + it('absorbs what the undecided stretch showed when the fingers carry on: never a step back, and no extra travel', () => { + const {box, seen} = followed(); + const deltas = [10, 10, 10, 10, 9.5, 9, 10, 10, 10, 10, 10, 10]; + const end = stream(box, deltas); + clock.runDue(end + 500); + const moves = seen.filter( + entry => entry.state === 'changed' || entry.state === 'ended', + ); + for (const [i, entry] of moves.entries()) { + if (i > 0) { + expect(entry.x).toBeLessThanOrEqual(moves[i - 1].x); + } + } + // The fingers' whole travel, past the 10 px hysteresis. + const travel = deltas.reduce((sum, delta) => sum + delta, 0) - 10; + expect(moves.at(-1)?.x).toBeCloseTo(-travel, 6); + }); + + it("never moves the pan past what the fingers' last speed projects through a long undecided stretch", () => { + const {box, seen} = followed(); + // Deltas that shrink too slowly to read a lift for a while. + const shrinking = [9.9, 9.8, 9.7, 9.6, 9.5, 9.4]; + stream(box, [10, 10, 10, 10, ...shrinking]); + const changed = seen.filter(entry => entry.state === 'changed'); + expect(changed.map(entry => entry.x).slice(-shrinking.length)).toEqual( + shrinking.map((_, i) => -30 - 10 * (i + 1)), + ); + }); +}); + +describe("the splitter's gestures on timed trackpad streams (spec:AST-094 FR43, DEC-24)", () => { + /** A pan that takes a trackpad's scroll, and how many gestures it began. */ + function counted(direction: 'horizontal' | 'vertical') { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction, + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + return {box, began: () => states.filter(state => state === 'began').length}; + } + + /** Fingers, then a shrinking run whose fourth delta reads the lift. */ + const SWIPE = [12, 12, 12, 12, 11, 10, 9, 8]; + + it("counts ten swipes that each start in the last one's momentum as ten gestures", () => { + const {box, began} = counted('horizontal'); + let time = 0; + for (let swipe = 0; swipe < 10; swipe++) { + // The next swipe's first delta rises against the decay. + for (const dx of [...SWIPE, 7, 6, 5]) { + clock.runDue(time); + wheel(box, {trackpad: true, dx, time}); + time += 16; + } + } + clock.runDue(time + 500); + expect(began()).toBe(10); + }); + + it('counts ten swipes whose momentum runs out as ten gestures', () => { + const {box, began} = counted('horizontal'); + let time = 0; + for (let swipe = 0; swipe < 10; swipe++) { + for (const dx of [...SWIPE, 7, 6, 5, 4, 3, 2, 1]) { + clock.runDue(time); + wheel(box, {trackpad: true, dx, time}); + time += 16; + } + time += 400; + } + clock.runDue(time + 500); + expect(began()).toBe(10); + }); + + it('counts a slow drag with 150 ms pauses as one gesture', () => { + const {box, began} = counted('vertical'); + let time = 0; + for (let stretch = 0; stretch < 6; stretch++) { + for (let step = 0; step < 5; step++) { + clock.runDue(time); + wheel(box, {trackpad: true, dy: 4, time}); + time += 16; + } + // A 150 ms pause from the stretch's last wheel to the next one's first. + time += 150 - 16; + } + clock.runDue(time + 500); + expect(began()).toBe(1); + }); +}); diff --git a/packages/core/src/gestures/__tests__/wheelSegmenter.test.ts b/packages/core/src/gestures/__tests__/wheelSegmenter.test.ts new file mode 100644 index 0000000000000..340381ec3269a --- /dev/null +++ b/packages/core/src/gestures/__tests__/wheelSegmenter.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file wheelSegmenter.test.ts + * @input A WheelSegmenter fed deltas along one axis with their times + * @output Tests for how a wheel stream splits into gestures and where a + * trackpad's fingers lift (spec:AST-094 FR43) + * @position Unit tests of wheelSegmenter.ts + */ + +import {describe, expect, it} from 'vitest'; +import {WheelSegmenter} from '../wheelSegmenter'; + +/** Push `deltas` 16 ms apart from `from`, with a 180 ms finger pause. */ +function feed(segmenter: WheelSegmenter, deltas: number[], from = 0) { + return deltas.map((delta, i) => segmenter.push(from + i * 16, delta, 180)); +} + +describe('WheelSegmenter (spec:AST-094 FR43)', () => { + it('starts a gesture at the first wheel, and again after a finger pause of idleMs', () => { + const segmenter = new WheelSegmenter(); + expect(segmenter.push(0, 10, 180).boundary).toBe('first'); + expect(segmenter.push(16, 10, 180).boundary).toBeNull(); + expect(segmenter.push(195, 10, 180).boundary).toBeNull(); + const after = segmenter.push(375, 10, 180); + expect(after.boundary).toBe('idle'); + expect(after.gesture).toBe(2); + }); + + it('reads the lift where a run of shrinking deltas has lost a fifth of its size, and marks the shrinking ones before it as held', () => { + const seen = feed( + new WheelSegmenter(), + [10, 10, 10, 10, 9.5, 9, 8.5, 8, 7.5], + ); + expect(seen.map(segment => segment.liftNow)).toEqual([ + false, + false, + false, + false, + false, + false, + false, + true, + false, + ]); + expect(seen.map(segment => segment.decaying)).toEqual([ + false, + false, + false, + false, + true, + true, + true, + false, + false, + ]); + expect(seen[7].phase).toBe('momentum'); + expect(seen[8].boundary).toBeNull(); + }); + + it('keeps a slow finger, whose deltas shrink and grow again, in its finger phase', () => { + const seen = feed(new WheelSegmenter(), [10, 10, 10, 9, 8, 9, 10, 9, 8, 9]); + expect(seen.some(segment => segment.liftNow)).toBe(false); + expect(seen.every(segment => segment.phase === 'finger')).toBe(true); + }); + + for (const [name, next, at] of [ + ['reversal', -5, 16], + ['gap', 7, 50], + ['jump', 20, 16], + ] as const) { + it(`starts a new gesture in momentum at a ${name}: momentum never ${name === 'gap' ? 'pauses' : name === 'jump' ? 'grows' : 'reverses'}`, () => { + const segmenter = new WheelSegmenter(); + const seen = feed(segmenter, [10, 10, 10, 10, 9.5, 9, 8.5, 8]); + expect(seen[7].liftNow).toBe(true); + const segment = segmenter.push(7 * 16 + at, next, 180); + expect(segment.boundary).toBe(name); + expect(segment.phase).toBe('finger'); + expect(segment.gesture).toBe(2); + }); + } + + it('starts a gesture at the next wheel once the last one ended on its own', () => { + const segmenter = new WheelSegmenter(); + feed(segmenter, [10, 10]); + segmenter.end(); + expect(segmenter.push(40, 10, 180).boundary).toBe('idle'); + }); +}); diff --git a/packages/core/src/gestures/createPanGesture.doc.mjs b/packages/core/src/gestures/createPanGesture.doc.mjs new file mode 100644 index 0000000000000..b9a5c473474ae --- /dev/null +++ b/packages/core/src/gestures/createPanGesture.doc.mjs @@ -0,0 +1,145 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').HookDoc} */ +export const docs = { + name: 'createPanGesture', + displayName: 'createPanGesture', + // A factory documented as a hook: listed with the utilities, beside its + // hook, not as a tile in the components gallery. + group: 'Utilities', + keywords: [ + 'pan', + 'drag', + 'gesture', + 'touch', + 'swipe', + 'translation', + 'velocity', + 'pointer', + 'vanilla', + 'factory', + ], + params: [ + { + name: 'element', + type: 'Element', + description: + 'The element to drag. The pan writes the `touch-action` its axis needs on it and nothing else; it moves nothing itself.', + required: true, + }, + { + name: 'options', + type: 'PanGestureOptions', + description: 'Configuration object.', + required: true, + }, + { + name: 'options.axis', + type: "'x' | 'y'", + description: + 'Limits the drag to one axis. A drag along the other axis is not reported, and that component of every value reads 0, so a scroll across it stays the page’s. Omit it to report both axes; a scroll that starts on the element then moves the element instead of the page.', + required: false, + }, + { + name: 'options.onPan', + type: '(event: PanGestureEvent) => void', + description: + "Called with `{state, translation, velocity}` as the drag begins, changes, ends at the release, or is cancelled. `state` is 'began', 'changed', 'ended' or 'cancelled'. `translation` is the movement since the drag began, in pixels, without the travel the drag needed to begin, so an element that follows it does not jump. `velocity` is in pixels per second; at 'ended' it is the release velocity, 0 when the finger rested before lifting, and at 'cancelled' it is 0.", + required: true, + }, + ], + returns: [ + { + name: 'destroy', + type: '() => void', + description: + 'Removes every listener the pan added and its `touch-action`. A drag in progress is cancelled: `onPan` hears `cancelled`.', + }, + ], + usage: { + description: + "Reports a drag on an element as values, without React, for a surface the product moves itself: a card that follows the finger, a drawer pulled by its handle. A finger, a mouse and a pen all drive it. The drag claims its touch at UIKit’s 10 pt, as `UIPanGestureRecognizer` does, so a tap stays a tap and a press or a long press is never reported as a pan. Use the `usePanGesture` hook in React. Where a component already offers the behavior, use its prop instead: BottomSheet's `snapPoints`, Carousel's `hasSnap`.", + bestPractices: [ + { + guidance: true, + description: + 'Move the element from `translation` added to where it was when the drag began, so it moves with the finger from the moment the drag claims it.', + }, + { + guidance: true, + description: + 'At `ended`, hand `velocity` to the animation that settles the element, so a flick carries on at the speed it was released.', + }, + { + guidance: true, + description: + 'Call `destroy` when the element leaves the page, so no listener outlives it.', + }, + { + guidance: false, + description: + 'Read the element’s layout on every `changed` event to place it; `translation` already says where the finger is.', + }, + ], + accessibility: [ + { + name: 'Keyboard and assistive technology', + category: 'Keyboard', + criterion: '2.5.7 Dragging Movements', + description: + 'A drag has no keyboard or assistive-technology path of its own: whatever the drag does needs another way to do it, such as buttons, arrow keys or a menu.', + }, + { + name: 'Reduce Motion', + category: 'Content', + criterion: '2.3.3 Animation from Interactions', + description: + 'The pan moves nothing itself, so Reduce Motion applies to the motion you drive from it: a settle or a throw goes to its end without travel when the person asks for reduced motion.', + }, + ], + }, + relatedHooks: ['usePanGesture'], + importPath: '@astryxdesign/core', + category: 'interaction', +}; + +/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */ +export const docsDense = { + description: + 'Reports a drag on an element as values ({state, translation, velocity}), without React; a finger, a mouse and a pen all drive it. Returns {destroy}.', + paramDescriptions: { + element: 'element to drag; the pan writes only its touch-action.', + options: 'config object.', + 'options.axis': + "'x' | 'y': limit to one axis; the other is never reported and reads 0.", + 'options.onPan': + "({state, translation, velocity}): state 'began' | 'changed' | 'ended' | 'cancelled'; translation px since the drag began (no jump); velocity px/s, release velocity at 'ended', 0 at 'cancelled'.", + }, + returnDescriptions: { + destroy: + '() => void; removes every listener and the touch-action, cancels a drag in progress.', + }, + usage: { + description: + 'Drag as values without React, for a surface you move. Claims at UIKit’s 10 pt. No keyboard path of its own (WCAG 2.5.7); Reduce Motion applies to what you animate. Prefer component props (BottomSheet snapPoints, Carousel hasSnap); in React, usePanGesture.', + bestPractices: [ + { + guidance: true, + description: + 'Move from translation + the offset at began, so the element moves with the finger from the claim.', + }, + { + guidance: true, + description: 'Hand the ended velocity to the settling animation.', + }, + { + guidance: true, + description: 'Call destroy when the element leaves the page.', + }, + { + guidance: false, + description: 'Read layout on every changed event; use translation.', + }, + ], + }, +}; diff --git a/packages/core/src/gestures/createPanGesture.ts b/packages/core/src/gestures/createPanGesture.ts new file mode 100644 index 0000000000000..2026e8d23b38c --- /dev/null +++ b/packages/core/src/gestures/createPanGesture.ts @@ -0,0 +1,112 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createPanGesture.ts + * @input An element and a pan listener + * @output Exports createPanGesture and its types: the public pan factory + * (spec:AST-107 FR1, FR6) + * @position Public face of packages/core/src/gestures. It reports a drag as + * values and hands out no recognizer; `usePanGesture` is its React layer. + * + * Under it is a `PanGestureRecognizer` on the document's gesture host, so + * the drag claims its touch at UIKit's 10 pt and the host writes the + * element's `touch-action` for the axis. `translation` is the recognizer's + * own: the travel the drag needed to begin is not part of it, so an element + * that follows it does not jump (measured in the UIKit lab). Importing this + * module does no DOM work; the host is reached only when a pan is created. + * SYNC: When modified, update ../hooks/usePanGesture.ts, its .doc.mjs and + * __tests__/createPanGesture.test.tsx. + */ + +import {getGestureHost} from './GestureHost'; +import {PanGestureRecognizer} from './PanGestureRecognizer'; +import type {Point} from './types'; + +/** Where a drag is: it began, moved, ended at the release, or was cancelled. */ +export type PanGestureState = 'began' | 'changed' | 'ended' | 'cancelled'; + +export interface PanGestureEvent { + state: PanGestureState; + /** + * Movement since the drag began, in CSS px. The travel the drag needed to + * begin is not part of it, so an element that follows it does not jump. + */ + translation: {x: number; y: number}; + /** + * Velocity in px/s. At `ended` it is the release velocity, 0 when the + * finger rested before lifting; at `cancelled` it is 0. + */ + velocity: {x: number; y: number}; +} + +export interface PanGestureOptions { + /** Limit the drag to one axis: a drag along the other is not reported, and that component reads 0. */ + axis?: 'x' | 'y'; + /** Called as the drag begins, changes, ends, or is cancelled. */ + onPan: (event: PanGestureEvent) => void; +} + +export interface PanGesture { + /** Remove every listener the pan added; a drag in progress is cancelled. */ + destroy(): void; +} + +const ZERO = {x: 0, y: 0}; + +/** + * Report drags on `element` as values. + * @example + * ``` + * const pan = createPanGesture(card, { + * axis: 'x', + * onPan: ({state, translation}) => { + * card.style.translate = state === 'changed' ? `${translation.x}px` : ''; + * }, + * }); + * // Later: + * pan.destroy(); + * ``` + */ +export function createPanGesture( + element: Element, + options: PanGestureOptions, +): PanGesture { + const {axis, onPan} = options; + const recognizer = new PanGestureRecognizer({ + name: 'pan', + direction: axis === 'x' ? 'horizontal' : axis === 'y' ? 'vertical' : 'any', + }); + const along = (point: Point) => + axis === 'x' + ? {x: point.x, y: 0} + : axis === 'y' + ? {x: 0, y: point.y} + : {x: point.x, y: point.y}; + recognizer.addListener(() => { + const state = recognizer.state; + if ( + state !== 'began' && + state !== 'changed' && + state !== 'ended' && + state !== 'cancelled' + ) { + return; + } + onPan({ + state, + translation: along(recognizer.translation()), + velocity: + state === 'cancelled' ? {...ZERO} : along(recognizer.velocity()), + }); + }); + let detach: (() => void) | null = getGestureHost( + element.ownerDocument, + ).attach(element, recognizer); + return { + destroy() { + const current = detach; + detach = null; + current?.(); + }, + }; +} diff --git a/packages/core/src/gestures/nativeScroll.ts b/packages/core/src/gestures/nativeScroll.ts index 0ff1c5440bb45..bbc6ced05beaa 100644 --- a/packages/core/src/gestures/nativeScroll.ts +++ b/packages/core/src/gestures/nativeScroll.ts @@ -4,7 +4,8 @@ * @file nativeScroll.ts * @input An element a touch landed on * @output Exports createNativeScrollProbe (the NativeScrollProbe a host gives - * each touch) and parseTouchAction + * each touch, and each wheel gesture without the touch-action narrowing) + * and parseTouchAction * @position DOM half of the scroll coordination in packages/core/src/gestures * * Answers one question for a recognizer without letting it touch the DOM: @@ -82,7 +83,7 @@ interface Chain { readonly allowsPan: boolean; } -function buildChain(target: Element): Chain { +function buildChain(target: Element, readsTouchAction: boolean): Chain { const doc = target.ownerDocument; const view = doc.defaultView; if (view == null) { @@ -103,9 +104,11 @@ function buildChain(target: Element): Chain { continue; } const style = view.getComputedStyle(node); - const pans = parseTouchAction(style.touchAction || 'auto'); - allowX &&= pans.x; - allowY &&= pans.y; + if (readsTouchAction) { + const pans = parseTouchAction(style.touchAction || 'auto'); + allowX &&= pans.x; + allowY &&= pans.y; + } const isRoot = node === root || node === doc.documentElement; if (isRoot) { reachedRoot = true; @@ -196,7 +199,10 @@ function canMoveY(element: Element, dy: number): boolean { element.scrollHeight - element.clientHeight - EDGE_TOLERANCE + 0.5; } -function canMoveX(entry: ChainEntry, dx: number): boolean { +function canMoveX( + entry: Pick, + dx: number, +): boolean { const {element} = entry; const overflow = element.scrollWidth - element.clientWidth; if (overflow <= EDGE_TOLERANCE) { @@ -210,13 +216,47 @@ function canMoveX(entry: ChainEntry, dx: number): boolean { : element.scrollLeft < max - EDGE_TOLERANCE + 0.5; } -/** A probe of the native scrollers under `target`. */ -export function createNativeScrollProbe(target: unknown): NativeScrollProbe { +/** + * Whether `element` itself scrolls natively now for a finger moving by + * (dx, dy), by the probe's own test of each scroller: overflow that scrolls + * on that axis, room past the edge tolerance, and a position short of the + * end it moves toward, a right-to-left range included. A wheel moves the + * content the other way: pass its deltas negated. + */ +export function canElementScroll( + element: Element, + dx: number, + dy: number, +): boolean { + const view = element.ownerDocument.defaultView; + if (view == null) { + return false; + } + const style = view.getComputedStyle(element); + if (dy !== 0 && SCROLLABLE.has(style.overflowY) && canMoveY(element, dy)) { + return true; + } + return ( + dx !== 0 && + SCROLLABLE.has(style.overflowX) && + canMoveX({element, isRtl: isRtlElement(element as HTMLElement)}, dx) + ); +} + +/** + * A probe of the native scrollers under `target`. A wheel scrolls whatever + * `touch-action` says, which narrows only a finger's pan: a wheel's probe + * passes `readsTouchAction: false`. + */ +export function createNativeScrollProbe( + target: unknown, + {readsTouchAction = true}: {readsTouchAction?: boolean} = {}, +): NativeScrollProbe { let chain: Chain | null = null; const getChain = (): ChainEntry[] => { chain ??= target instanceof Element - ? buildChain(target) + ? buildChain(target, readsTouchAction) : {entries: [], allowsPan: false}; return chain.entries; }; diff --git a/packages/core/src/gestures/recognizerInternals.ts b/packages/core/src/gestures/recognizerInternals.ts index dd9a20547cf03..53f6f439aa0d5 100644 --- a/packages/core/src/gestures/recognizerInternals.ts +++ b/packages/core/src/gestures/recognizerInternals.ts @@ -28,7 +28,7 @@ import type {GestureEventContext, GestureState, GestureTouch} from './types'; * never imports a recognizer class: a page ships only the recognizers it * constructs. */ -export type RecognizerFamily = 'tap' | 'longPress' | 'pan'; +export type RecognizerFamily = 'tap' | 'longPress' | 'pan' | 'pinch'; /** The members of a recognizer only the gesture system calls. */ export interface RecognizerInternals { diff --git a/packages/core/src/gestures/types.ts b/packages/core/src/gestures/types.ts index d50f5c1391a9c..ea9cad4526d22 100644 --- a/packages/core/src/gestures/types.ts +++ b/packages/core/src/gestures/types.ts @@ -21,6 +21,14 @@ export type GestureState = /** What made the contact. A pen on an iPad is `pen`, not `touch`. */ export type PointerKind = 'touch' | 'mouse' | 'pen'; +/** + * Scroll input a recognizer can take, as UIKit's `UIScrollTypeMask`: a + * trackpad's two-finger scroll or pinch (`continuous`), or a mouse wheel's + * notches (`discrete`). A pan takes only a trackpad's; a pinch takes a + * ctrl-wheel of either (`spec:AST-094` FR43, DEC-24). + */ +export type ScrollType = 'continuous' | 'discrete'; + export interface Point { readonly x: number; readonly y: number; @@ -85,6 +93,8 @@ export interface GestureTouch { readonly target: unknown; /** The native scrollers under the contact, when the host knows them. */ readonly scroll: NativeScrollProbe | null; + /** Set for a wheel's or a trackpad's scroll input, which is no contact. */ + readonly scrollType?: ScrollType; /** The browser took this contact for its own pan or zoom. */ takenByBrowser: boolean; } @@ -104,6 +114,8 @@ export interface TouchBeganInput extends TouchInput { readonly pointerType: PointerKind; readonly target: unknown; readonly scroll?: NativeScrollProbe | null; + /** Set for a wheel's or a trackpad's scroll input (FR43). */ + readonly scrollType?: ScrollType; } /** What a recognizer is told alongside the touches that changed. */ diff --git a/packages/core/src/gestures/wheelSegmenter.ts b/packages/core/src/gestures/wheelSegmenter.ts new file mode 100644 index 0000000000000..a0339e7a3c026 --- /dev/null +++ b/packages/core/src/gestures/wheelSegmenter.ts @@ -0,0 +1,112 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file wheelSegmenter.ts + * @input A wheel stream's deltas along one axis, with their event times + * @output Exports WheelSegmenter: splits the stream into gestures and reads + * a trackpad's lift (the onset of momentum) + * @position Read by GestureHost.ts's wheel input (`spec:AST-094` FR43): the + * web reports no trackpad contact or phase, so both are inferred. + * + * A trackpad gesture has a finger phase (deltas while the fingers move, + * possibly with pauses when they move slowly) and, after a fling, a momentum + * phase (deltas at frame rate that shrink smoothly). The lift is read as the + * start of a run of shrinking deltas that has lost a fifth of its size. In + * the finger phase, a pause of `idleMs` ends the gesture. In the momentum + * phase, a reversal, a jump up against the decay, or a pause of 50 ms starts + * a new gesture: momentum never pauses, reverses or grows. + */ + +export type WheelBoundary = 'first' | 'idle' | 'reversal' | 'jump' | 'gap'; +export type WheelPhase = 'finger' | 'momentum'; + +export interface WheelSegment { + /** Why this event starts a new gesture, or null when it continues one. */ + readonly boundary: WheelBoundary | null; + readonly phase: WheelPhase; + /** The gesture's number, counting from 1. */ + readonly gesture: number; + /** This event is where the fingers lifted (momentum begins with it). */ + readonly liftNow: boolean; + /** This event shrank, so it may be momentum: hold it until known. */ + readonly decaying: boolean; +} + +/** Momentum arrives at frame rate: a pause this long ends it. */ +const MOMENTUM_GAP_MS = 50; +/** Shrinking deltas in a row before the lift is read. */ +const LIFT_RUN = 4; +/** And how much of its size the run has lost by then. */ +const LIFT_DECAY = 0.8; + +export class WheelSegmenter { + gesture = 0; + phase: WheelPhase = 'finger'; + private last = -Infinity; + private previous = 0; + private sign = 0; + private count = 0; + private run = 0; + private runStart = 0; + + push(time: number, delta: number, idleMs: number): WheelSegment { + const size = Math.abs(delta); + const sign = Math.sign(delta); + const gap = time - this.last; + let boundary: WheelBoundary | null = null; + if (this.count === 0) { + boundary = this.gesture === 0 ? 'first' : 'idle'; + } else if (this.phase === 'finger' && gap >= idleMs) { + boundary = 'idle'; + } else if (this.phase === 'momentum') { + if (sign !== 0 && this.sign !== 0 && sign !== this.sign) { + boundary = 'reversal'; + } else if (gap >= MOMENTUM_GAP_MS) { + boundary = 'gap'; + } else if (size >= 2 && size > this.previous * 1.3 + 0.5) { + boundary = 'jump'; + } + } + if (boundary != null) { + this.gesture += 1; + this.phase = 'finger'; + this.count = 0; + this.run = 0; + } + let liftNow = false; + let decaying = false; + if (this.phase === 'finger' && this.count >= 3 && size > 0) { + decaying = size <= this.previous * 0.995 && size >= this.previous * 0.5; + if (decaying) { + if (this.run === 0) { + this.runStart = this.previous; + } + this.run += 1; + } else { + this.run = 0; + } + if (this.run >= LIFT_RUN && size <= this.runStart * LIFT_DECAY) { + this.phase = 'momentum'; + liftNow = true; + } + } + this.count += 1; + this.previous = size; + if (sign !== 0) { + this.sign = sign; + } + this.last = time; + return { + boundary, + phase: this.phase, + gesture: this.gesture, + liftNow, + decaying: decaying && !liftNow, + }; + } + + /** The gesture ended on its own (its timer): the next event starts one. */ + end(): void { + this.count = 0; + } +} diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index 051bca2be4878..c10e8c3664d6d 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -133,6 +133,15 @@ export type { UseInteractiveRoleOptions, } from './useInteractiveRole'; +export {useAnimator} from './useAnimator'; + +export {useDragToSnapPoints} from './useDragToSnapPoints'; +export type {UseDragToSnapPointsResult} from './useDragToSnapPoints'; +export type { + DragToSnapPoints, + DragToSnapPointsOptions, +} from '../dragToSnapPoints/createDragToSnapPoints'; + export {useLongPress} from './useLongPress'; export type {UseLongPressOptions, UseLongPressHandlers} from './useLongPress'; @@ -148,3 +157,11 @@ export type { ContainerRevealOptions, ContentRevealOptions, } from './useContainerReveal'; + +export {usePanGesture} from './usePanGesture'; +export type { + PanGesture, + PanGestureEvent, + PanGestureOptions, + PanGestureState, +} from '../gestures/createPanGesture'; diff --git a/packages/core/src/hooks/useAnimator.doc.mjs b/packages/core/src/hooks/useAnimator.doc.mjs new file mode 100644 index 0000000000000..d1dbc0bbd18ed --- /dev/null +++ b/packages/core/src/hooks/useAnimator.doc.mjs @@ -0,0 +1,109 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').HookDoc} */ +export const docs = { + name: 'useAnimator', + displayName: 'useAnimator', + keywords: [ + 'animate', + 'animation', + 'animator', + 'spring', + 'decay', + 'momentum', + 'fling', + 'motion', + 'transform', + 'opacity', + 'drag', + 'card', + ], + params: [ + { + name: 'ref', + type: 'RefObject', + description: + 'The ref of the one element to animate, the same ref you pass to the element: another hook, such as a gesture hook, can take it too, so a drag and an animation share one element. After every render the animator follows the element behind it. When the element unmounts its motion stops and its values are kept, so a remount resumes from them; a node that replaces it takes them over. Before the element mounts, and while it is unmounted, every call does nothing and `animate` and `decay` resolve at once.', + required: true, + }, + ], + returns: [ + { + name: 'animator', + type: 'AnimatorHandle', + description: + "The element's animator, the same object on every render: `animate(target, {velocity?})` springs channels to a target on the state-change motion Astryx's components use, `decay(velocity)` coasts them from a release velocity, `set(values)` jumps them, `get()` and `getVelocity()` read them, and `stop()` halts them. The channels are `x` and `y` (px), `scale` (1 at the element's size), `rotate` (degrees) and `opacity` (0 to 1). `animate` and `decay` return a promise that resolves when the motion ends. Calling it never re-renders the component. Calls before the ref attaches do nothing and resolve at once.", + }, + ], + usage: { + description: + "Animates one element with the motion Astryx's own components use. Call `animator.animate` to spring an element to new values on the state-change motion, `animator.decay` to fling it with the momentum of a released scroll view, and `animator.set` to move it directly, so a card can follow a finger, snap back and be thrown away. To animate an area's layout as its items change, use `useAnimatedLayout`; for what a component already animates, such as a sheet's detents or a carousel's snapping, use the component's own props. Outside React, use `createAnimator`.", + bestPractices: [ + { + guidance: true, + description: + 'Call the animator from event handlers and effects: it lives outside render, so animating never re-renders the component.', + }, + { + guidance: true, + description: + 'Start every `animate` that follows a drag with the release velocity, so the element carries on as it was thrown.', + }, + { + guidance: true, + description: + 'Reach every state an animation shows without the animation too: a key, a button or a menu item that calls the same code.', + }, + { + guidance: false, + description: + 'Keep the animated values in React state: read them with `animator.get()` when you need them.', + }, + ], + accessibility: [ + { + name: 'Keyboard and assistive technology', + category: 'Keyboard', + description: + 'An animator moves an element; it adds no way to act. Give every action an animation stands for a control a keyboard and a screen reader can reach, such as a button that discards a card, and have it call `animate`, which Reduce Motion turns into a fade in place. Keep `decay` for a throw the person made: Reduce Motion keeps the momentum of a coast, as it continues that throw. Keep focus on an element that stays on screen: move it before you fling its element away.', + }, + { + name: 'Reduce Motion', + category: 'Content', + criterion: '2.3.3 Animation from Interactions', + description: + 'When the person asks for reduced motion, every call is planned for them. On a touch screen a move or a scale becomes a short opacity cross-fade: the element fades out where it was and fades back in where it lands. With a mouse it lands at once. A decay of `x` and `y` keeps its momentum, as it continues the throw the person made; a decay of `scale`, `rotate` or `opacity` is planned like a move. No option turns this off.', + }, + ], + }, + relatedHooks: ['useAnimatedLayout'], + importPath: '@astryxdesign/core/hooks', + category: 'interaction', +}; + +/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */ +export const docsDense = { + description: + "Animates the element behind ref (x, y, scale, rotate, opacity) with Astryx's motion; returns the animator.", + paramDescriptions: { + ref: 'The element ref; share it with a gesture hook to drag and animate one element.', + }, + returnDescriptions: { + animator: + 'animate(target, {velocity?}), decay(velocity), set, get, getVelocity, stop; stable, never re-renders.', + }, + usage: { + description: + 'Spring with animate, fling with decay, follow a finger with set. Reduce Motion: a cross-fade on touch, at once with a mouse; decay keeps its momentum.', + bestPractices: [ + { + guidance: true, + description: 'Pass the release velocity to animate after a drag.', + }, + { + guidance: false, + description: 'Keep animated values in state; read animator.get().', + }, + ], + }, +}; diff --git a/packages/core/src/hooks/useAnimator.test.tsx b/packages/core/src/hooks/useAnimator.test.tsx new file mode 100644 index 0000000000000..dea0b7c651f93 --- /dev/null +++ b/packages/core/src/hooks/useAnimator.test.tsx @@ -0,0 +1,187 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useAnimator.test.tsx + * @input vitest (jsdom), React Testing Library, the public hook and factory + * @output Checks `spec:AST-107` FR3 and FR6 for the hook: `useAnimator(ref)` + * takes the caller's element ref and returns one animator, the factory's + * handle and nothing more; an animator used before its element mounts does + * nothing and resolves at once; it follows the element behind the ref + * when the node is replaced; an unmount keeps the values and a remount + * resumes; another hook can share the element through the same ref; no + * re-render while animating; and a hook module with no behavior of its own + * @position Testing; validates useAnimator.ts + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {act, render} from '@testing-library/react'; +import {useEffect, useRef} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type {AnimatorHandle} from '../animator/createAnimator'; +import {useAnimator} from './useAnimator'; + +beforeEach(() => { + HTMLElement.prototype.animate = function () { + return { + startTime: null, + playState: 'running', + ready: new Promise(() => {}), + cancel: vi.fn(), + } as unknown as Animation; + }; +}); + +afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); +}); + +function Probe({ + box, + onRender, + show = true, +}: { + box: {current: AnimatorHandle | null}; + onRender?: () => void; + show?: boolean; +}) { + const ref = useRef(null); + const animator = useAnimator(ref); + box.current = animator; + onRender?.(); + return show ?
: null; +} + +describe('useAnimator', () => { + it('takes the element ref and returns one animator for the component\u2019s lifetime, and never re-renders while animating', () => { + const animators = new Set(); + let renders = 0; + function Counter({label}: {label: string}) { + const ref = useRef(null); + const animator = useAnimator(ref); + animators.add(animator); + renders += 1; + return ( + + ); + } + const {getByRole, rerender} = render(); + rerender(); + const after = renders; + act(() => { + getByRole('button').click(); + }); + expect(renders).toBe(after); + expect(animators.size).toBe(1); + }); + + it('returns the handle\u2019s six methods, and nothing else: no ref and no destroy of its own', () => { + const box: {current: AnimatorHandle | null} = {current: null}; + render(); + const animator = box.current!; + expect(Object.keys(animator).sort()).toEqual( + ['animate', 'decay', 'get', 'getVelocity', 'set', 'stop'].sort(), + ); + for (const method of Object.keys(animator)) { + expect( + typeof (animator as unknown as Record)[method], + ).toBe('function'); + } + }); + + it('an animator used before its element mounts does nothing and resolves at once', async () => { + const box: {current: AnimatorHandle | null} = {current: null}; + const {rerender, getByTestId} = render(); + const animator = box.current!; + // Unattached: no element yet, so nothing happens and nothing throws. + animator.set({x: 30, opacity: 0.4}); + animator.stop(); + expect(animator.get()).toMatchObject({x: 0, opacity: 1}); + let resolved = 0; + await Promise.all([ + animator.animate({y: 10}).then(() => resolved++), + animator.decay({x: 900}).then(() => resolved++), + ]); + expect(resolved).toBe(2); + rerender(); + const target = getByTestId('target'); + expect(animator.get()).toMatchObject({x: 0, y: 0, opacity: 1}); + // Attached, it works. + animator.set({x: 30}); + expect(target.style.translate).toBe('30px 0px'); + }); + + it('an unmount lets the element go and keeps the values; a remount resumes from them', () => { + const box: {current: AnimatorHandle | null} = {current: null}; + const {rerender, getByTestId} = render(); + const animator = box.current!; + animator.set({x: 12, rotate: 5}); + rerender(); + // With no element attached, a call does nothing. + animator.set({x: 24}); + expect(animator.get()).toMatchObject({x: 12, rotate: 5}); + rerender(); + expect(getByTestId('target').style.translate).toBe('12px 0px'); + expect(getByTestId('target').style.rotate).toBe('5deg'); + }); + + it('follows the element behind the ref when the node is replaced (architecture:react-component-runtime INV6)', () => { + const box: {current: AnimatorHandle | null} = {current: null}; + function Swapped({id}: {id: string}) { + const ref = useRef(null); + const animator = useAnimator(ref); + box.current = animator; + return
; + } + const {rerender, getByTestId} = render(); + const animator = box.current!; + animator.set({x: 8}); + expect(getByTestId('first').style.translate).toBe('8px 0px'); + rerender(); + // The new node takes the values, and the next call moves it. + expect(getByTestId('second').style.translate).toBe('8px 0px'); + animator.set({x: 16}); + expect(getByTestId('second').style.translate).toBe('16px 0px'); + }); + + it('shares its element with another hook through the same ref: a drag and an animation on one element', () => { + const box: {current: AnimatorHandle | null} = {current: null}; + let seen: Element | null = null; + function Shared() { + const ref = useRef(null); + const animator = useAnimator(ref); + box.current = animator; + // Another hook reads the same ref, as a gesture hook does. + useEffect(() => { + seen = ref.current; + }); + return
; + } + const {getByTestId} = render(); + expect(seen).toBe(getByTestId('shared')); + box.current!.set({y: 4}); + expect(getByTestId('shared').style.translate).toBe('0px 4px'); + }); + + it('delegates entirely to the factory: the hook module holds no behavior of its own', () => { + const source = fs.readFileSync( + path.join(__dirname, 'useAnimator.ts'), + 'utf8', + ); + const imports = [...source.matchAll(/from '([^']+)'/g)].map(m => m[1]); + expect(imports.sort()).toEqual([ + '../animator/createAnimator', + './useIsomorphicLayoutEffect', + 'react', + ]); + // One construction, the factory's: the hook only wires the caller's ref + // to it. + expect(source).toContain('createAttachableAnimator()'); + expect(source).not.toMatch(/\bmotion\//); + }); +}); diff --git a/packages/core/src/hooks/useAnimator.ts b/packages/core/src/hooks/useAnimator.ts new file mode 100644 index 0000000000000..b43fa003dff26 --- /dev/null +++ b/packages/core/src/hooks/useAnimator.ts @@ -0,0 +1,71 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useAnimator.ts + * @input React (useState), the element ref the caller owns, the public + * animator factory (animator/createAnimator.ts) + * @output Exports `useAnimator(ref)`: the public animator for the element + * behind `ref` (`spec:AST-107` FR3) + * @position Public hook, exported from `@astryxdesign/core/hooks`. A thin + * wrapper over `createAnimator` with no behavior of its own (FR6). + * + * The hook takes the caller's element ref, as the gesture hooks do, so a drag + * and an animation can share one element. The animator lives outside React's + * render cycle: the hook makes it once, returns the same object on every + * render, and nothing it does sets state, so animating never re-renders the + * component. After every commit it follows the element behind the ref: it + * attaches the element when it mounts, moves to a node that replaces it, and + * lets it go when it unmounts (running motion stops, the values stay); a + * remount resumes from them. While no element is attached, every call does + * nothing, and `animate` and `decay` resolve at once. + * + * SYNC: When modified, update: + * - /packages/core/src/hooks/useAnimator.test.tsx + * - /packages/core/src/hooks/useAnimator.doc.mjs + */ + +import {useState, type RefObject} from 'react'; +import { + createAttachableAnimator, + type AnimatorHandle, +} from '../animator/createAnimator'; +import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; + +/** + * Animates the element behind `ref` with the motion Core uses: call + * `animate`, `decay`, `set`, `get`, `getVelocity` or `stop` on the returned + * animator from event handlers and effects. Pass the same `ref` to a gesture + * hook to drag and animate one element. + * + * @example + * ``` + * const ref = useRef(null); + * const animator = useAnimator(ref); + * return ( + *
animator.animate({scale: 1.2})}> + * Card + *
+ * ); + * ``` + */ +export function useAnimator( + ref: RefObject, +): AnimatorHandle { + const [{handle, attach}] = useState(() => { + const {animator, attach: attachElement} = createAttachableAnimator(); + // The handle without `destroy`: the hook owns the element's lifetime. + const {destroy: _destroy, ...handle} = animator; + return {handle, attach: attachElement}; + }); + // Syncs the animator with the element behind the caller's ref after every + // commit: a mounted node is attached, a replacing node takes its place, and + // an unmounted one is let go. + useIsomorphicLayoutEffect(() => { + attach(ref.current); + }); + // Lets the element go when the component unmounts. + useIsomorphicLayoutEffect(() => () => attach(null), [attach]); + return handle; +} diff --git a/packages/core/src/hooks/useDragToSnapPoints.doc.mjs b/packages/core/src/hooks/useDragToSnapPoints.doc.mjs new file mode 100644 index 0000000000000..30679ef006269 --- /dev/null +++ b/packages/core/src/hooks/useDragToSnapPoints.doc.mjs @@ -0,0 +1,127 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').HookDoc} */ +export const docs = { + name: 'useDragToSnapPoints', + displayName: 'useDragToSnapPoints', + keywords: [ + 'drag', + 'snap', + 'snap points', + 'detents', + 'drawer', + 'flick', + 'swipe', + 'gesture', + ], + params: [ + { + name: 'ref', + type: 'RefObject', + description: 'A ref to the element a drag moves, along `axis` only.', + required: true, + }, + { + name: 'options.axis', + type: "'x' | 'y'", + description: + 'The axis the element moves along. A new one creates the drag again, starting where the element is.', + required: true, + }, + { + name: 'options.snapPoints', + type: 'number[]', + description: + "Offsets in px along `axis` from the element's resting position, such as `[0, 240, 480]`, sorted before use (`onSettle` and `settleTo` count in that order); an empty list makes the drag do nothing. Unlike BottomSheet's `snapPoints`, they are not fractions of the viewport. New ones create the drag again and nothing more: the element stays where it is, a settle in flight included, and the next drag or `settleTo` starts there.", + required: true, + }, + { + name: 'options.flickThreshold', + type: 'number', + description: + "A release faster than this, in px/s, carries to the next snap point in its direction. The default is the flick speed measured for UIKit's sheet. A new one creates the drag again, as new snap points do.", + default: '1008', + required: false, + }, + { + name: 'options.onSettle', + type: '(index: number) => void', + description: + "Called with the snap point's index once the element settles on it. The latest function is the one called, so it may be new on every render.", + required: true, + }, + ], + returns: [ + { + name: 'settleTo', + type: '(index: number) => void', + description: + 'Settles the element on the snap point at `index` without a drag: the keyboard path. The same function on every render.', + }, + ], + usage: { + description: + "Lets a drag move an element along one axis and settles it on snap points when the finger lifts, the way an iOS sheet settles on its detents: on the nearest snap point to where it is, on the next one in its direction after a quick flick, and back where it started when the finger was moving back. The settle continues the release's velocity. For a bottom sheet, use BottomSheet's `snapPoints`; for a scrolling row, Carousel's `hasSnap`. Outside React, use `createDragToSnapPoints`.", + bestPractices: [ + { + guidance: true, + description: + 'Offer the same positions without a drag: buttons or keys that call `settleTo`.', + }, + { + guidance: true, + description: + 'Keep the settled index in state from `onSettle`, and render what the position means from it.', + }, + { + guidance: false, + description: + 'Use it for a sheet or a carousel: their own props already settle the way people expect.', + }, + ], + accessibility: [ + { + name: 'Keyboard and assistive technology', + category: 'Keyboard', + description: + 'A drag is one way in, never the only one. Give each snap point a control a keyboard and a screen reader can reach, such as Expand and Collapse buttons, and call `settleTo` from it. When a position changes what the element shows, announce the change.', + }, + { + name: 'Reduce Motion', + category: 'Content', + criterion: '2.3.3 Animation from Interactions', + description: + 'When the person asks for reduced motion, the settle after a touch drag becomes a short cross-fade into the snap point, as does any settle on a device whose primary pointer is coarse, such as a tablet; after a mouse or pen drag elsewhere it lands at once. `settleTo` fades on such a device and lands at once with a fine primary pointer. While the finger is down, the element still follows it.', + }, + ], + }, + relatedComponents: ['BottomSheet', 'Carousel'], + relatedHooks: ['usePanGesture', 'useAnimator'], + importPath: '@astryxdesign/core/hooks', + category: 'interaction', +}; + +/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */ +export const docsDense = { + description: + 'A drag that settles the element at ref on snap points (px offsets): {settleTo}.', + paramDescriptions: { + ref: 'Ref to the dragged element.', + 'options.axis': "'x' or 'y'.", + 'options.snapPoints': 'Ascending px offsets from the resting position.', + 'options.flickThreshold': + 'px/s past which a release carries one snap point; 1008.', + 'options.onSettle': '(index) once settled; latest one is called.', + }, + returnDescriptions: { + settleTo: '(index) settles without a drag; stable.', + }, + usage: { + description: + 'Nearest snap point, the next on a flick, the start when moving back; the settle keeps the release velocity. Sheets and carousels: use their props.', + bestPractices: [ + {guidance: true, description: 'Wire settleTo to buttons and keys.'}, + {guidance: false, description: 'Rebuild a sheet or carousel with it.'}, + ], + }, +}; diff --git a/packages/core/src/hooks/useDragToSnapPoints.test.tsx b/packages/core/src/hooks/useDragToSnapPoints.test.tsx new file mode 100644 index 0000000000000..5260db9626116 --- /dev/null +++ b/packages/core/src/hooks/useDragToSnapPoints.test.tsx @@ -0,0 +1,369 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useDragToSnapPoints.test.tsx + * @input vitest (jsdom, fake clock), React Testing Library, the hook + * @output Checks `spec:AST-107` FR2 and FR6 for the hook: `settleTo` from a + * key settles and calls the latest `onSettle`, `settleTo` is stable, an + * unmount stops reporting, an empty list stays empty, and the hook module + * holds no behavior of its own + * @position Testing; validates useDragToSnapPoints.ts + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {act, render} from '@testing-library/react'; +import {useRef} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {coarsePrimaryPointer} from '../gestures/__tests__/harness'; +import {useDragToSnapPoints} from './useDragToSnapPoints'; + +beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'performance', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ], + }); + HTMLElement.prototype.animate = function ( + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + ) { + played.push(keyframes); + let start: number | null = null; + const animation = { + get startTime() { + return start; + }, + set startTime(value: number | null) { + start = value; + }, + playState: 'running', + ready: new Promise(() => {}), + finish() {}, + cancel() {}, + }; + requestAnimationFrame(() => { + start ??= performance.now(); + }); + return animation as unknown as Animation; + }; +}); + +afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + vi.useRealTimers(); +}); + +const POINTS = [0, 240, 480]; + +function touch( + type: string, + target: EventTarget, + contact: {x: number; y: number}, + time: number, + isLift = false, +): void { + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = {identifier: 1, clientX: contact.x, clientY: contact.y, target}; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', {value: isLift ? [] : [entry]}); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); +} + +// The document's host outlives a test, so event times only move forward. +let clock = 2_000_000; + +/** The keyframes of every animation the element played, in order. */ +const played: (Keyframe[] | PropertyIndexedKeyframes | null)[] = []; + +function Drawer({ + onSettle, + settlers, + snapPoints = POINTS, +}: { + onSettle: (index: number) => void; + settlers: Set<(index: number) => void>; + snapPoints?: ReadonlyArray; +}) { + const ref = useRef(null); + const {settleTo} = useDragToSnapPoints(ref, { + axis: 'y', + snapPoints, + onSettle, + }); + settlers.add(settleTo); + return
; +} + +describe('useDragToSnapPoints', () => { + it('settleTo settles from a key and calls the latest onSettle; settleTo is stable', async () => { + const settlers = new Set<(index: number) => void>(); + const first = vi.fn(); + const latest = vi.fn(); + const {rerender} = render(); + rerender(); + expect(settlers.size).toBe(1); + const [settleTo] = settlers; + act(() => settleTo(2)); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(first).not.toHaveBeenCalled(); + expect(latest).toHaveBeenCalledWith(2); + }); + + it('after an unmount nothing reports', async () => { + const settlers = new Set<(index: number) => void>(); + const onSettle = vi.fn(); + const {unmount} = render( + , + ); + const [settleTo] = settlers; + act(() => settleTo(1)); + unmount(); + await vi.advanceTimersByTimeAsync(3000); + expect(onSettle).not.toHaveBeenCalled(); + expect(() => settleTo(2)).not.toThrow(); + }); + + it('an empty list stays empty: settleTo does nothing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const settlers = new Set<(index: number) => void>(); + const onSettle = vi.fn(); + render(); + const [settleTo] = settlers; + act(() => settleTo(0)); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(onSettle).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('drives an element mounted after the hook first ran, and follows the one that replaces it (INV6)', async () => { + const settlers = new Set<(index: number) => void>(); + const onSettle = vi.fn(); + function Late({shown, version}: {shown: boolean; version: number}) { + const ref = useRef(null); + const {settleTo} = useDragToSnapPoints(ref, { + axis: 'y', + snapPoints: POINTS, + onSettle, + }); + settlers.add(settleTo); + return shown ?
: null; + } + const {rerender, getByTestId} = render(); + rerender(); + const [settleTo] = settlers; + act(() => settleTo(1)); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(onSettle).toHaveBeenLastCalledWith(1); + // A new node behind the ref: the drag moves to it. + rerender(); + const replaced = getByTestId('late'); + act(() => settleTo(2)); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(onSettle).toHaveBeenLastCalledWith(2); + expect(replaced.style.translate).toContain('480px'); + }); + + it('an option changed mid-use leaves the element where it is, and the next settle starts there', async () => { + const settlers = new Set<(index: number) => void>(); + const onSettle = vi.fn(); + function Sheet({flickThreshold}: {flickThreshold: number}) { + const ref = useRef(null); + const {settleTo} = useDragToSnapPoints(ref, { + axis: 'y', + snapPoints: POINTS, + flickThreshold, + onSettle, + }); + settlers.add(settleTo); + return
; + } + const {rerender, getByTestId} = render(); + const element = getByTestId('sheet'); + const [settleTo] = settlers; + act(() => settleTo(1)); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + const settled = element.style.translate; + expect(settled).toContain('240px'); + rerender(); + expect(element.style.translate).toBe(settled); + // The next settle moves on from 240, not from the resting position. + played.length = 0; + act(() => settleTo(2)); + expect(JSON.stringify(played[0])).toContain('240px'); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(onSettle).toHaveBeenLastCalledWith(2); + }); + + it('an option changed during a settle stops it where it is, and the next settle starts there', async () => { + const settlers = new Set<(index: number) => void>(); + const onSettle = vi.fn(); + function Sheet({flickThreshold}: {flickThreshold: number}) { + const ref = useRef(null); + const {settleTo} = useDragToSnapPoints(ref, { + axis: 'y', + snapPoints: POINTS, + flickThreshold, + onSettle, + }); + settlers.add(settleTo); + return
; + } + const {rerender} = render(); + const [settleTo] = settlers; + act(() => settleTo(2)); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + played.length = 0; + rerender(); + // The drag is created again with the new option and nothing more: the + // settle stops where the element is, and nothing reports. + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(played).toHaveLength(0); + expect(onSettle).not.toHaveBeenCalled(); + // The next settle starts from there: never from 0, never a jump. + const [next] = [...settlers].slice(-1); + act(() => next(2)); + const frames = played[0] as Keyframe[]; + const yOf = (frame: Keyframe | undefined) => + Number.parseFloat(String(frame?.translate ?? '').split(' ')[1] ?? ''); + expect(yOf(frames[0])).toBeGreaterThan(0); + expect(yOf(frames[0])).toBeLessThan(480); + expect(yOf(frames.at(-1))).toBeCloseTo(480, 0); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(onSettle).toHaveBeenLastCalledWith(2); + }); + + it('new snap points leave a resting element where it is, and the next settle starts there', async () => { + const settlers = new Set<(index: number) => void>(); + const onSettle = vi.fn(); + function Sheet({snapPoints}: {snapPoints: number[]}) { + const ref = useRef(null); + const {settleTo} = useDragToSnapPoints(ref, { + axis: 'y', + snapPoints, + onSettle, + }); + settlers.add(settleTo); + return
; + } + const {rerender} = render(); + const [settleTo] = settlers; + act(() => settleTo(1)); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + // 240 is still a snap point, now the third: nothing moves. + played.length = 0; + rerender(); + expect(played).toHaveLength(0); + // 240 is gone: still nothing moves, and nothing reports. + onSettle.mockClear(); + rerender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(played).toHaveLength(0); + expect(onSettle).not.toHaveBeenCalled(); + // The next settle, to 300, starts from 240. + act(() => settleTo(1)); + expect(JSON.stringify(played[0])).toContain('240px'); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(onSettle).toHaveBeenLastCalledWith(1); + }); + + it('an option changed during a release\u2019s settle leaves it where it is: the hook only creates the drag again', async () => { + // A flick from 260 toward 480, stopped about 330 by a new option. Creating + // the drag again is all the hook does (spec:AST-107 FR6), so nothing aims + // it at 240 or reports a point it never reached. + const restore = coarsePrimaryPointer(); + try { + const settlers = new Set<(index: number) => void>(); + const onSettle = vi.fn(); + function Sheet({flickThreshold}: {flickThreshold: number}) { + const ref = useRef(null); + const {settleTo} = useDragToSnapPoints(ref, { + axis: 'y', + snapPoints: POINTS, + flickThreshold, + onSettle, + }); + settlers.add(settleTo); + return
; + } + const {rerender, container} = render(); + const element = container.firstElementChild as HTMLElement; + const [settleTo] = settlers; + act(() => settleTo(1)); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + onSettle.mockClear(); + // From 240: 20 px down slowly, to 260, then 30 px in 20 ms (1500 px/s), + // a flick toward 480. + clock += 10_000; + const start = clock; + act(() => { + touch('touchstart', element, {x: 10, y: 10}, start); + touch('touchmove', element, {x: 10, y: 20}, start + 200); + touch('touchmove', element, {x: 10, y: 30}, start + 400); + touch('touchmove', element, {x: 10, y: 45}, start + 410); + touch('touchmove', element, {x: 10, y: 60}, start + 420); + touch('touchend', element, {x: 10, y: 60}, start + 424, true); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(40); + }); + played.length = 0; + rerender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(played).toHaveLength(0); + expect(onSettle).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it('delegates entirely to the factory: the hook module holds no behavior of its own', () => { + const source = fs.readFileSync( + path.join(__dirname, 'useDragToSnapPoints.ts'), + 'utf8', + ); + const imports = [...source.matchAll(/from '([^']+)'/g)].map(m => m[1]); + expect([...new Set(imports)].sort()).toEqual([ + '../dragToSnapPoints/createDragToSnapPoints', + './useAttachment', + './useIsomorphicLayoutEffect', + 'react', + ]); + expect(source).toContain('createDragToSnapPoints(element'); + expect(source).toContain('useAttachment('); + expect(source).not.toMatch(/\b(motion|gestures)\//); + }); +}); diff --git a/packages/core/src/hooks/useDragToSnapPoints.ts b/packages/core/src/hooks/useDragToSnapPoints.ts new file mode 100644 index 0000000000000..85da16d132af2 --- /dev/null +++ b/packages/core/src/hooks/useDragToSnapPoints.ts @@ -0,0 +1,100 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useDragToSnapPoints.ts + * @input React, the public snap-point drag factory + * (dragToSnapPoints/createDragToSnapPoints.ts) + * @output Exports `useDragToSnapPoints`: a drag that settles the element `ref` + * points at on snap points (`spec:AST-107` FR2) + * @position Public hook, exported from `@astryxdesign/core/hooks`. A thin + * wrapper over `createDragToSnapPoints` with no behavior of its own (FR6). + * + * The factory attaches to the element behind `ref` after each commit that + * mounts or replaces it (`architecture:react-component-runtime` INV6), and + * is destroyed when it unmounts. Any option change creates the drag again + * and does nothing more: the element stays where it is, a settle in flight + * included, and the next drag or `settleTo` starts there. The latest + * `onSettle` is the one called, so it may be a new function on every render, + * and `settleTo` is the same function on every render. + * + * SYNC: When modified, update: + * - /packages/core/src/hooks/useDragToSnapPoints.test.tsx + * - /packages/core/src/hooks/useDragToSnapPoints.doc.mjs + */ + +import {useRef, useState} from 'react'; +import type {RefObject} from 'react'; +import { + createDragToSnapPoints, + type DragToSnapPoints, + type DragToSnapPointsOptions, +} from '../dragToSnapPoints/createDragToSnapPoints'; +import {useAttachment} from './useAttachment'; +import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; + +export interface UseDragToSnapPointsResult { + /** + * Settles the element on the snap point at `index` without a drag: the + * keyboard path. The same function on every render. + */ + readonly settleTo: (index: number) => void; +} + +/** + * Lets a drag move the element along `axis` and settles it on `snapPoints` + * at the release. + * + * @example + * ``` + * const ref = useRef(null); + * const {settleTo} = useDragToSnapPoints(ref, { + * axis: 'y', + * snapPoints: [0, 240, 480], + * onSettle: setIndex, + * }); + * ``` + */ +export function useDragToSnapPoints( + ref: RefObject, + options: DragToSnapPointsOptions, +): UseDragToSnapPointsResult { + const {axis, snapPoints, flickThreshold} = options; + const onSettleRef = useRef(options.onSettle); + useIsomorphicLayoutEffect(() => { + onSettleRef.current = options.onSettle; + }); + const snapRef = useRef(null); + // The snap points as a value, so a new array with the same points keeps + // the drag (an empty list stays empty). + const points = JSON.stringify(snapPoints); + // One drag for the node behind `ref`, kept by Core's one attachment owner: + // a node mounted or replaced later is driven too (INV6), and any option + // change creates the drag again through the factory and does nothing more + // (spec:AST-107 FR6). Destroying the old drag leaves the element where it + // is, a settle in flight included, and the new one starts there. + useAttachment( + ref, + element => { + const snap = createDragToSnapPoints(element, { + axis, + snapPoints: JSON.parse(points) as number[], + flickThreshold, + onSettle: index => onSettleRef.current(index), + }); + snapRef.current = snap; + return () => { + snap.destroy(); + if (snapRef.current === snap) { + snapRef.current = null; + } + }; + }, + `${axis} ${points} ${flickThreshold ?? ''}`, + ); + const [result] = useState(() => ({ + settleTo: index => snapRef.current?.settleTo(index), + })); + return result; +} diff --git a/packages/core/src/hooks/usePanGesture.doc.mjs b/packages/core/src/hooks/usePanGesture.doc.mjs new file mode 100644 index 0000000000000..74d280df783de --- /dev/null +++ b/packages/core/src/hooks/usePanGesture.doc.mjs @@ -0,0 +1,159 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').HookDoc} */ +export const docs = { + name: 'usePanGesture', + displayName: 'usePanGesture', + keywords: [ + 'pan', + 'drag', + 'gesture', + 'touch', + 'swipe', + 'translation', + 'velocity', + 'pointer', + 'createPanGesture', + 'uikit', + ], + params: [ + { + name: 'ref', + type: 'RefObject', + description: + 'The element to drag. The hook creates one pan for the element behind the ref, creates a new one when the ref moves to another node or `axis` changes, and destroys it at unmount; a drag in progress then ends without a `cancelled` event, since an effect never calls `onPan`.', + required: true, + }, + { + name: 'options', + type: 'PanGestureOptions', + description: 'Configuration object.', + required: true, + }, + { + name: 'options.axis', + type: "'x' | 'y'", + description: + 'Limits the drag to one axis. A drag along the other axis is not reported, and that component of every value reads 0. Omit it to report both axes.', + required: false, + }, + { + name: 'options.onPan', + type: '(event: PanGestureEvent) => void', + description: + "Called with `{state, translation, velocity}` as the drag begins, changes, ends at the release, or is cancelled. `state` is 'began', 'changed', 'ended' or 'cancelled'. `translation` is the movement since the drag began, in pixels: the travel the drag needed to begin is not part of it, so an element that follows it does not jump. `velocity` is in pixels per second; at 'ended' it is the release velocity (0 when the finger rested before lifting), and at 'cancelled' it is 0. Read through a ref: a new function each render re-creates nothing.", + required: true, + }, + ], + returns: [ + { + name: 'void', + type: 'void', + description: + 'Nothing: the hook hands out no recognizer. Without React, `createPanGesture(element, options)` takes the same options and returns `{destroy}`, which removes every listener it added and cancels a drag in progress.', + }, + ], + usage: { + description: + 'Reports a drag on an element as values, for a surface the product moves itself: a card that follows the finger, a drawer pulled by its handle. A finger, a mouse and a pen all drive it. The drag claims its touch at UIKit’s 10 pt, as `UIPanGestureRecognizer` does, so a tap stays a tap, and a press or a long press is never reported as a pan. With `axis`, a scroll across that axis that starts on the element stays the page’s; without it, the pan takes a drag in any direction, so a scroll that starts on the element moves the element instead of the page. The hook is a thin layer over `createPanGesture`, the same API for code without React. A drag has no keyboard or assistive-technology path of its own: whatever the drag does needs another way to do it, such as buttons or arrow keys (WCAG 2.5.7). The pan moves nothing itself, so Reduce Motion applies to the motion you drive from it: honor the user’s reduced-motion setting in the animation that moves the element. Where a component already offers the behavior, use its prop instead: BottomSheet’s `snapPoints`, Carousel’s `hasSnap`.', + bestPractices: [ + { + guidance: true, + description: + 'Move the element from `translation` added to where it was when the drag began, so it moves with the finger from the moment the drag claims it.', + }, + { + guidance: true, + description: + 'At `ended`, hand `velocity` to the animation that settles the element, so a flick carries on at the speed it was released.', + }, + { + guidance: true, + description: + 'Give every drag another way to do the same thing from a keyboard and from assistive technology, such as buttons, arrow keys or a menu.', + }, + { + guidance: true, + description: + "Use a component's own prop when it covers the job: BottomSheet's `snapPoints` for a sheet, Carousel's `hasSnap` for paging.", + }, + { + guidance: false, + description: + 'Set a new React state on every `changed` event; write the element’s style in `onPan` instead, so a drag costs no render.', + }, + { + guidance: false, + description: + 'Recognize taps or long presses with it; `useLongPress` and the components’ own press handling own those.', + }, + ], + accessibility: [ + { + name: 'Keyboard and assistive technology', + category: 'Keyboard', + criterion: '2.5.7 Dragging Movements', + description: + 'A drag has no keyboard or assistive-technology path of its own: whatever the drag does needs another way to do it, such as buttons, arrow keys or a menu.', + }, + { + name: 'Reduce Motion', + category: 'Content', + criterion: '2.3.3 Animation from Interactions', + description: + 'The pan moves nothing itself, so Reduce Motion applies to the motion you drive from it: a settle or a throw goes to its end without travel when the person asks for reduced motion.', + }, + ], + }, + relatedComponents: ['BottomSheet', 'Carousel'], + relatedHooks: ['useLongPress', 'createPanGesture'], + importPath: '@astryxdesign/core/hooks', + category: 'interaction', +}; + +/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */ +export const docsDense = { + description: + 'Reports a drag as values ({state, translation, velocity}) for a surface you move yourself; a finger, a mouse and a pen all drive it. Claims at UIKit’s 10 pt; taps, presses and long presses are never a pan, and with axis a scroll across it stays the page’s. Thin layer over createPanGesture(element, options) → {destroy}. No keyboard path of its own: give the drag another way (WCAG 2.5.7). Reduce Motion: honor it in the animation you drive. Prefer BottomSheet snapPoints / Carousel hasSnap when they fit.', + paramDescriptions: { + ref: 'element to drag; one pan per node, re-created when the ref moves, destroyed at unmount.', + options: 'config object.', + 'options.axis': + "'x' | 'y': limit to one axis; the other is never reported and reads 0.", + 'options.onPan': + "({state, translation, velocity}): state 'began' | 'changed' | 'ended' | 'cancelled'; translation px since the drag began (threshold travel excluded, no jump); velocity px/s, release velocity at 'ended', 0 at 'cancelled'. Read through a ref.", + }, + returnDescriptions: { + void: 'nothing (no recognizer). createPanGesture returns {destroy}: removes every listener, cancels a drag in progress.', + }, + usage: { + description: + 'Drag as values for a card, a drawer handle, any surface you move. Thin layer over createPanGesture. No keyboard path of its own (WCAG 2.5.7); Reduce Motion applies to what you animate. Prefer component props (BottomSheet snapPoints, Carousel hasSnap).', + bestPractices: [ + { + guidance: true, + description: + 'Move from translation + the offset at began, so the element moves with the finger from the claim.', + }, + { + guidance: true, + description: 'Hand the ended velocity to the settling animation.', + }, + { + guidance: true, + description: + 'Give every drag a keyboard and assistive-technology alternative.', + }, + { + guidance: true, + description: "Use a component's own prop when it covers the job.", + }, + { + guidance: false, + description: + 'Set React state on every changed event; write the style in onPan.', + }, + {guidance: false, description: 'Recognize taps or long presses with it.'}, + ], + }, +}; diff --git a/packages/core/src/hooks/usePanGesture.ts b/packages/core/src/hooks/usePanGesture.ts new file mode 100644 index 0000000000000..e37821d0fd0be --- /dev/null +++ b/packages/core/src/hooks/usePanGesture.ts @@ -0,0 +1,108 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file usePanGesture.ts + * @input An element ref and pan options + * @output Exports usePanGesture: the React layer of createPanGesture + * (spec:AST-107 FR1, FR6) + * @position Public hook in packages/core/src/hooks. It delegates entirely to + * createPanGesture: it creates one pan for the element behind `ref`, + * re-creates it when the node or the axis changes, and destroys it at + * unmount. `onPan` is read through a ref, so a new function each render + * re-creates nothing and a drag costs no React render. + * SYNC: When modified, update usePanGesture.doc.mjs and + * ../gestures/__tests__/createPanGesture.test.tsx. + */ + +import {useRef, type RefObject} from 'react'; +import { + createPanGesture, + type PanGesture, + type PanGestureOptions, +} from '../gestures/createPanGesture'; +import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; + +interface Attached { + readonly element: Element; + readonly axis: PanGestureOptions['axis']; + readonly gesture: PanGesture; + /** Cleared before an effect destroys the pan, so its cancel reaches no one. */ + isForwarding: boolean; +} + +/** + * Destroy a pan from an effect without telling the caller: an effect never + * calls `onPan` (`architecture:react-component-runtime` INV2), so a drag in + * progress at unmount, or when the node or the axis changes, ends silently. + */ +function detach(attached: Attached | null): void { + if (attached != null) { + attached.isForwarding = false; + attached.gesture.destroy(); + } +} + +/** + * Report drags on the element behind `ref` as values. + * @example + * ``` + * const ref = useRef(null); + * usePanGesture(ref, { + * axis: 'x', + * onPan: ({state, translation}) => { + * ref.current!.style.translate = + * state === 'changed' ? `${translation.x}px` : ''; + * }, + * }); + * ``` + */ +export function usePanGesture( + ref: RefObject, + options: PanGestureOptions, +): void { + const {axis, onPan} = options; + const onPanRef = useRef(onPan); + const attachedRef = useRef(null); + // The latest listener, for the next event. + useIsomorphicLayoutEffect(() => { + onPanRef.current = onPan; + }); + // One pan for the node behind `ref` with this axis: re-created when either + // changes, after a commit that replaced the node. + useIsomorphicLayoutEffect(() => { + const element = ref.current; + const attached = attachedRef.current; + if (attached?.element === element && attached?.axis === axis) { + return; + } + detach(attached); + if (element == null) { + attachedRef.current = null; + return; + } + const next: Attached = { + element, + axis, + isForwarding: true, + gesture: createPanGesture(element, { + axis, + onPan: event => { + if (next.isForwarding) { + onPanRef.current(event); + } + }, + }), + }; + attachedRef.current = next; + }); + // Destroyed at unmount. + useIsomorphicLayoutEffect( + () => () => { + detach(attachedRef.current); + attachedRef.current = null; + }, + [], + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index da746066d77cf..aa68f07490d68 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -172,9 +172,33 @@ export * from './Outline'; // Overflow list export * from './OverflowList'; +// Animating one element (spec:AST-107 FR3): from the package root only, as +// one API has one entry; its hook ships from './hooks'. +export { + createAnimator, + type Animator, + type AnimatorAnimateOptions, + type AnimatorChannel, + type AnimatorDecayVelocity, + type AnimatorHandle, + type AnimatorSnapshot, + type AnimatorValues, +} from './animator/createAnimator'; + +// Settling an element on snap points (spec:AST-107 FR2): from the package +// root only, as one API has one entry; its hook ships from './hooks'. +export { + createDragToSnapPoints, + type DragToSnapPoints, + type DragToSnapPointsOptions, +} from './dragToSnapPoints/createDragToSnapPoints'; + // Hooks export * from './hooks'; +// Gestures: the public layer over the gesture system (spec:AST-107) +export {createPanGesture} from './gestures/createPanGesture'; + // Utilities export * from './utils'; diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index 6a0f6b68ac379..65b9884ebef0e 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -7,7 +7,7 @@ source), the spring, decay, rubber-band and projection math UIKit uses, and a compositor animator that plays it. Nothing here is exported from the package entry point. Core's motion and gesture modules read the table by path; components read touch motion through its internal motion roles (`roles.ts`), -so each keeps its measured value; no shipped component reads the roles yet. +so each keeps its measured value, as the snap-point drag's settle does. ## Files @@ -59,7 +59,7 @@ the timings that decide who gets a touch are not roles. ## Animator ``` -import {createAnimator} from '../motion/animator'; +import {createMotionAnimator} from '../motion/animator'; import {coast} from '../motion/coast'; import {solveSpringTowardRamp, toSpring} from '../motion/spring'; import {resolveMotion} from '../motion/resolveMotion'; @@ -68,7 +68,7 @@ import {registerSpringsTable, springs} from '../motion/uikit'; // Once, so the springs are registered as their roles: registerSpringsTable(); -const animator = createAnimator(sheet); +const animator = createMotionAnimator(sheet); // The animator plays a motion role (spec:AST-094 FR4), its table value, as the // per-role tables stay internal (DEC-15); math on a spring reads the role with // resolveMotion. @@ -163,7 +163,7 @@ await handle.finished; // true if it ran to the end, false if interrupted - `animatorFor(element)` returns the one animator for an element, so code that animates it from several places (a swipe on x, a removal on y) composes in one `translate` track. -- `createAnimator(dialog, {pseudoElement: '::backdrop'})` drives a +- `createMotionAnimator(dialog, {pseudoElement: '::backdrop'})` drives a pseudo-element; with no inline style to hold it, a static filling animation holds its end value until the next animation replaces it, while the pseudo-element exists. A dialog's `::backdrop` is made again each time the diff --git a/packages/core/src/motion/__tests__/animator.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/animator.interaction.browser.spec.ts index fba9741b82b24..03d9f34250318 100644 --- a/packages/core/src/motion/__tests__/animator.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animator.interaction.browser.spec.ts @@ -109,7 +109,7 @@ async function open(page: Page) {
-`; +`; await page.route('http://motion.test/', async route => route.fulfill({contentType: 'text/html', body: html}), ); @@ -373,7 +373,7 @@ test('a frame painted before the compositor has the curve shows the element wher document.body.append(element); return { element, - animator: Motion.createAnimator(element, now ? {now} : {}), + animator: Motion.createMotionAnimator(element, now ? {now} : {}), }; }; const x = (element: HTMLElement) => @@ -909,7 +909,7 @@ test('a role variable set on a scope changes nothing while touch motion stays in const report: Record = {}; for (const id of ['plain', 'themed'] as const) { const element = document.getElementById(id) as HTMLElement; - const animator = Motion.createAnimator(element); + const animator = Motion.createMotionAnimator(element); animator.set({y: 300}); const handle = animator.animate({y: 0}, {spring: Motion.springs.settle}); await handle.started; @@ -1013,7 +1013,7 @@ test('a fade-in on an element its stylesheet hides stays visible once it retires element.style.cssText = 'position:absolute;top:120px;left:0;width:48px;height:48px;background:#c63'; document.body.append(element); - const animator = Motion.createAnimator(element); + const animator = Motion.createMotionAnimator(element); animator.set({opacity: 0}); animator.animate({opacity: 1}, {spring}); await new Promise(resolve => { @@ -1085,7 +1085,7 @@ test('a dialog\u2019s ::backdrop plays the curve and holds its end state', async const dialog = document.createElement('dialog'); document.body.append(dialog); dialog.showModal(); - const backdrop = Motion.createAnimator(dialog, { + const backdrop = Motion.createMotionAnimator(dialog, { pseudoElement: '::backdrop', }); backdrop.set({opacity: 0}); diff --git a/packages/core/src/motion/__tests__/motionBundle.ts b/packages/core/src/motion/__tests__/motionBundle.ts index 0c5447b6f5e2a..874fb5e2a8d38 100644 --- a/packages/core/src/motion/__tests__/motionBundle.ts +++ b/packages/core/src/motion/__tests__/motionBundle.ts @@ -8,7 +8,7 @@ * (global `Motion`), and the type of that global */ -export {createAnimator} from '../animator'; +export {createMotionAnimator} from '../animator'; export {solveSpring, toSpring} from '../spring'; export {registerSpringsTable, springs} from '../uikit/springs'; export {motionRoleOf} from '../roles'; diff --git a/packages/core/src/motion/animator.test.ts b/packages/core/src/motion/animator.test.ts index 89aefce585308..724602ff1e12e 100644 --- a/packages/core/src/motion/animator.test.ts +++ b/packages/core/src/motion/animator.test.ts @@ -19,7 +19,7 @@ import path from 'node:path'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import { animatorFor, - createAnimator, + createMotionAnimator, END_HOLD_MS, PROMOTION_LINGER_MS, type AnimatorOptions, @@ -205,7 +205,7 @@ function setup({ }: AnimatorOptions & {reduced?: boolean; coarse?: boolean} = {}) { stubMedia(reduced, coarse); const element = document.createElement('div'); - const animator = createAnimator(element, { + const animator = createMotionAnimator(element, { now: () => clock, requestFrame, ...options, @@ -902,6 +902,139 @@ describe('a call from rest starts at the commit, as Core Animation does', () => expect(started).toBe(false); }); + describe("a call made in an input event's own task (start: 'next-frame')", () => { + it('starts at the first frame after it, however long its task ran', async () => { + const {animator} = setup(); + const handle = animator.animate( + {x: 300}, + {spring: springs.settle}, + {start: 'next-frame'}, + ); + // A listener later in the same task holds the main thread 800 ms. + clock += 800; + const [animation] = live('translate'); + clock += 16; + await commitFrame(clock, {startUnread: true}); + expect(animation.startTime).toBe(clock); + await expect(handle.started).resolves.toBe(clock); + }); + + it('moves a start the browser dates before a later listener’s block (WebKit) to the first frame after it', async () => { + // WebKit dates the animation at its last frame time before the task; + // the task's first microtask ran before the later listener's block, + // so only the caller says the task ran on. + Object.defineProperty(document, 'timeline', { + configurable: true, + get: () => ({currentTime: clock}), + }); + try { + const {animator} = setup(); + const handle = animator.animate( + {x: 300}, + {spring: springs.settle}, + {start: 'next-frame'}, + ); + // The call's listener returns: its first microtask runs. + await Promise.resolve(); + // A later listener of the same event holds the main thread 800 ms. + clock += 800; + const [animation] = live('translate'); + animation.startTime = 1000; + clock += 16; + await commitFrame(clock); + await expect(handle.started).resolves.toBe(clock); + expect(animation.startTime).toBe(clock); + } finally { + Reflect.deleteProperty(document, 'timeline'); + } + }); + + it('starts a call whose first frame comes late without a block (WebKit) at that frame', async () => { + // The first read stands for the task's end: a slow first frame, with + // no block in the task, moves WebKit's dated start to that frame too. + // Nothing was painted before it, so the motion is delayed by that + // frame instead of skipping into its curve. + Object.defineProperty(document, 'timeline', { + configurable: true, + get: () => ({currentTime: clock}), + }); + try { + const {animator} = setup(); + const handle = animator.animate( + {x: 300}, + {spring: springs.settle}, + {start: 'next-frame'}, + ); + await Promise.resolve(); + const [animation] = live('translate'); + animation.startTime = 1000; + // The first frame comes 100 ms after the call. + clock = 1100; + await commitFrame(clock); + await expect(handle.started).resolves.toBe(1100); + expect(animation.startTime).toBe(1100); + } finally { + Reflect.deleteProperty(document, 'timeline'); + } + }); + + it('leaves a call with an explicit start to that start', async () => { + // `at` pins the call's clock: it never waits for a start. + const {animator} = setup(); + const at = clock; + animator.animate( + {x: 300}, + {spring: springs.settle}, + {at, start: 'next-frame'}, + ); + clock += 800; + const [animation] = live('translate'); + expect(animation.startTime).toBe(at); + clock += 16; + await commitFrame(clock, {startUnread: true}); + expect(animation.startTime).toBe(at); + }); + + it('leaves an earlier waiting call to its own rule when a call with an explicit start joins it', async () => { + // An explicit `at` no later than a call from rest still waiting on + // the same channel joins that call's start; `start` is ignored with + // `at`, so the earlier call keeps the rule it had. + const {animator} = setup(); + const handle = animator.animate({x: 300}, {spring: springs.settle}); + animator.animate( + {x: 400}, + {spring: springs.settle}, + {at: clock, start: 'next-frame'}, + ); + // A block before the first frame: without 'next-frame' the call + // keeps the browser's start. + clock += 816; + await commitFrame(clock, {startUnread: true}); + expect(live('translate').at(-1)?.startTime).toBeNull(); + let started = false; + void handle.started.then(() => { + started = true; + }); + await Promise.resolve(); + expect(started).toBe(false); + }); + + it('starts a call that continues motion now, as by default', async () => { + const {animator} = setup(); + animator.animate({x: 300}, {spring: springs.settle}); + clock = 1016; + await commitFrame(1016); + clock = 1100; + animator.animate( + {x: 400}, + {spring: springs.settle}, + {start: 'next-frame'}, + ); + const retarget = live('translate').at(-1) as Recorded; + expect(retarget.startTime).toBe(1100); + }); + }); + it('a call the page pauses before its first frame waits for its start', async () => { // A harness or a page that pauses the animation: nothing starts it at // the commit, so nothing sets a start for it either. @@ -1755,11 +1888,11 @@ describe('promotion: a compositing layer while it moves', () => { .mock.calls.filter(([read]) => read === node).length; const removed = document.createElement('div'); document.body.append(removed); - createAnimator(removed, {now: () => clock, requestFrame}); + createMotionAnimator(removed, {now: () => clock, requestFrame}); removed.remove(); const kept = document.createElement('div'); document.body.append(kept); - createAnimator(kept, {now: () => clock, requestFrame}); + createMotionAnimator(kept, {now: () => clock, requestFrame}); await vi.advanceTimersByTimeAsync(0); expect(readsOf(removed)).toBe(0); // A node still in the document is read, once, in that task. @@ -1773,7 +1906,7 @@ describe('promotion: a compositing layer while it moves', () => { stylesheet['will-change'] = 'transform, opacity'; stubMedia(false, false); const element = document.createElement('div'); - const animator = createAnimator(element, { + const animator = createMotionAnimator(element, { now: () => clock, requestFrame, }); @@ -2518,7 +2651,7 @@ describe('Reduced Motion (design:reduced-motion)', () => { it('a running dip survives any re-attach, so the move never passes in view', async () => { const halfway = async ( - animator: ReturnType, + animator: ReturnType, start: number, ): Promise => { // The plan lands by timer: step the clock to the landing. @@ -3855,7 +3988,7 @@ describe('pseudoElement: a dialog\u2019s ::backdrop', () => { describe('attach', () => { it('keeps state without an element and resumes the curve on attach', () => { - const animator = createAnimator(null, { + const animator = createMotionAnimator(null, { now: () => clock, }); animator.animate({x: 300}, {spring: springs.settle}); @@ -3946,7 +4079,7 @@ describe('attach', () => { it('writes nothing for channels at identity', () => { const element = document.createElement('div'); - createAnimator(element); + createMotionAnimator(element); expect(element.getAttribute('style')).toBeNull(); }); }); diff --git a/packages/core/src/motion/animator.ts b/packages/core/src/motion/animator.ts index 715e8be26790a..fe4862cb66c45 100644 --- a/packages/core/src/motion/animator.ts +++ b/packages/core/src/motion/animator.ts @@ -4,7 +4,7 @@ * @file animator.ts * @input An element, target values, and a spring, momentum curves (coast.ts) * or any closed-form curve - * @output Exports `createAnimator`: a compositor animator for translate, + * @output Exports `createMotionAnimator`: a compositor animator for translate, * rotate, scale and opacity, with presentation values and velocities read * from the analytic curves, velocity-preserving retargeting, freeze-in-place * `stop`, completion promises, a shared clock, and UIKit's Reduced Motion @@ -256,8 +256,15 @@ export interface AnimateOptions { * frames more before an idle start. Any other change to the animator * before then creates the animations first, in call order. A call that * continues motion on screen (a retarget) starts now, as by default. + * - `'next-frame'`: the caller is handling an input event (a key, a click, + * a pointer, a touch) in that event's own task, never in a frame + * callback, so the first frame whose callbacks run after the call is the + * one that commits it. A call from rest starts at that frame however + * long the task ran: a later listener's block delays the motion instead + * of skipping into it (the browser would date the start inside that + * block). A call that continues motion starts now, as by default. */ - start?: 'commit' | 'presented'; + start?: 'commit' | 'presented' | 'next-frame'; /** * A call that retargets a curve on screen adds to it instead of replacing * it, as UIKit's animations do: the old curve keeps going (past its end, at @@ -297,7 +304,7 @@ export interface MotionCurve { } /** - * Options of `createAnimator`. + * Options of `createMotionAnimator`. */ /** How `set` puts values in place. */ export interface SetOptions { @@ -515,6 +522,12 @@ interface StartGroup { * so the frame after it is not the one that starts them. */ madeInFrame?: boolean; + /** + * Its call asked to start at the next frame (`start: 'next-frame'`): it + * is made in an input event's own task, so the first frame after it is + * the one that commits its animations, however long that task ran. + */ + nextFrame?: boolean; /** The task it was made in, with every call made in that task. */ task?: TaskMark; /** A later call in the same task that took over its curves. */ @@ -795,7 +808,7 @@ export function animatorFor( const key = options.pseudoElement ?? ''; let animator = byPseudo.get(key); if (animator == null) { - animator = createAnimator(element, options); + animator = createMotionAnimator(element, options); byPseudo.set(key, animator); } return animator; @@ -912,7 +925,7 @@ const REDUCED_TRANSITION: SpringTransition = { * * @example * ``` - * const animator = createAnimator(sheet); + * const animator = createMotionAnimator(sheet); * // finger lifts at 1200 px/s heading for the detent at 0 (a component * // passes the spring of its motion role): * animator.animate({y: 0}, {spring: {response: 0.45, dampingRatio: 0.9}, velocity: {y: 1200}}); @@ -921,7 +934,7 @@ const REDUCED_TRANSITION: SpringTransition = { * animator.stop(['y']); * ``` */ -export function createAnimator( +export function createMotionAnimator( element: HTMLElement | null, options: AnimatorOptions = {}, ): Animator { @@ -2123,11 +2136,15 @@ export function createAnimator( // frame callback, whose frame committed it already, nor when a block // ran between the call and this frame (more than LATE_START_MS), after // which this frame cannot tell a call made in a task from one a page's - // own frame callback made; those keep the browser's start. + // own frame callback made; those keep the browser's start. A call made + // in an input event's own task says so (`start: 'next-frame'`): this + // frame commits it however late it comes, so a later listener's block + // holds the motion back instead of skipping into it. if ( !group.resolved && group.madeInFrame !== true && - now() - (group.task?.first ?? group.provisional) <= LATE_START_MS && + (group.nextFrame === true || + now() - (group.task?.first ?? group.provisional) <= LATE_START_MS) && group.animations.some( animation => animation.startTime == null && animation.playState === 'running', @@ -2271,7 +2288,10 @@ export function createAnimator( * main-thread block later in the call's task delays the motion; WebKit * dates it at its last frame time before that task, so the first frame * after the block would show it skipped into its curve. When the task ran - * on more than LATE_START_MS past its first call, a start the browser put + * on more than LATE_START_MS past its first call (for a call made with + * `start: 'next-frame'`, read at the call's first read: a later listener + * of its input event may hold the task past its first microtask), a start + * the browser put * by the task's first animations (a presented call's second frame, not the * call) and more than LATE_START_MS before the call's first read (its * `ready`, once the task has ended, or the first frame after it) moves to @@ -2294,8 +2314,12 @@ export function createAnimator( return start; } const {task} = group; - // A read in the task's first microtasks comes after its end too. - const ended = task?.ended ?? now(); + // A read in the task's first microtasks comes after its end too. A call + // made in an input event's own task (`start: 'next-frame'`) may have had + // that task held past its first microtask by a later listener of the + // event: its end is this read, the first frame after the task or its + // `ready`. + const ended = group.nextFrame === true ? now() : (task?.ended ?? now()); if (task == null || ended - task.first <= LATE_START_MS) { return start; } @@ -3327,6 +3351,14 @@ export function createAnimator( animateOptions.at, animateOptions.start, ); + // Ignored with `at`, which may join an earlier call's start. + if ( + group != null && + animateOptions.at == null && + animateOptions.start === 'next-frame' + ) { + group.nextFrame = true; + } const adopted = plan?.landing != null ? adoptLandings(plan.landing, Object.keys(next) as MotionProperty[]) @@ -3456,6 +3488,14 @@ export function createAnimator( playOptions.at, playOptions.start, ); + // Ignored with `at`, which may join an earlier call's start. + if ( + group != null && + playOptions.at == null && + playOptions.start === 'next-frame' + ) { + group.nextFrame = true; + } const adopted = plan?.landing != null ? adoptLandings(plan.landing, Object.keys(next) as MotionProperty[]) diff --git a/packages/core/src/motion/resolveMotion.test.ts b/packages/core/src/motion/resolveMotion.test.ts index b2a15020811b0..cf5a3067619ce 100644 --- a/packages/core/src/motion/resolveMotion.test.ts +++ b/packages/core/src/motion/resolveMotion.test.ts @@ -12,7 +12,7 @@ */ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {createAnimator} from './animator'; +import {createMotionAnimator} from './animator'; import {coast} from './coast'; import {resolveMotion} from './resolveMotion'; import {solveSpring, toSpring} from './spring'; @@ -93,7 +93,7 @@ describe('resolveMotion while the per-role tables stay internal (DEC-15)', () => describe('the animator', () => { it("plays the table's spring even where a scope sets the role's variable", () => { const child = scoped({'--motion-springs-settle': '0.8s 0.3'}); - const animator = createAnimator(child, {now: () => 0}); + const animator = createMotionAnimator(child, {now: () => 0}); animator.set({y: 300}); animator.animate({y: 0}, {spring: springs.settle}, {at: 0}); const table = solveSpring(toSpring(springs.settle), {from: 300, to: 0}); @@ -104,7 +104,7 @@ describe('the animator', () => { }); it('a coast plays the table edge spring whatever a scope sets: scroll.bounce is no role', () => { const child = scoped({'--motion-scroll-bounce': '0.8s 0.3'}); - const animator = createAnimator(child, {now: () => 0}); + const animator = createMotionAnimator(child, {now: () => 0}); animator.set({x: 60}); // Released past the edge: straight back on the edge spring. animator.decay( diff --git a/packages/core/src/motion/roles.ts b/packages/core/src/motion/roles.ts index 15381b4a3d4f8..7dcd101986645 100644 --- a/packages/core/src/motion/roles.ts +++ b/packages/core/src/motion/roles.ts @@ -16,7 +16,8 @@ * names, each by its table path, such as `springs.settle` or * `timing.curves.move`: a table lists its roles when it registers, and every * other value, a component's measured spring included, stays an internal - * constant. No shipped component reads the roles yet. + * constant. The snap-point drag's settle (`springs.settle`) is one a shipped + * component reads. * A role is carried by one CSS custom property, `--motion-` followed by its id * in kebab case (`--motion-springs-settle`), which nothing public writes; an * empty property means the table's value, so the table stays the one measured diff --git a/scripts/motion-node-load.test.mjs b/scripts/motion-node-load.test.mjs index d6b9b15aacab2..20056045560e4 100644 --- a/scripts/motion-node-load.test.mjs +++ b/scripts/motion-node-load.test.mjs @@ -28,7 +28,7 @@ const coreSrc = path.join(root, 'packages/core/src'); * the press paint's styles). */ const ENTRY = ` -export {createAnimator, animatorFor, SETTLE_THRESHOLD} from './motion/animator'; +export {createMotionAnimator, animatorFor, SETTLE_THRESHOLD} from './motion/animator'; export {reducedMotionFade} from './motion/reducedMotion'; export {resolveMotion} from './motion/resolveMotion'; export {solveSpring, toSpring} from './motion/spring'; @@ -98,10 +98,10 @@ describe('the touch motion foundation in plain Node', () => { '-e', `const m = await import(${JSON.stringify(pathToFileURL(file).href)}); m.registerSpringsTable(); - const animator = m.createAnimator(null); + const animator = m.createMotionAnimator(null); const pan = new m.PanGestureRecognizer({}); console.log(JSON.stringify({ - createAnimator: typeof m.createAnimator, + createMotionAnimator: typeof m.createMotionAnimator, set: typeof animator.set, sheet: m.springs.sheetPresent != null, pan: typeof pan.state, @@ -110,7 +110,7 @@ describe('the touch motion foundation in plain Node', () => { {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}, ); expect(JSON.parse(out.trim().split('\n').at(-1))).toEqual({ - createAnimator: 'function', + createMotionAnimator: 'function', set: 'function', sheet: true, pan: 'string', diff --git a/scripts/touch-support-bundle.test.mjs b/scripts/touch-support-bundle.test.mjs index 696e106705ccd..e1b7efe02ef9e 100644 --- a/scripts/touch-support-bundle.test.mjs +++ b/scripts/touch-support-bundle.test.mjs @@ -118,7 +118,12 @@ function filesUnder(dir, pattern) { * gives a component its gesture adds that component's import, and each import * that reaches it, with a comment naming the gesture. */ -const GESTURE_USERS = new Set([]); +const GESTURE_USERS = new Set([ + // The public pan, which the root's createPanGesture shares. + 'hooks: usePanGesture', + // The snap-point drag, which moves an element on that pan. + 'hooks: useDragToSnapPoints', +]); /** Public imports that carry no gesture code, whatever the list above says. */ const NEVER_GESTURE_USERS = ['Button', 'TextInput'];