diff --git a/.changeset/core-internal-touch-entry-lab-animator.md b/.changeset/core-internal-touch-entry-lab-animator.md new file mode 100644 index 0000000000000..75bf478b46b66 --- /dev/null +++ b/.changeset/core-internal-touch-entry-lab-animator.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: Core's internal motion engine factory is renamed `createMotionEngine` (from `createMotionAnimator`), with its types `MotionEngine` and `MotionEngineOptions`, at its source and in every internal caller. Core's internal touch entry also re-exports, by name, the UIKit gesture values, the right-to-left check, and the touch-presentation hook and query that Lab components read. Lab's `useMotionEngine` hook wraps the engine, and Lab's refresh control uses it. Apps import nothing new. + +@vjeux diff --git a/packages/core/src/DateInput/wheelStrip.ts b/packages/core/src/DateInput/wheelStrip.ts index 8feef24354ca4..506a2f783fda5 100644 --- a/packages/core/src/DateInput/wheelStrip.ts +++ b/packages/core/src/DateInput/wheelStrip.ts @@ -16,7 +16,7 @@ */ import {canVibrate, playHaptic} from '../gestures/haptics'; -import {createMotionAnimator, type MotionCurve} from '../motion/animator'; +import {createMotionEngine, type MotionCurve} from '../motion/animator'; import {reducedMotionFade} from '../motion/reducedMotion'; import {picker} from '../motion/uikit/picker'; import { @@ -132,7 +132,7 @@ function now(): number { * ``` */ export function createWheelStrip(initialLayout: WheelLayout): WheelStrip { - const animator = createMotionAnimator(null); + const animator = createMotionEngine(null); let layout = initialLayout; let running: Running | null = null; let nearestListener: ((position: number) => void) | null = null; diff --git a/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.test.ts b/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.test.ts index f88dc63b3b2c0..713644d7f95b0 100644 --- a/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.test.ts +++ b/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.test.ts @@ -13,7 +13,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {motionRoleOf} from '../motion/roles'; -import {createMotionAnimator, type Animator} from '../motion/animator'; +import {createMotionEngine, type Animator} from '../motion/animator'; import {solveSpring, toSpring} from '../motion/spring'; import {cubicBezier} from '../motion/bezier'; import {segmentedControl, springs} from '../motion/uikit'; @@ -105,12 +105,12 @@ function setup(selected: string, {coarse = true}: {coarse?: boolean} = {}) { document.head.append(style); document.body.append(track); // A finger by default: Reduced Motion fades (design:reduced-motion DR2). - const animator: Animator = createMotionAnimator(null, { + const animator: Animator = createMotionEngine(null, { now: () => clock, pseudoElement: '::before', }); // The ::after: the lifted lens's shadow, or the surface fading out. - const afterAnimator: Animator = createMotionAnimator(null, { + const afterAnimator: Animator = createMotionEngine(null, { now: () => clock, pseudoElement: '::after', initial: {opacity: 0}, diff --git a/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.ts b/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.ts index 69e10fba731b1..3fdf9c0af3c63 100644 --- a/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.ts +++ b/packages/core/src/SegmentedControl/useSegmentedSelectionMotion.ts @@ -25,7 +25,7 @@ import { type RefObject, } from 'react'; import { - createMotionAnimator, + createMotionEngine, type AnimateOptions, type Animator, type MotionCurve, @@ -539,7 +539,7 @@ export class SegmentedSelectionMotion { private readonly after: Animator; constructor( - private readonly animator: Animator = createMotionAnimator(null, { + private readonly animator: Animator = createMotionEngine(null, { pseudoElement: '::before', }), options: SegmentedSelectionMotionOptions = {}, @@ -552,7 +552,7 @@ export class SegmentedSelectionMotion { this.now = options.now ?? defaultNow; this.after = options.afterAnimator ?? - createMotionAnimator(null, { + createMotionEngine(null, { pseudoElement: '::after', initial: {opacity: 0}, }); diff --git a/packages/core/src/Slider/sliderTouchController.ts b/packages/core/src/Slider/sliderTouchController.ts index 5d90e247e1551..916ae87e3124c 100644 --- a/packages/core/src/Slider/sliderTouchController.ts +++ b/packages/core/src/Slider/sliderTouchController.ts @@ -29,7 +29,7 @@ import {TapGestureRecognizer} from '../gestures/TapGestureRecognizer'; import type {PointerKind} from '../gestures/types'; import {isRtlElement} from '../hooks/isRtlElement'; import { - createMotionAnimator, + createMotionEngine, type Animator, type MotionCurve, type MotionHandle, @@ -1186,7 +1186,7 @@ export class SliderTouchController { private animatorFor(element: HTMLElement): Animator { let animator = this.animators.get(element); if (animator == null) { - animator = createMotionAnimator(element); + animator = createMotionEngine(element); this.animators.set(element, animator); } return animator; diff --git a/packages/core/src/Switch/useSwitchTouch.test.ts b/packages/core/src/Switch/useSwitchTouch.test.ts index 63fd79d0771a5..b74e18c8494e2 100644 --- a/packages/core/src/Switch/useSwitchTouch.test.ts +++ b/packages/core/src/Switch/useSwitchTouch.test.ts @@ -39,7 +39,7 @@ vi.mock('../motion/animator', async importOriginal => { const actual = await importOriginal(); return { ...actual, - createMotionAnimator: ( + createMotionEngine: ( element: Element, options: {pseudoElement?: string} = {}, ) => { diff --git a/packages/core/src/Switch/useSwitchTouch.ts b/packages/core/src/Switch/useSwitchTouch.ts index 2422dc0f4735d..888f997b53878 100644 --- a/packages/core/src/Switch/useSwitchTouch.ts +++ b/packages/core/src/Switch/useSwitchTouch.ts @@ -23,7 +23,7 @@ import type {GestureTouch} from '../gestures/types'; import {isRtlElement} from '../hooks/isRtlElement'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import { - createMotionAnimator, + createMotionEngine, type Animator, type MotionCurve, } from '../motion/animator'; @@ -282,15 +282,15 @@ export class SwitchTouchController { return; } registerSwitchTable(); - this.thumb = createMotionAnimator(elements.thumb); + this.thumb = createMotionEngine(elements.thumb); // The layer rests clear (its class's opacity 0) while the visuals show // the value; from here its opacity is the animator's. - this.fade = createMotionAnimator(elements.track, { + this.fade = createMotionEngine(elements.track, { pseudoElement: '::after', initial: {opacity: 0}, }); this.fade.set({opacity: 0}); - this.shadow = createMotionAnimator(elements.thumb, { + this.shadow = createMotionEngine(elements.thumb, { pseudoElement: '::after', initial: {opacity: 0}, }); diff --git a/packages/core/src/animator/createAnimator.ts b/packages/core/src/animator/createAnimator.ts index c712bd9467c6b..0fe0663ba43c7 100644 --- a/packages/core/src/animator/createAnimator.ts +++ b/packages/core/src/animator/createAnimator.ts @@ -18,9 +18,9 @@ */ import { - createMotionAnimator, + createMotionEngine, SETTLE_THRESHOLD, - type Animator as MotionAnimator, + type Animator as MotionEngine, type MotionCurve, type MotionProperty, type MotionValues, @@ -199,7 +199,7 @@ export function createAttachableAnimator(): { readonly animator: Animator; readonly attach: (element: HTMLElement | null) => void; } { - const motion: MotionAnimator = createMotionAnimator(null); + const motion: MotionEngine = createMotionEngine(null); // Calls do nothing while no element is attached, before the ref attaches // or after it lets go. They also do nothing after destroy, and animate and // decay then resolve at once. diff --git a/packages/core/src/animator/publicSurface.test.ts b/packages/core/src/animator/publicSurface.test.ts index 00babbcb429af..e2196d246e2f0 100644 --- a/packages/core/src/animator/publicSurface.test.ts +++ b/packages/core/src/animator/publicSurface.test.ts @@ -21,7 +21,7 @@ const SRC = path.join(__dirname, '..'); /** Names of the motion module beneath the public animator. */ const INTERNAL = [ - 'createMotionAnimator', + 'createMotionEngine', 'createAttachableAnimator', 'animatorFor', 'coast', @@ -67,6 +67,6 @@ describe('the public animator surface', () => { 'utf8', ); expect(motion).not.toMatch(/export function createAnimator\b/); - expect(motion).toMatch(/export function createMotionAnimator\b/); + expect(motion).toMatch(/export function createMotionEngine\b/); }); }); diff --git a/packages/core/src/gestures/__tests__/browser/presentedFixture.ts b/packages/core/src/gestures/__tests__/browser/presentedFixture.ts index cc28188bf15eb..28d360831f071 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 {createMotionAnimator} from '../../../motion/animator'; +import {createMotionEngine} 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 = createMotionAnimator(sheet); +const animator = createMotionEngine(sheet); const drawn = (target: Element): DOMRect => Element.prototype.getBoundingClientRect.call(target); diff --git a/packages/core/src/hooks/usePager.test.ts b/packages/core/src/hooks/usePager.test.ts index 46831b156e278..506e985b43ede 100644 --- a/packages/core/src/hooks/usePager.test.ts +++ b/packages/core/src/hooks/usePager.test.ts @@ -17,7 +17,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {GestureHost} from '../gestures/GestureHost'; import {PanGestureRecognizer} from '../gestures/PanGestureRecognizer'; import {ManualClock} from '../gestures/__tests__/harness'; -import {createMotionAnimator, type Animator} from '../motion/animator'; +import {createMotionEngine, type Animator} from '../motion/animator'; import {solveAnimatedPageScroll, solvePaging} from '../motion/paging'; import type * as motionResolution from '../motion/resolveMotion'; import {resolveMotion} from '../motion/resolveMotion'; @@ -305,7 +305,7 @@ function setup(options: Partial = {}, rtl = false): Fixture { value: WIDTH, configurable: true, }); - const animator = createMotionAnimator(track, { + const animator = createMotionEngine(track, { now: () => now, requestFrame: callback => { frameQueue.push(callback); @@ -1259,7 +1259,7 @@ describe('createPager', () => { // (DR3). The drag itself follows the pointer. function reducedPager(f: Fixture, defaultIndex: number, coarse: boolean) { stubMedia(true, coarse); - const reduced = createMotionAnimator(f.track, { + const reduced = createMotionEngine(f.track, { now: () => now, requestFrame: callback => { frameQueue.push(callback); @@ -1928,7 +1928,7 @@ function setupChildren( value: WIDTH, configurable: true, }); - const animator = createMotionAnimator(track, { + const animator = createMotionEngine(track, { now: () => now, requestFrame: callback => { frameQueue.push(callback); @@ -2461,7 +2461,7 @@ describe('pre-review fixes', () => { configurable: true, }); const host = {attach: () => () => {}} as unknown as GestureHost; - const animator = createMotionAnimator(track, { + const animator = createMotionEngine(track, { now: () => now, requestFrame: callback => { frameQueue.push(callback); diff --git a/packages/core/src/internal/touch.test.ts b/packages/core/src/internal/touch.test.ts index 0300e71f3886a..02e63f68e064a 100644 --- a/packages/core/src/internal/touch.test.ts +++ b/packages/core/src/internal/touch.test.ts @@ -45,7 +45,7 @@ describe('internal/touch', () => { const scroll = await import('../motion/uikit/scroll'); expect(entry.PanGestureRecognizer).toBe(pan.PanGestureRecognizer); expect(entry.useGestureRecognizer).toBe(hook.useGestureRecognizer); - expect(entry.createMotionAnimator).toBe(animator.createMotionAnimator); + expect(entry.createMotionEngine).toBe(animator.createMotionEngine); expect(entry.cubicBezier).toBe(bezier.cubicBezier); expect(entry.mapProgress).toBe(progress.mapProgress); expect(entry.parseCssEasing).toBe(roles.parseCssEasing); @@ -54,8 +54,34 @@ describe('internal/touch', () => { expect(entry.rubberBandInverse).toBe(band.rubberBandInverse); expect(entry.rubberBandSlope).toBe(band.rubberBandSlope); expect(entry.scroll).toBe(scroll.scroll); - // No internal factory shares a public factory's name. - expect(entry).not.toHaveProperty('createAnimator'); + // No internal factory shares a public factory's name, or a planned one's. + for (const name of [ + 'createAnimator', + 'createPanGesture', + 'createDragToSnapPoints', + ]) { + expect(entry, name).not.toHaveProperty(name); + } + }); + + it('re-exports by name what other Lab components read', async () => { + const entry: Record = await import('./touch'); + const presentation = await import('../utils/touchPresentation'); + const direction = await import('../hooks/isRtlElement'); + const uikitGestures = await import('../motion/uikit/gestures'); + const touchQueries = await import('../utils/touchQueries'); + expect(entry.useTouchPresentation).toBe(presentation.useTouchPresentation); + expect(entry.isRtlElement).toBe(direction.isRtlElement); + expect(entry.UIKIT_GESTURES).toBe(uikitGestures.UIKIT_GESTURES); + expect(entry.TOUCH_QUERY).toBe(touchQueries.TOUCH_QUERY); + // An identity check passes when both sides are undefined, so each name + // must also be defined. + for (const name of ['useTouchPresentation', 'isRtlElement']) { + expect(typeof entry[name], name).toBe('function'); + } + for (const name of ['UIKIT_GESTURES', 'TOUCH_QUERY']) { + expect(entry[name], name).toBeDefined(); + } }); it('attaches no window or document listeners when it loads', async () => { diff --git a/packages/core/src/internal/touch.ts b/packages/core/src/internal/touch.ts index 560cc95f3eeac..c46d3d5a8342e 100644 --- a/packages/core/src/internal/touch.ts +++ b/packages/core/src/internal/touch.ts @@ -3,10 +3,11 @@ /** * @file touch.ts * @input The gesture core: the arena, the recognizers, and their geometry; - * the pan, the motion animator and the motion math that Lab's - * useRefreshControl reads + * the pan, the motion engine and math, and the touch-presentation and + * direction helpers that Lab components read * @output `@astryxdesign/core/internal/touch`: the gesture core's primitives - * for Lab components, re-exported by name + * and the motion, touch-presentation and direction modules for Lab + * components, re-exported by name * @position An internal entry reserved for Astryx's own packages: * undocumented, outside the public API and its compatibility promise * (spec:AST-017 FR21), versioned in lockstep with Core, and allowed only in @@ -43,7 +44,7 @@ export {TapGestureRecognizer} from '../gestures/TapGestureRecognizer'; export {DEFAULT_ENVIRONMENT} from '../gestures/types'; // Lab's useRefreshControl reads these: its pull's pan and the hook that -// starts it. It also reads the motion animator and math, and the scroll +// starts it. It also reads the motion engine and math, and the scroll // view's bounce spring, for its pull, settle and end. Its indicator fades // along the settle's curve, and the motion-role parsers read the theme's // tokens for its end. @@ -55,12 +56,12 @@ export type { PointerKind, } from '../gestures/types'; export {useGestureRecognizer} from '../gestures/useGestureRecognizer'; -// The animator's internal factory. The public one, `createAnimator`, plays +// The motion engine's factory. The public one, `createAnimator`, plays // only the theme's state-change motion. -export {createMotionAnimator} from '../motion/animator'; +export {createMotionEngine} from '../motion/animator'; export type { - Animator as MotionAnimator, - AnimatorOptions as MotionAnimatorOptions, + Animator as MotionEngine, + AnimatorOptions as MotionEngineOptions, MotionCurve, } from '../motion/animator'; export {cubicBezier} from '../motion/bezier'; @@ -72,3 +73,10 @@ export { rubberBandSlope, } from '../motion/rubberBand'; export {scroll} from '../motion/uikit/scroll'; + +// Lab components also read the UIKit gesture table, the touch presentation +// and its query, and an element's direction. +export {isRtlElement} from '../hooks/isRtlElement'; +export {UIKIT_GESTURES} from '../motion/uikit/gestures'; +export {useTouchPresentation} from '../utils/touchPresentation'; +export {TOUCH_QUERY} from '../utils/touchQueries'; diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index 352a45bbd9c4f..bb8df901afb34 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -69,7 +69,7 @@ the timings that decide who gets a touch are not roles. ## Animator ``` -import {createMotionAnimator} from '../motion/animator'; +import {createMotionEngine} from '../motion/animator'; import {coast} from '../motion/coast'; import {solveSpringTowardRamp, toSpring} from '../motion/spring'; import {resolveMotion} from '../motion/resolveMotion'; @@ -78,7 +78,7 @@ import {registerSpringsTable, springs} from '../motion/uikit'; // Once, so the springs are registered as their roles: registerSpringsTable(); -const animator = createMotionAnimator(sheet); +const animator = createMotionEngine(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. @@ -175,7 +175,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. -- `createMotionAnimator(dialog, {pseudoElement: '::backdrop'})` drives a +- `createMotionEngine(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 5d69afb906959..e8ca643b2a294 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}), ); @@ -394,7 +394,7 @@ test('a frame painted before the compositor has the curve shows the element wher document.body.append(element); return { element, - animator: Motion.createMotionAnimator(element, now ? {now} : {}), + animator: Motion.createMotionEngine(element, now ? {now} : {}), }; }; const x = (element: HTMLElement) => @@ -930,7 +930,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.createMotionAnimator(element); + const animator = Motion.createMotionEngine(element); animator.set({y: 300}); const handle = animator.animate({y: 0}, {spring: Motion.springs.settle}); await handle.started; @@ -1034,7 +1034,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.createMotionAnimator(element); + const animator = Motion.createMotionEngine(element); animator.set({opacity: 0}); animator.animate({opacity: 1}, {spring}); await new Promise(resolve => { @@ -1106,7 +1106,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.createMotionAnimator(dialog, { + const backdrop = Motion.createMotionEngine(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 874fb5e2a8d38..097b83e91a4d6 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 {createMotionAnimator} from '../animator'; +export {createMotionEngine} 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 a714bf6414137..86c75b2ad3777 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, - createMotionAnimator, + createMotionEngine, 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 = createMotionAnimator(element, { + const animator = createMotionEngine(element, { now: () => clock, requestFrame, ...options, @@ -2071,11 +2071,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); - createMotionAnimator(removed, {now: () => clock, requestFrame}); + createMotionEngine(removed, {now: () => clock, requestFrame}); removed.remove(); const kept = document.createElement('div'); document.body.append(kept); - createMotionAnimator(kept, {now: () => clock, requestFrame}); + createMotionEngine(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. @@ -2089,7 +2089,7 @@ describe('promotion: a compositing layer while it moves', () => { stylesheet['will-change'] = 'transform, opacity'; stubMedia(false, false); const element = document.createElement('div'); - const animator = createMotionAnimator(element, { + const animator = createMotionEngine(element, { now: () => clock, requestFrame, }); @@ -2834,7 +2834,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. @@ -4171,7 +4171,7 @@ describe('pseudoElement: a dialog\u2019s ::backdrop', () => { describe('attach', () => { it('keeps state without an element and resumes the curve on attach', () => { - const animator = createMotionAnimator(null, { + const animator = createMotionEngine(null, { now: () => clock, }); animator.animate({x: 300}, {spring: springs.settle}); @@ -4262,7 +4262,7 @@ describe('attach', () => { it('writes nothing for channels at identity', () => { const element = document.createElement('div'); - createMotionAnimator(element); + createMotionEngine(element); expect(element.getAttribute('style')).toBeNull(); }); }); @@ -4367,7 +4367,7 @@ describe("a theme's reduced-motion mark", () => { const element = document.createElement('div'); theme.append(element); document.body.append(theme); - const animator = createMotionAnimator(element, { + const animator = createMotionEngine(element, { now: () => clock, requestFrame, }); diff --git a/packages/core/src/motion/animator.ts b/packages/core/src/motion/animator.ts index d9af6ca317e06..ba5ec8421a5f8 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 `createMotionAnimator`, a compositor animator for + * @output Exports `createMotionEngine`, a compositor animator for * translate, rotate, scale and opacity, and its option, handle and * transition types. It reads presentation values and velocities from the * analytic curves. It retargets with the velocity kept, freezes in place on @@ -299,9 +299,6 @@ export interface MotionCurve { readonly isTransition?: boolean; } -/** - * Options of `createMotionAnimator`. - */ /** How `set` puts values in place. */ export interface SetOptions { /** @@ -317,6 +314,9 @@ export interface SetOptions { promote?: boolean; } +/** + * Options of `createMotionEngine`. + */ export interface AnimatorOptions { /** * Animates a pseudo-element of the element instead (`'::backdrop'` of a @@ -811,7 +811,7 @@ export function animatorFor( const key = options.pseudoElement ?? ''; let animator = byPseudo.get(key); if (animator == null) { - animator = createMotionAnimator(element, options); + animator = createMotionEngine(element, options); byPseudo.set(key, animator); } return animator; @@ -928,7 +928,7 @@ const REDUCED_TRANSITION: SpringTransition = { * * @example * ``` - * const animator = createMotionAnimator(sheet); + * const animator = createMotionEngine(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}}); @@ -937,7 +937,7 @@ const REDUCED_TRANSITION: SpringTransition = { * animator.stop(['y']); * ``` */ -export function createMotionAnimator( +export function createMotionEngine( element: HTMLElement | null, options: AnimatorOptions = {}, ): Animator { diff --git a/packages/core/src/motion/resolveMotion.test.ts b/packages/core/src/motion/resolveMotion.test.ts index cf5a3067619ce..3045170905ac6 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 {createMotionAnimator} from './animator'; +import {createMotionEngine} 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 = createMotionAnimator(child, {now: () => 0}); + const animator = createMotionEngine(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 = createMotionAnimator(child, {now: () => 0}); + const animator = createMotionEngine(child, {now: () => 0}); animator.set({x: 60}); // Released past the edge: straight back on the edge spring. animator.decay( diff --git a/packages/lab/src/RefreshControl/refreshTouchController.ts b/packages/lab/src/RefreshControl/refreshTouchController.ts index 9fd8d2cd703b1..e4f66cb91e0c5 100644 --- a/packages/lab/src/RefreshControl/refreshTouchController.ts +++ b/packages/lab/src/RefreshControl/refreshTouchController.ts @@ -55,7 +55,7 @@ import {cubicBezier} from '@astryxdesign/core/internal/touch'; import {mapProgress} from '@astryxdesign/core/internal/touch'; import {scroll} from '@astryxdesign/core/internal/touch'; import {observeResize} from '@astryxdesign/core/utils'; -import {useMotionAnimator} from './useMotionAnimator'; +import {useMotionEngine} from '../internal/useMotionEngine'; import { RefreshPullRecognizer, RefreshScrollPullRecognizer, @@ -186,8 +186,8 @@ export function useRefreshTouchController({ (event: RefreshEvent) => dispatchRef.current(event), [dispatchRef], ); - const content = useMotionAnimator(); - const indicator = useMotionAnimator(); + const content = useMotionEngine(); + const indicator = useMotionEngine(); const contentElement = useRef(null); const indicatorElement = useRef(null); // Whether the content holds the refreshing inset. diff --git a/packages/lab/src/RefreshControl/useMotionAnimator.ts b/packages/lab/src/RefreshControl/useMotionAnimator.ts deleted file mode 100644 index 697641a6e32fc..0000000000000 --- a/packages/lab/src/RefreshControl/useMotionAnimator.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -'use client'; - -/** - * @file useMotionAnimator.ts - * @input Core's internal motion animator (internal/touch) - * @output useMotionAnimator: one motion animator for a component's lifetime, - * and the ref that attaches it to an element - * @position Internal to useRefreshControl; refreshTouchController calls it. - * Core's public `useAnimator` plays only the theme's state-change motion. - */ - -import {useState} from 'react'; -import { - createMotionAnimator, - type MotionAnimator, - type MotionAnimatorOptions, -} from '@astryxdesign/core/internal/touch'; - -export interface UseMotionAnimatorResult { - readonly ref: (element: HTMLElement | null) => void; - readonly animator: MotionAnimator; -} - -/** One motion animator for the component's lifetime, attached through `ref`. */ -export function useMotionAnimator( - options?: MotionAnimatorOptions, -): UseMotionAnimatorResult { - const [result] = useState(() => { - const animator = createMotionAnimator(null, options); - return {animator, ref: element => animator.attach(element)}; - }); - return result; -} diff --git a/packages/lab/src/RefreshControl/useRefreshControl.test.tsx b/packages/lab/src/RefreshControl/useRefreshControl.test.tsx index 062d8d7f25a7a..15695a6a7c4b7 100644 --- a/packages/lab/src/RefreshControl/useRefreshControl.test.tsx +++ b/packages/lab/src/RefreshControl/useRefreshControl.test.tsx @@ -17,10 +17,11 @@ import {act, render, screen, waitFor} from '@testing-library/react'; import { GestureRecognizer, PanGestureRecognizer, - type MotionAnimator, type MotionCurve, + type MotionEngine, } from '@astryxdesign/core/internal/touch'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type * as MotionEngineModule from '../internal/useMotionEngine'; import {revealProgress} from './refreshPull'; import { useRefreshControl, @@ -30,14 +31,14 @@ import { // Records the animators that the touch layer creates, the content's first, // so a test can watch the settle and the indicator's fade. const animators = vi.hoisted(() => ({list: [] as unknown[]})); -vi.mock('./useMotionAnimator', async importOriginal => { - const real = await importOriginal(); +vi.mock('../internal/useMotionEngine', async importOriginal => { + const real = await importOriginal(); return { ...real, - useMotionAnimator: ( - ...args: Parameters - ): ReturnType => { - const result = real.useMotionAnimator(...args); + useMotionEngine: ( + ...args: Parameters + ): ReturnType => { + const result = real.useMotionEngine(...args); if (!animators.list.includes(result.animator)) { animators.list.push(result.animator); } @@ -54,7 +55,7 @@ vi.mock('./useMotionAnimator', async importOriginal => { function watchFade(settleCurve: () => MotionCurve | null) { const [contentAnimator, indicatorAnimator] = animators.list.slice( -2, - ) as MotionAnimator[]; + ) as MotionEngine[]; let finishSettle: (done: boolean) => void = () => {}; const animate = contentAnimator.animate.bind(contentAnimator); vi.spyOn(contentAnimator, 'animate').mockImplementation( @@ -504,14 +505,14 @@ describe('useRefreshControl', () => {

Inbox

, ); - const [, indicatorAnimator] = animators.list.slice(-2) as MotionAnimator[]; + const [, indicatorAnimator] = animators.list.slice(-2) as MotionEngine[]; const play = vi.spyOn(indicatorAnimator, 'play'); // Released below the trigger: the settle back and the fade start. pull(content(), [20, 20, 20, 10]); const fadeAt = play.mock.calls.findIndex(([curves]) => 'opacity' in curves); expect(fadeAt).toBeGreaterThanOrEqual(0); const fade = play.mock.results[fadeAt].value as ReturnType< - MotionAnimator['play'] + MotionEngine['play'] >; const shown = Number(reveal()); expect(shown).toBeGreaterThan(0); @@ -1263,7 +1264,7 @@ describe('useRefreshControl', () => { ); const [contentAnimator, indicatorAnimator] = animators.list.slice( -2, - ) as MotionAnimator[]; + ) as MotionEngine[]; const contentAnimate = vi.spyOn(contentAnimator, 'animate'); const indicatorAnimate = vi.spyOn(indicatorAnimator, 'animate'); scrollVelocity.mockClear(); diff --git a/packages/lab/src/internal/useMotionEngine.test.tsx b/packages/lab/src/internal/useMotionEngine.test.tsx new file mode 100644 index 0000000000000..73b76673ab298 --- /dev/null +++ b/packages/lab/src/internal/useMotionEngine.test.tsx @@ -0,0 +1,108 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useMotionEngine.test.tsx + * @input vitest (jsdom), React Testing Library, the hook + * @output Checks that the animator is stable, attaches through the ref, and + * never re-renders its component while animating, retargeting or tracking + * @position Testing; validates useMotionEngine.ts + */ + +import {act, render} from '@testing-library/react'; +import {useEffect} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type {MotionEngine} from '@astryxdesign/core/internal/touch'; +import {useMotionEngine} from './useMotionEngine'; + +/** A critically damped half-second spring (SwiftUI's default). */ +const SETTLE = {duration: 0.5, bounce: 0} as const; +/** A stiff, heavy spring (UIKit's sheet presentation). */ +const SHEET = {mass: 3, stiffness: 1000, damping: 500} as const; + +beforeEach(() => { + HTMLElement.prototype.animate = function () { + return {startTime: null, cancel: vi.fn()} as unknown as Animation; + }; +}); + +afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); +}); + +/** A component that hands its animator out, for the unmount test. */ +function UnmountProbe({box}: {box: {current: MotionEngine | null}}) { + const {ref, animator} = useMotionEngine(); + box.current = animator; + return
; +} + +describe('useMotionEngine', () => { + it('keeps one animator and ref, and never re-renders while animating', () => { + let renders = 0; + const seen = new Set(); + const refs = new Set(); + let api: MotionEngine | null = null; + function Probe() { + renders++; + const {ref, animator} = useMotionEngine(); + seen.add(animator); + refs.add(ref); + useEffect(() => { + api = animator; + }); + return
; + } + const {rerender, getByTestId} = render(); + const before = renders; + act(() => { + api?.animate({x: 100}, {spring: SETTLE}); + for (let x = 0; x < 50; x++) { + api?.set({y: x}); + } + api?.animate({x: 10}, {spring: SETTLE}); + api?.stop(); + }); + expect(renders).toBe(before); + expect(getByTestId('probe').style.translate).not.toBe(''); + rerender(); + expect(seen.size).toBe(1); + expect(refs.size).toBe(1); + }); + + it('attaches on mount and detaches on unmount', () => { + const box: {current: MotionEngine | null} = {current: null}; + function Probe() { + const {ref, animator} = useMotionEngine({initial: {opacity: 0.5}}); + box.current = animator; + return
; + } + const {getByTestId, unmount} = render(); + const element = getByTestId('probe'); + expect(box.current?.element).toBe(element); + expect(element.style.opacity).toBe('0.5'); + unmount(); + expect(box.current?.element).toBeNull(); + }); + + it('ends its callers waits on unmount and keeps no timer for them', async () => { + vi.useFakeTimers(); + try { + const box: {current: MotionEngine | null} = {current: null}; + const {unmount} = render(); + const animator = box.current!; + const handle = animator.animate({x: 100}, {spring: SHEET}); + let rested = false; + void animator.atRest().then(() => { + rested = true; + }); + expect(vi.getTimerCount()).toBeGreaterThan(0); + unmount(); + // Without advancing time: the curve has not ended. + await expect(handle.finished).resolves.toBe(false); + expect(rested).toBe(true); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/lab/src/internal/useMotionEngine.ts b/packages/lab/src/internal/useMotionEngine.ts new file mode 100644 index 0000000000000..5f0e9c7f00049 --- /dev/null +++ b/packages/lab/src/internal/useMotionEngine.ts @@ -0,0 +1,54 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useMotionEngine.ts + * @input React and Core's motion engine (`createMotionEngine`) + * @output useMotionEngine: a stable motion engine and the callback ref that + * attaches it to an element + * @position Lab's React binding of Core's motion engine, for Lab components. + * Lab's entry point does not export it. + * + * SYNC: When modified, update useMotionEngine.test.tsx + */ + +import {useState} from 'react'; +import { + createMotionEngine, + type MotionEngine, + type MotionEngineOptions, +} from '@astryxdesign/core/internal/touch'; + +export interface UseMotionEngineResult { + /** Callback ref for the animated element; stable. */ + readonly ref: (element: HTMLElement | null) => void; + /** The animator; stable for the component's lifetime. */ + readonly animator: MotionEngine; +} + +/** + * A compositor animator bound to an element through a callback ref. The + * animator never sets state, so a move never re-renders the component. When + * the element unmounts, the ref detaches the animator: its moves stop and its + * values stay, so a remount resumes from them. `options` are read on the + * first render only. + * + * @example + * ``` + * const {ref, animator} = useMotionEngine(); + * // `spring` is the motion role for this move, or the component's own + * // measured spring. + * const open = () => animator.animate({y: 0}, {spring}); + * return
; + * ``` + */ +export function useMotionEngine( + options?: MotionEngineOptions, +): UseMotionEngineResult { + const [result] = useState(() => { + const animator = createMotionEngine(null, options); + return {animator, ref: element => animator.attach(element)}; + }); + return result; +} diff --git a/scripts/motion-node-load.test.mjs b/scripts/motion-node-load.test.mjs index 093ea9eab1682..14fb54553f22f 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 {createMotionAnimator, animatorFor, SETTLE_THRESHOLD} from './motion/animator'; +export {createMotionEngine, animatorFor, SETTLE_THRESHOLD} from './motion/animator'; export {reducedMotionFade} from './motion/reducedMotion'; export {resolveMotion} from './motion/resolveMotion'; export {solveSpring, toSpring} from './motion/spring'; @@ -105,10 +105,10 @@ describe('the touch motion foundation in plain Node', () => { '-e', `const m = await import(${JSON.stringify(pathToFileURL(file).href)}); m.registerSpringsTable(); - const animator = m.createMotionAnimator(null); + const animator = m.createMotionEngine(null); const pan = new m.PanGestureRecognizer({}); console.log(JSON.stringify({ - createMotionAnimator: typeof m.createMotionAnimator, + createMotionEngine: typeof m.createMotionEngine, set: typeof animator.set, sheet: m.springs.sheetPresent != null, pan: typeof pan.state, @@ -117,7 +117,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({ - createMotionAnimator: 'function', + createMotionEngine: 'function', set: 'function', sheet: true, pan: 'string', diff --git a/scripts/touch-support-bundle.test.mjs b/scripts/touch-support-bundle.test.mjs index 76ebf5bc5b09f..83963a726938b 100644 --- a/scripts/touch-support-bundle.test.mjs +++ b/scripts/touch-support-bundle.test.mjs @@ -110,14 +110,16 @@ function exportsOf(entry) { } /** - * What the internal entry gives Lab's refresh pull: the pan, its hook, the - * motion animator, its motion math and companion curves, the scroll view's - * spring and the motion-role parsers. None is public API. + * What the internal entry gives Lab components, none of it public API. Lab's + * refresh pull reads the pan, its hook, the motion engine, its motion math and + * companion curves, the scroll view's spring and the motion-role parsers. + * Other Lab components read the UIKit gesture table, the touch presentation + * and its query, and an element's direction. */ const LAB_ONLY_NAMES = [ 'PanGestureRecognizer', 'useGestureRecognizer', - 'createMotionAnimator', + 'createMotionEngine', 'cubicBezier', 'mapProgress', 'parseCssEasing', @@ -126,6 +128,10 @@ const LAB_ONLY_NAMES = [ 'rubberBandInverse', 'rubberBandSlope', 'scroll', + 'isRtlElement', + 'UIKIT_GESTURES', + 'useTouchPresentation', + 'TOUCH_QUERY', ]; /** The inputs matching `pattern` that put bytes in `bytes`. */