diff --git a/.changeset/core-internal-entries-lab-tabbar.md b/.changeset/core-internal-entries-lab-tabbar.md new file mode 100644 index 0000000000000..62d4eff726e36 --- /dev/null +++ b/.changeset/core-internal-entries-lab-tabbar.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: Core's internal touch entry re-exports the action-group tracking hook, which gains a no-paint option and press callbacks for Lab's TabBar. A tab-bar string joins Core's message catalog. Apps import nothing new. + +@vjeux 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..1cb309ff3f1f5 --- /dev/null +++ b/.changeset/core-internal-touch-entry-lab-animator.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: Core's internal touch entry re-exports the motion engine, curve and gesture helpers Lab's touch components use, and the engine's factory is renamed `createMotionEngine`. Apps import nothing new. + +@vjeux diff --git a/packages/core/locales/en.json b/packages/core/locales/en.json index 7cde835ef0d44..a5076fc38d37f 100644 --- a/packages/core/locales/en.json +++ b/packages/core/locales/en.json @@ -931,6 +931,10 @@ "defaultMessage": "Maximum value", "description": "Accessible name of the upper thumb of a range Slider (two thumbs), announced together with the Slider's own label, for example \"Price range, Maximum value, 80\". Never shown on screen." }, + "@astryx.tabBar.badgedItem": { + "defaultMessage": "{label}, {badge}", + "description": "Screen-reader name of a bottom tab bar item that shows a badge (\"Library, 3\"). {label} is the tab's visible name; {badge} is the badge's count or its own label (\"3 unread\"). Translators may reorder the two or change the separator." + }, "@astryx.tabList.label": { "defaultMessage": "Tabs", "description": "Screen-reader-only fallback name for a horizontal tab bar. Plural noun; \"Tabs\" here = UI tab panels, not browser tabs or the Tab key." 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..d0ea9fd4b618c 100644 --- a/packages/core/src/animator/createAnimator.ts +++ b/packages/core/src/animator/createAnimator.ts @@ -18,7 +18,7 @@ */ import { - createMotionAnimator, + createMotionEngine, SETTLE_THRESHOLD, type Animator as MotionAnimator, type MotionCurve, @@ -199,7 +199,7 @@ export function createAttachableAnimator(): { readonly animator: Animator; readonly attach: (element: HTMLElement | null) => void; } { - const motion: MotionAnimator = createMotionAnimator(null); + const motion: MotionAnimator = 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/useActionGroupTracking.test.tsx b/packages/core/src/hooks/useActionGroupTracking.test.tsx index 05d4fe43201eb..922b110bb4f9d 100644 --- a/packages/core/src/hooks/useActionGroupTracking.test.tsx +++ b/packages/core/src/hooks/useActionGroupTracking.test.tsx @@ -12,7 +12,7 @@ * action is skipped; a second finger or the window losing focus cancels; * inside a scroller a flick is left to the scroller; a mouse under a fine * pointer is tracked like a finger; the shared tracker does the tracking; - * no render per move + * a caller's no-paint option and press callbacks; no render per move * @position jsdom tests of packages/core/src/hooks/useActionGroupTracking.ts */ @@ -313,6 +313,96 @@ describe('useActionGroupTracking', () => { expect(onDelete).not.toHaveBeenCalled(); }); + describe('a group that draws its own highlight (Lab TabBar)', () => { + const nameOf = (element: Element | null) => element?.textContent ?? 'none'; + + it('paints nothing with paintsHighlight off, and still runs the action it lifts on', () => { + const {cancel, del, onCancel} = mount({ + options: {paintsHighlight: false}, + }); + const f = finger(); + f.down(cancel, centerOf(CANCEL)); + expect(paintOf(cancel)).toBeNull(); + f.move(cancel, centerOf(DELETE)); + expect(paintOf(del)).toBeNull(); + f.move(cancel, centerOf(CANCEL)); + f.up(cancel, centerOf(CANCEL)); + expect(paintOf(cancel)).toBeNull(); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('reports the press around the tracker: the action at the begin and after each move, then the lift before its action runs', () => { + const log: string[] = []; + const {cancel} = mount({ + options: { + onPressBegin: (_x, _y, action) => log.push(`begin ${nameOf(action)}`), + onPressMove: (_x, _y, action) => log.push(`move ${nameOf(action)}`), + onPressEnd: action => log.push(`end ${nameOf(action)}`), + onPressCancel: () => log.push('cancel'), + onActivate: action => log.push(`activate ${nameOf(action)}`), + }, + }); + const f = finger(); + f.down(cancel, centerOf(CANCEL)); + f.move(cancel, {x: 104, y: 124}); + f.move(cancel, centerOf(DELETE)); + f.up(cancel, centerOf(DELETE)); + expect(log).toEqual([ + 'begin Cancel', + 'move none', + 'move Delete', + 'end Delete', + 'activate Delete', + ]); + }); + + it('reports a press that a second finger ends as a cancel, and runs nothing (family:action-groups FR3)', () => { + const log: string[] = []; + const {cancel, del} = mount({ + options: { + onPressBegin: (_x, _y, action) => log.push(`begin ${nameOf(action)}`), + onPressEnd: () => log.push('end'), + onPressCancel: () => log.push('cancel'), + onActivate: () => log.push('activate'), + }, + }); + const first = {id: nextTouchId++, ...centerOf(CANCEL)}; + const second = {id: nextTouchId++, ...centerOf(DELETE)}; + time += 1000; + touch('touchstart', cancel, first, time); + time += 30; + touch('touchstart', del, second, time, [first]); + time += 16; + touch('touchend', del, second, time, [first]); + time += 16; + touch('touchend', cancel, first, time); + expect(log).toEqual(['begin Cancel', 'cancel']); + }); + + it('reports a press that turning tracking off ends as a cancel, and runs nothing', () => { + const log: string[] = []; + const options: Partial = { + paintsHighlight: false, + onPressBegin: (_x, _y, action) => log.push(`begin ${nameOf(action)}`), + onPressEnd: () => log.push('end'), + onPressCancel: () => log.push('cancel'), + onActivate: () => log.push('activate'), + }; + const view = mount({options}); + const f = finger(); + f.down(view.cancel, centerOf(CANCEL)); + view.rerender( + , + ); + f.up(view.cancel, centerOf(CANCEL)); + expect(log).toEqual(['begin Cancel', 'cancel']); + }); + }); + it('ends the gesture with nothing acting when the window loses focus (family:action-groups FR3)', () => { const {cancel, onCancel} = mount(); const f = finger(); diff --git a/packages/core/src/hooks/useActionGroupTracking.ts b/packages/core/src/hooks/useActionGroupTracking.ts index fb07c7221a9e8..a6525e9fa8a9d 100644 --- a/packages/core/src/hooks/useActionGroupTracking.ts +++ b/packages/core/src/hooks/useActionGroupTracking.ts @@ -9,7 +9,8 @@ * @output Exports useActionGroupTracking and ActionGroupTrackingOptions * @position Internal hook, not exported from the hooks entry. A long press * drives the shared action-group tracker (hooks/actionGroupTracker.ts) for - * a group that a held pointer slides across. AlertDialog consumes it. + * a group that a held pointer slides across. AlertDialog consumes it, and + * Lab components through the internal touch entry. * * SYNC: When modified, update useActionGroupTracking.test.tsx */ @@ -49,6 +50,22 @@ export interface ActionGroupTrackingOptions { manageStyle?: boolean; /** Runs the action under the finger at the lift. @default its own click */ onActivate?: (item: HTMLElement) => void; + /** + * Paint the tracked action with the press attribute. Off, the group draws + * its own highlight. @default true + */ + paintsHighlight?: boolean; + /** A press began at viewport (x, y) on `action` (null: none). */ + onPressBegin?: (x: number, y: number, action: HTMLElement | null) => void; + /** The press moved to viewport (x, y), now on `action` (null: none). */ + onPressMove?: (x: number, y: number, action: HTMLElement | null) => void; + /** The lift, before its action runs, with the tracked action (null: none). */ + onPressEnd?: (action: HTMLElement | null) => void; + /** + * The press ended with nothing acting: a cancel, a second finger, or + * tracking turned off. + */ + onPressCancel?: () => void; } const PRESS = UIKIT_PRESS; @@ -98,6 +115,7 @@ export function useActionGroupTracking( pointerRef.current === 'touch' ? optionsRef.current.highlightExpansion : 0, + paintsHighlight: optionsRef.current.paintsHighlight, onActivate: optionsRef.current.onActivate, // The browser's own click for a press the group ran is taken, so the // action runs once. The gesture host swallows it for a press it claims. @@ -109,11 +127,16 @@ export function useActionGroupTracking( // Synchronizes the tracker with tracking on and mounted. Turning tracking // off, or unmounting, drops a tracked press and ends its release fades. // The host reports no cancellation to a disabled recognizer, and the lift - // that runs an action often closes the owner before its fade ends. + // that runs an action often closes the owner before its fade ends. The + // group hears the dropped press as a cancel, so its own highlight goes out. useEffect( () => () => { + const wasTracking = tracker.isTracking; tracker.cancel(); tracker.dispose(); + if (wasTracking) { + optionsRef.current.onPressCancel?.(); + } }, [tracker, isEnabled], ); @@ -146,14 +169,17 @@ export function useActionGroupTracking( case 'began': { const {x, y} = recognizer.location(); tracker.begin(x, y, {landed: landedRef.current}); + optionsRef.current.onPressBegin?.(x, y, tracker.highlighted); break; } case 'changed': { const {x, y} = recognizer.location(); tracker.move(x, y); + optionsRef.current.onPressMove?.(x, y, tracker.highlighted); break; } case 'ended': { + optionsRef.current.onPressEnd?.(tracker.highlighted); const doc = ref.current?.ownerDocument; const focusedBefore = doc?.activeElement ?? null; const beganOn = tracker.beganOn; @@ -182,6 +208,7 @@ export function useActionGroupTracking( } case 'cancelled': tracker.cancel(); + optionsRef.current.onPressCancel?.(); break; case 'possible': case 'failed': 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 2f175885dbf4e..dd2c329efddf7 100644 --- a/packages/core/src/internal/touch.test.ts +++ b/packages/core/src/internal/touch.test.ts @@ -28,11 +28,68 @@ describe('internal/touch', () => { for (const name of ['TapGestureRecognizer', 'LongPressGestureRecognizer']) { expect(typeof entry[name], name).toBe('function'); } + const hook = await import('../gestures/useGestureRecognizer'); + const presentation = await import('../utils/touchPresentation'); + const direction = await import('../hooks/isRtlElement'); + const animator = await import('../motion/animator'); + const bezier = await import('../motion/bezier'); + const uikitTiming = await import('../motion/uikit/timing'); + const pan = await import('../gestures/PanGestureRecognizer'); + const band = await import('../motion/rubberBand'); + const uikitGestures = await import('../motion/uikit/gestures'); + const touchQueries = await import('../utils/touchQueries'); + expect(entry.useGestureRecognizer).toBe(hook.useGestureRecognizer); + expect(entry.useTouchPresentation).toBe(presentation.useTouchPresentation); + expect(entry.isRtlElement).toBe(direction.isRtlElement); + expect(entry.createMotionEngine).toBe(animator.createMotionEngine); + expect(entry.cubicBezier).toBe(bezier.cubicBezier); + expect(entry.timing).toBe(uikitTiming.timing); + expect(entry.PanGestureRecognizer).toBe(pan.PanGestureRecognizer); + expect(entry.rubberBand).toBe(band.rubberBand); + 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 [ + 'useGestureRecognizer', + 'useTouchPresentation', + 'isRtlElement', + 'createMotionEngine', + 'cubicBezier', + 'PanGestureRecognizer', + 'rubberBand', + ]) { + expect(typeof entry[name], name).toBe('function'); + } + for (const name of ['timing', 'UIKIT_GESTURES', 'TOUCH_QUERY']) { + expect(entry[name], name).toBeDefined(); + } + // No internal factory shares a public factory's name + // (architecture:react-component-runtime INV13, spec:AST-107 FR6). + for (const name of [ + 'createAnimator', + 'createPanGesture', + 'createDragToSnapPoints', + ]) { + expect(entry, name).not.toHaveProperty(name); + } // Named re-exports only: no gathering object a component would carry // whole (architecture:component-style-authoring INV6). expect(entry).not.toHaveProperty('touchModule'); }); + it('re-exports the action-group tracking hook by name', async () => { + const entry: Record = await import('./touch'); + // Identity alone passes when both sides are undefined. + expect(typeof entry.useActionGroupTracking).toBe('function'); + const modules: Record> = { + useActionGroupTracking: await import('../hooks/useActionGroupTracking'), + }; + for (const [name, module] of Object.entries(modules)) { + expect(entry[name], name).toBe(module[name]); + } + }); + it('attaches no window or document listeners when it loads', async () => { vi.resetModules(); const onWindow = vi.spyOn(window, 'addEventListener'); diff --git a/packages/core/src/internal/touch.ts b/packages/core/src/internal/touch.ts index b1ff8a168bdb0..01471bfa7fe5a 100644 --- a/packages/core/src/internal/touch.ts +++ b/packages/core/src/internal/touch.ts @@ -2,7 +2,8 @@ /** * @file touch.ts - * @input The gesture core: the arena, the recognizers, and their geometry + * @input The gesture core and the motion, touch and direction modules Lab + * uses * @output `@astryxdesign/core/internal/touch`: the gesture core's primitives * for Lab components, re-exported by name * @position An internal entry reserved for Astryx's own packages: @@ -37,5 +38,24 @@ export { unwrapAngle, } from '../gestures/geometry'; export {LongPressGestureRecognizer} from '../gestures/LongPressGestureRecognizer'; +export {PanGestureRecognizer} from '../gestures/PanGestureRecognizer'; export {TapGestureRecognizer} from '../gestures/TapGestureRecognizer'; export {DEFAULT_ENVIRONMENT} from '../gestures/types'; +export {useGestureRecognizer} from '../gestures/useGestureRecognizer'; +export {isRtlElement} from '../hooks/isRtlElement'; +export {useActionGroupTracking} from '../hooks/useActionGroupTracking'; +export type {ActionGroupTrackingOptions} from '../hooks/useActionGroupTracking'; +export {createMotionEngine} from '../motion/animator'; +export type { + AnimateOptions, + Animator as MotionEngine, + AnimatorOptions as MotionEngineOptions, + MotionCurve, + MotionHandle, +} from '../motion/animator'; +export {cubicBezier} from '../motion/bezier'; +export {rubberBand} from '../motion/rubberBand'; +export {UIKIT_GESTURES} from '../motion/uikit/gestures'; +export {timing} from '../motion/uikit/timing'; +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 05e5a15d629a1..ab2431b2e988b 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -65,7 +65,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'; @@ -74,7 +74,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. @@ -171,7 +171,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 d1b46d31245ea..f052f7ae20585 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}), ); @@ -374,7 +374,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) => @@ -910,7 +910,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; @@ -1014,7 +1014,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 => { @@ -1086,7 +1086,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 f52f2cbb867fb..c2b5284cc5b56 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(); }); }); diff --git a/packages/core/src/motion/animator.ts b/packages/core/src/motion/animator.ts index 0a3fb468ddce7..d720dc028469f 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 @@ -298,7 +298,7 @@ export interface MotionCurve { } /** - * Options of `createMotionAnimator`. + * Options of `createMotionEngine`. */ /** How `set` puts values in place. */ export interface SetOptions { @@ -816,7 +816,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; @@ -933,7 +933,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}}); @@ -942,7 +942,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/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 20056045560e4..7daa58cd83347 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'; @@ -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.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, @@ -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({ - createMotionAnimator: 'function', + createMotionEngine: 'function', set: 'function', sheet: true, pan: 'string',