diff --git a/.changeset/build-stylex-cross-file-consts.md b/.changeset/build-stylex-cross-file-consts.md new file mode 100644 index 0000000000000..736b20da4327d --- /dev/null +++ b/.changeset/build-stylex-cross-file-consts.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/build': patch +--- + +[fix] `astryxStylex` resolves `defineConsts` values read from another file. With the `{stylexOptions}` form (the one Storybook uses), a file that read only constants from a `.stylex.ts` module lost that import once StyleX compiled it, so the constants never reached the CSS: a constant used as a media-query key failed the build ("Invalid empty selector") and one used as a value shipped as a custom property nothing declares. In either form, a constant defined on the other side of the layer split, or in an Astryx package (compiled with the library class-name prefix) and read from an app file (compiled with its own), stayed unresolved. + +@vjeux diff --git a/.changeset/gesture-arena-moving-cancel.md b/.changeset/gesture-arena-moving-cancel.md new file mode 100644 index 0000000000000..5b7e52cad131a --- /dev/null +++ b/.changeset/gesture-arena-moving-cancel.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a gesture that cancels itself on a finger's move (a long press dragged past its tolerance) keeps that finger claimed until it lifts, so the page never takes the drag over midway; a resting finger is still given back when its gesture is cancelled. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-browser-negotiation.md b/.changeset/gesture-browser-negotiation.md new file mode 100644 index 0000000000000..1930e34496d7e --- /dev/null +++ b/.changeset/gesture-browser-negotiation.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: touch-action derivation, the native scroll probe and haptics for the gesture system. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host.md b/.changeset/gesture-host.md new file mode 100644 index 0000000000000..eb9f2fec42db2 --- /dev/null +++ b/.changeset/gesture-host.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture system's DOM host (claims, click suppression, the browser's takeover, the pointer behind a contact). Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-pan.md b/.changeset/gesture-pan.md index 12eb02fc42391..908da1b367575 100644 --- a/.changeset/gesture-pan.md +++ b/.changeset/gesture-pan.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the pan gesture recognizer, with UIKit's measured hysteresis and velocity filter. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the pan gesture recognizer, with UIKit's measured hysteresis and velocity filter. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-press-tracking.md b/.changeset/gesture-press-tracking.md new file mode 100644 index 0000000000000..1c66836c74243 --- /dev/null +++ b/.changeset/gesture-press-tracking.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: UIControl press tracking with control and cell profiles and the gesture hooks, with every press painter writing `data-astryx-press` through one internal writer in `utils/pressFeedback.ts`. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-readme.md b/.changeset/gesture-readme.md new file mode 100644 index 0000000000000..9a6293b811b68 --- /dev/null +++ b/.changeset/gesture-readme.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture system's README for component authors, with its browser proof in Chromium and WebKit. + +@vjeux diff --git a/.changeset/gesture-recognizer-core.md b/.changeset/gesture-recognizer-core.md index 0b0bfc389235f..28caef0d5579c 100644 --- a/.changeset/gesture-recognizer-core.md +++ b/.changeset/gesture-recognizer-core.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the gesture recognizer core for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the gesture recognizer core for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-swipe-pinch-rotation-edge-hover.md b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md new file mode 100644 index 0000000000000..ffeadde679ef5 --- /dev/null +++ b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: swipe, pinch, rotation, screen-edge pan and hover gesture recognizers. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-tap-long-press.md b/.changeset/gesture-tap-long-press.md index 9ef786da65842..f1efef8d29014 100644 --- a/.changeset/gesture-tap-long-press.md +++ b/.changeset/gesture-tap-long-press.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: tap and long-press gesture recognizers with UIKit's measured thresholds. Not exported from the package entry point; no component uses them yet. +[chore] Internal: tap and long-press gesture recognizers with UIKit's measured thresholds. Not exported from the package entry point. @vjeux diff --git a/.changeset/list-dividers-hide-on-press.md b/.changeset/list-dividers-hide-on-press.md new file mode 100644 index 0000000000000..0af8460ffdca3 --- /dev/null +++ b/.changeset/list-dividers-hide-on-press.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] In a `List` with `hasDividers`, a touch press hides the dividers on either side of the pressed row, so the pressed row reads as one unbroken highlight; both come back the moment the finger lifts, while the highlight fades. This applies under any finger (a coarse primary pointer); with a mouse the dividers stay drawn. + +@vjeux diff --git a/.changeset/list-row-press-compositor.md b/.changeset/list-row-press-compositor.md new file mode 100644 index 0000000000000..df7f0f38f7256 --- /dev/null +++ b/.changeset/list-row-press-compositor.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A list row's touch press release keeps fading while the page runs script. `ListItem`, `Item` and `TreeList` rows used to stay fully pressed after the finger lifted until the main thread was free, and every frame of the fade repainted the row; the release now runs on the compositor, with the same color, clock and curve as before. Under a finger, while a row is pressed and through its release, the press paints on the row's `::before` and the row is its own stacking context; at rest nothing of it applies, so a row's own `::before` decoration and its children's stacking are as they were. During a press the layer is the row's `::before`, so a caller's decoration there takes the layer's box and paint for that time (a page stylesheet keeps the properties it sets); decoration that should stay through a press belongs on `::after` or a child element. With a mouse or trackpad the row is unchanged: no layer and no new stacking context. + +@vjeux diff --git a/.changeset/list-selection-hold.md b/.changeset/list-selection-hold.md new file mode 100644 index 0000000000000..222de0bbf1579 --- /dev/null +++ b/.changeset/list-selection-hold.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[chore] Internal: under a finger, a list row tapped to push can stay selected while the pushed screen covers it, then deselect as the screen pops, as a UIKit table row does; with a mouse the press releases at the lift as before. The hold that drives it (`List/selectionHold.ts`) is internal to core, so no public export changes. The deselect runs on the compositor, and a divided list keeps the dividers around the held row hidden. + +@vjeux diff --git a/.changeset/reduced-motion-record.md b/.changeset/reduced-motion-record.md new file mode 100644 index 0000000000000..2ec44f4c1f9c8 --- /dev/null +++ b/.changeset/reduced-motion-record.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: Reduce Motion for the touch presentations follows design:reduced-motion beyond the animator too. `reducedMotionFade()` gives code beside the animator the animator's own fade (an opacity-only 200 ms cross-fade on a coarse pointer or after a touch release, at once on any other pointer), `animator.reducedMotion()` reports which form a change takes for feedback that plays no fade of its own, and `reducedMotion.stylex.ts` holds the fade's constants for StyleX transitions. The fade's length is not a theme motion role. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/uikit-constants-table.md b/.changeset/uikit-constants-table.md index 01fd41a182b0b..2c063cdb50bc7 100644 --- a/.changeset/uikit-constants-table.md +++ b/.changeset/uikit-constants-table.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the UIKit constants table for touch presentations (springs, scroll physics, timing curves and navigation values), each value with its source and a checked list of the simulator measurements still owed. Not exported from the package entry point; no component reads it yet. +[chore] Internal: the UIKit constants table for touch presentations (springs, scroll physics, timing curves and navigation values), each value with its source and a checked list of the simulator measurements still owed. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-animator-pseudo-element.md b/.changeset/uikit-motion-animator-pseudo-element.md new file mode 100644 index 0000000000000..7ccb10bab35da --- /dev/null +++ b/.changeset/uikit-motion-animator-pseudo-element.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the touch presentations' animator drives a pseudo-element such as a dialog's ::backdrop: a static filling animation holds its value after a curve ends and after `set`, a custom property on its host promotes it, and a Reduce Motion dip borrows and hands back the pseudo-element's own opacity. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/uikit-motion-animator.md b/.changeset/uikit-motion-animator.md index d489e3022e6db..6b23ce1985186 100644 --- a/.changeset/uikit-motion-animator.md +++ b/.changeset/uikit-motion-animator.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; `decay` plays momentum, and sends a spring back from past an edge through the Reduce Motion policy: a cross-fade on a coarse pointer or after a touch release, at once elsewhere) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point; no component uses it yet. +[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; `decay` plays momentum, and sends a spring back from past an edge through the Reduce Motion policy: a cross-fade on a coarse pointer or after a touch release, at once elsewhere) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-math.md b/.changeset/uikit-motion-math.md index 7af3d73a92a98..cbb9399568857 100644 --- a/.changeset/uikit-motion-math.md +++ b/.changeset/uikit-motion-math.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the spring, decay, rubber-band and projection math of UIKit for touch presentations, as pure functions checked against an independent integration and against values read from UIKit. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the spring, decay, rubber-band and projection math of UIKit for touch presentations, as pure functions checked against an independent integration and against values read from UIKit. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-use-animator.md b/.changeset/uikit-motion-use-animator.md new file mode 100644 index 0000000000000..b234a4e987752 --- /dev/null +++ b/.changeset/uikit-motion-use-animator.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: useAnimator, a React wrapper around the touch presentations' animator that never re-renders per frame, and browser proofs on the interaction harness that the animator's motion keeps running through a blocked main thread in Chromium and WebKit. Not exported from the package entry point. + +@vjeux diff --git a/apps/storybook/stories/List.stories.tsx b/apps/storybook/stories/List.stories.tsx index 881f53db5430d..78bfb30e335de 100644 --- a/apps/storybook/stories/List.stories.tsx +++ b/apps/storybook/stories/List.stories.tsx @@ -7,7 +7,7 @@ * @position Storybook demonstrations for List consumers */ -import {useState} from 'react'; +import {useRef, useState, type MouseEvent} from 'react'; import * as stylex from '@stylexjs/stylex'; import type {Meta, StoryObj} from '@storybook/react'; import {List, ListItem} from '@astryxdesign/core/List'; @@ -18,6 +18,12 @@ import {Icon} from '@astryxdesign/core/Icon'; import {Switch} from '@astryxdesign/core/Switch'; import {Heading, Text} from '@astryxdesign/core/Text'; import {VStack} from '@astryxdesign/core/Layout'; +import {Button} from '@astryxdesign/core/Button'; +// The selection hold is internal to core (no public API records it yet). +import { + holdSelection, + type SelectionHold as RowSelectionHold, +} from '../../../packages/core/src/List/selectionHold'; import { Cog6ToothIcon, BellIcon, @@ -408,3 +414,118 @@ export const HeaderInFlexParent: Story = { }, }, }; + +function SelectionHoldDemo() { + const hold = useRef(null); + const slider = useRef(null); + const [open, setOpen] = useState(null); + const [progress, setProgress] = useState(0); + const push = (label: string) => (event: MouseEvent) => { + // In the same task as the activation that pushes. + hold.current = holdSelection(event.target); + setProgress(0); + setOpen(label); + }; + const settle = async ( + to: 0 | 1, + timing?: Parameters[1], + {followSlider = false}: {followSlider?: boolean} = {}, + ) => { + const current = hold.current; + // A slow pop shows where it is on the slider (written to the input, no + // render per frame), so a finger that takes the slider over mid-flight + // continues from the value on screen. (Off for the regular pop, whose + // proof counts every paint.) + const following = {frame: 0}; + const follow = () => { + if (current != null && slider.current != null) { + slider.current.value = String(current.presentation()); + } + following.frame = requestAnimationFrame(follow); + }; + if (followSlider) { + following.frame = requestAnimationFrame(follow); + } + // No hold under a mouse: the screen just pops. + const arrived = current == null || (await current.animate(to, timing)); + cancelAnimationFrame(following.frame); + if (!arrived) { + // Interrupted (a finger took the progress over mid-flight): the hold + // stays where the finger left it. + return; + } + if (to === 1) { + setOpen(null); + current?.end(true); + current?.focus({preventScroll: true}); + hold.current = null; + } else { + current?.end(false); + setProgress(0); + } + }; + return ( + + + {['Inbox', 'Sent', 'Drafts'].map(label => ( + } + /> + ))} + + {open != null && ( + + {open} (pushed) +
`; + const FIXTURE = ` +
`; + +async function busyFixture(page: Page) { + await page.route('http://busy.test/', route => + route.fulfill({contentType: 'text/html', body: BUSY_FIXTURE}), + ); + await page.goto('http://busy.test/'); +} + // A real navigation (not setContent, whose document.open() erases the window // listeners the input log installs). async function fixture(page: Page) { @@ -242,6 +298,894 @@ test.describe('touch input', () => { }); }); +test.describe('frame evidence', () => { + test('a compositor animation advances every frame through a blocked main thread; a rAF one freezes, whatever the screencast rate', async ({ + page, + }, testInfo) => { + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + + const run = async (driver: 'compositor' | 'raf', everyNthFrame = 1) => { + await box.evaluate((el, how) => { + el.getAnimations().forEach(a => a.cancel()); + el.style.transform = 'none'; + if (how === 'compositor') { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear'}, + ); + } else { + const start = performance.now(); + const step = (now: number) => { + const p = Math.min(1, (now - start) / 1600); + el.style.transform = `translateY(${560 * p}px)`; + if (p < 1) { + requestAnimationFrame(step); + } + }; + requestAnimationFrame(step); + } + }, driver); + const cast = await startScreencast(page, {everyNthFrame}); + await page.waitForTimeout(200); + const window = await blockMainThread(page, 700); + await page.waitForTimeout(200); + const tracked = await trackMarker(page, await cast.stop()); + return busyWindowProof(tracked, window, {axis: 'y'}); + }; + + const compositor = await run('compositor'); + testInfo.annotations.push({ + type: 'busy-main-thread compositor', + description: compositor.summary, + }); + expect(compositor.verdict.failures, compositor.summary).toEqual([]); + expect(compositor.monotonic).toBe(true); + // Resolvable on the cast's grid: two position steps or more per frame. + expect(compositor.notes, compositor.summary).toEqual([]); + expect(compositor.pxPerFrame).toBeGreaterThanOrEqual(2 * compositor.stepPx); + + const raf = await run('raf'); + testInfo.annotations.push({ + type: 'busy-main-thread rAF control', + description: raf.summary, + }); + // Frozen for the whole block: no presented frame moved (the compositor + // sends none at all when nothing on screen changes), and the verdict that + // passes the compositor animation fails it on every criterion. + expect(raf.changed).toBe(0); + expect(raf.longestStallMs).toBeGreaterThan(600); + expect(raf.verdict.pass).toBe(false); + expect(raf.verdict.failures.join('\n')).toMatch(/longest stall/); + expect(raf.verdict.failures.join('\n')).toMatch(/expected frames moved/); + + // The verdict judges the frames the screencast delivered, not its rate: + // at one frame in six (a loaded machine's ~100 ms), the compositor + // animation still passes and the rAF one still fails. + const slow = await run('compositor', 6); + expect(slow.cadenceMs, slow.summary).toBeGreaterThan(60); + expect(slow.verdict.failures, slow.summary).toEqual([]); + const slowRaf = await run('raf', 6); + expect(slowRaf.verdict.pass, slowRaf.summary).toBe(false); + expect(slowRaf.verdict.failures.join('\n')).toMatch(/longest stall/); + }); + + test('frames the screencast skips while the compositor presents them are not a stall; a frozen animation still fails with the compositor record', async ({ + page, + browserName, + }, testInfo) => { + test.skip( + browserName !== 'chromium', + 'compositor traces are Chromium-only (README: engine limits)', + ); + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + const run = async (driver: 'compositor' | 'raf') => { + await box.evaluate((el, how) => { + el.getAnimations().forEach(a => a.cancel()); + el.style.transform = 'none'; + if (how === 'compositor') { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear'}, + ); + } else { + const start = performance.now(); + const step = (now: number) => { + const p = Math.min(1, (now - start) / 1600); + el.style.transform = `translateY(${560 * p}px)`; + if (p < 1) { + requestAnimationFrame(step); + } + }; + requestAnimationFrame(step); + } + }, driver); + const cast = await startScreencast(page); + const compositor = await startCompositorTrace(page); + await page.waitForTimeout(200); + const window = await blockMainThread(page, 700); + await page.waitForTimeout(200); + const presented = await compositor.stop(); + const tracked = await trackMarker(page, await cast.stop()); + return {window, tracked, presented}; + }; + + const moving = await run('compositor'); + expect(moving.presented.length).toBeGreaterThan(0); + // The screencast as a loaded machine delivers it: four frames in a row + // skipped mid-block, which the compositor still presented. + const mid = moving.tracked.findIndex(f => f.t > moving.window.from + 350); + const skipped = moving.tracked.filter((_, i) => i < mid || i >= mid + 4); + const alone = busyWindowProof(skipped, moving.window, {axis: 'y'}); + expect(alone.verdict.failures.join('\n'), alone.summary).toMatch( + /longest stall/, + ); + const proof = busyWindowProof(skipped, moving.window, { + axis: 'y', + presented: moving.presented, + }); + testInfo.annotations.push({ + type: 'busy-main-thread compositor record', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.recorderSkipped).toBeGreaterThanOrEqual(4); + + // A rAF animation presents no change for the whole block: the record + // excuses nothing, whatever the compositor drew meanwhile. + const frozen = await run('raf'); + const still = busyWindowProof(frozen.tracked, frozen.window, { + axis: 'y', + presented: frozen.presented, + }); + testInfo.annotations.push({ + type: 'busy-main-thread rAF control, compositor record', + description: `${still.summary}; compositor frames inside the block: ${frozen.presented.filter(t => t > frozen.window.from && t < frozen.window.to).length}`, + }); + expect(still.verdict.pass).toBe(false); + expect(still.recorderSkipped).toBe(0); + expect(still.verdict.failures.join('\n')).toMatch(/longest stall/); + }); + + test('a block that starts during a just-started transition still lets the compositor advance it', async ({ + page, + }, testInfo) => { + // `during` blocks in the frame the transition has run 16 ms. In WebKit + // the transition reaches the compositor in that frame's rendering update, + // so a loop inside the rAF callback froze it (2 of 29 frames moved). + // Linear and longer than the block, so every frame of the block moves two + // position steps or more and the transition is still running when it + // ends. The cast shows the box at rest first, so a transition that reaches + // the screen after the block began is judged from there (onScreenFromMs). + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + const cast = await startScreencast(page); + await expect.poll(() => cast.frameCount()).toBeGreaterThan(0); + await box.evaluate(el => { + el.style.transition = 'transform 1600ms linear'; + el.getBoundingClientRect(); + el.style.transform = 'translateY(560px)'; + }); + const window = await blockMainThread(page, 700, { + during: box, + minProgressMs: 16, + }); + await waitForRest(box); + const tracked = await trackMarker(page, await cast.stop()); + const proof = busyWindowProof(tracked, window, {axis: 'y'}); + testInfo.annotations.push({ + type: 'busy-main-thread transition', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.monotonic).toBe(true); + }); + + test('a block never starts on an animation that is not on the compositor yet', async ({ + page, + }, testInfo) => { + // An animation created in the frame the helper looks is still pending (not + // committed to the compositor). A block that started on it then froze it + // in Chromium: no presented frame moved. + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + await box.evaluate(el => { + el.style.willChange = 'transform'; + }); + const cast = await startScreencast(page); + // A rAF loop registered before the helper's tick runs ahead of it in every + // frame; on its 12th frame it creates the animation, and the helper's tick + // looks in that same frame. + await box.evaluate(el => { + let n = 0; + const loop = () => { + n += 1; + if (n === 12) { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear'}, + ); + return; + } + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + }); + const window = await blockMainThread(page, 700, { + during: box, + minProgressMs: 0, + }); + await waitForRest(box); + const tracked = await trackMarker(page, await cast.stop()); + const proof = busyWindowProof(tracked, window, {axis: 'y'}); + testInfo.annotations.push({ + type: 'busy-main-thread pending animation', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.monotonic).toBe(true); + }); + + test('in the WebKit container, compiled shaders outlive the run', async ({ + browser, + }, testInfo) => { + // The official Playwright image keeps its browsers here. + test.skip( + testInfo.project.name !== 'webkit' || + process.env.PLAYWRIGHT_BROWSERS_PATH !== '/ms-playwright', + 'runs inside webkit-container.mjs', + ); + // A cache inside the container dies with it: every run then compiled each + // shader again, 450–700 ms in the first frame of a blurred layer. + const dir = process.env.MESA_SHADER_CACHE_DIR; + expect(dir, 'MESA_SHADER_CACHE_DIR names the host-kept cache').toBeTruthy(); + // Written on the host by the script before the container starts. + expect( + fs.readFileSync(path.join(dir ?? '', 'ASTRYX-SHADER-CACHE'), 'utf8'), + ).toContain('playwright'); + // Mesa writes what it compiles there once a page draws a blurred layer. + // Earlier runs have filled the cache already, so this run's browser gets + // an empty directory beside them, in the same host-kept mount, and the + // test watches its own writes arrive. + const fresh = fs.mkdtempSync(path.join(dir ?? '', 'self-test-')); + try { + const own = await browser.browserType().launch({ + env: {...process.env, MESA_SHADER_CACHE_DIR: fresh}, + }); + try { + const page = await own.newPage(); + await page.setContent( + '
', + ); + await page.evaluate( + () => + document + .getElementById('glass') + ?.animate([{scale: '1'}, {scale: '0.9'}], {duration: 200}) + .finished, + ); + } finally { + await own.close(); + } + expect(fs.readdirSync(fresh, {recursive: true})).not.toEqual([]); + } finally { + fs.rmSync(fresh, {recursive: true, force: true}); + } + }); + + test('rect sampling reads one rect per animation frame while the main thread is free', async ({ + page, + browserName, + }) => { + await fixture(page); + const box = page.locator('#box'); + await box.evaluate(el => { + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 600, easing: 'linear'}, + ); + }); + const samples = await sampleRects(box, 400); + const xs = samples.map(s => s.box?.x ?? NaN); + const gaps = samples + .slice(1) + .map((s, i) => s.t - samples[i].t) + .sort((a, b) => a - b); + const cadence = gaps[Math.floor(gaps.length / 2)]; + expect(cadence).toBeLessThan(34); + expect(samples.length).toBeGreaterThanOrEqual(10); + if (browserName === 'chromium') { + // One sample per presented frame at 60 Hz. (Headless WebKit's animation + // frames are irregular, so only the cadence is checked there.) + expect(samples.length).toBeGreaterThanOrEqual(20); + } + expect(xs.every((x, i) => i === 0 || x >= xs[i - 1])).toBe(true); + expect(xs.at(-1) ?? 0).toBeGreaterThan(xs[0] + 80); + }); + + test('the trace counts no Layout for a transform animation and one per frame for `top`', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + const box = page.locator('#box'); + // Count the animation's steady middle: starting and ending it may lay out once each. + const animate = async (property: 'transform' | 'top') => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await box.evaluate((el, prop) => { + const keyframes = + prop === 'transform' + ? [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(200px)'}, + ] + : [{top: '360px'}, {top: '560px'}]; + el.animate(keyframes, {duration: 600}); + return performance.timeOrigin + performance.now(); + }, property); + await page.waitForTimeout(700); + }); + return countBetween(trace, started + 100, started + 500); + }; + const transform = await animate('transform'); + const top = await animate('top'); + expect(transform.Layout ?? 0).toBe(0); + expect(transform.Paint ?? 0).toBe(0); + // ~24 frames in 400 ms: one layout per frame. + expect(top.Layout ?? 0).toBeGreaterThan(18); + }); + + test("the trace counts no Layout from the harness's own animation recorder, which stands down while it runs", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + // The recorder runs, as in any spec that waited for an animation first. + await waitForRest(pop); + const watch = await watchAnimations(pop); + const middle = async (askEveryFrame: boolean) => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate((el, every) => { + const w = window as unknown as {stop?: boolean}; + w.stop = false; + if (every) { + const ask = () => { + if (!w.stop) { + requestAnimationFrame(ask); + } + }; + requestAnimationFrame(ask); + } + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }, askEveryFrame); + await page.waitForTimeout(700); + await page.evaluate(() => { + (window as unknown as {stop?: boolean}).stop = true; + }); + }); + // The steady middle: starting and ending it may lay out once each. + return countBetween(trace, started + 100, started + 500); + }; + expect((await middle(false)).Layout ?? 0).toBe(0); + // Control: a page that asks for a frame every frame lays it out in each. + expect((await middle(true)).Layout ?? 0).toBeGreaterThan(18); + // Nothing the traces ran went unrecorded. + const started = await watch.started(); + expect(started.map(a => a.durationMs)).toEqual([600, 600]); + }); + + test('a waitForRest awaited inside a traced body stands down too, so the trace counts no Layout from it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }); + // A per-frame wait here would make every frame a main-thread frame. + await waitForRest(pop); + }); + expect(countBetween(trace, started + 100, started + 500).Layout ?? 0).toBe( + 0, + ); + // It still waited for the curve to end. + expect(trace.window.to - started).toBeGreaterThanOrEqual(590); + }); + + test('a waitForAnimation or watch.started() awaited inside a traced body stands down too, so the trace counts no Layout from it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + const watch = await watchAnimations(pop); + let started = 0; + let waited: {durationMs: number}[] = []; + let watched: {durationMs: number}[] = []; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }); + // Both poll until 450 ms of the curve has played, across the steady + // middle; a read of the page's animations each time would lay it out. + waited = await waitForAnimation(pop, {minProgressMs: 450}); + watched = await watch.started({minProgressMs: 450}); + await page.waitForTimeout(200); + }); + expect(countBetween(trace, started + 100, started + 500).Layout ?? 0).toBe( + 0, + ); + // Each still answered with the animation once 450 ms of it had played. + expect(waited.map(a => a.durationMs)).toEqual([600]); + expect(watched.map(a => a.durationMs)).toEqual([600]); + expect(trace.window.to - started).toBeGreaterThanOrEqual(590); + }); + + test('a waitForRest awaited inside a traced body waits for an animation the action starts two frames later', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + const trace = await traceRendering(page, async () => { + // The action starts the animation two frames later, as a component's + // effect often does. + await pop.evaluate(el => { + requestAnimationFrame(() => + requestAnimationFrame(() => { + (window as unknown as {__started: number}).__started = + performance.timeOrigin + performance.now(); + el.animate( + [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(100px)'}, + ], + {duration: 600}, + ); + }), + ); + }); + await waitForRest(pop); + }); + const started = await page.evaluate( + () => (window as unknown as {__started?: number}).__started ?? null, + ); + // The traced window holds the whole animation. + expect(started).not.toBeNull(); + expect(trace.window.to - (started as number)).toBeGreaterThanOrEqual(590); + }); + + test('a waitForRest awaited inside a traced body waits for a run going backward to its end, and no longer', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + // How long a traced waitForRest holds after a 600 ms move starts. + const traced = async (reverse: boolean, endDelay = 0) => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate( + (el, {reverse, endDelay}) => { + const run = el.animate( + [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(100px)'}, + ], + {duration: 600, endDelay}, + ); + if (reverse) { + // Its current time falls from its end time to 0. + run.reverse(); + } + return performance.timeOrigin + performance.now(); + }, + {reverse, endDelay}, + ); + await waitForRest(pop); + }); + await waitForRest(pop); + return trace.window.to - started; + }; + // Reversed with a 200 ms end delay: the move plays from 200 to 800 ms. + expect(await traced(true, 200)).toBeGreaterThanOrEqual(790); + // A reversed run is waited for as long as the same run forward, not + // twice as long. + const forward = await traced(false); + const backward = await traced(true); + expect(backward).toBeGreaterThanOrEqual(590); + expect(backward).toBeLessThan(forward * 1.5); + }); + + test('a waitForRest awaited inside a traced body runs on the real timers, so a frozen timer gate cannot hold it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + await installTimerGate(page); + await freezeTimers(page); + let outcome = ''; + await traceRendering(page, async () => { + await pop.evaluate(el => { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(100px)'}], + {duration: 300}, + ); + }); + // Held by the gate, the wait would neither resolve nor reach its + // deadline: the race reads that as "held". + outcome = await Promise.race([ + waitForRest(pop, {timeout: 2000}).then( + () => 'rested', + () => 'rejected', + ), + new Promise(resolve => setTimeout(() => resolve('held'), 4000)), + ]); + }); + await thawTimers(page); + expect(outcome).toBe('rested'); + }); + + test('inside a trace, waitForRest rests beside a scroll-timeline animation', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + const scroller = page.locator('#scroller'); + await waitForRest(scroller); + let outcome = ''; + await traceRendering(page, async () => { + // A row driven by its scroller's timeline: it moves only on scroll. + await scroller.evaluate(el => { + const row = el.firstElementChild as HTMLElement; + row.animate([{opacity: 0.5}, {opacity: 1}], { + timeline: new ScrollTimeline({source: el}), + }); + }); + outcome = await waitForRest(scroller, {timeout: 2000}).then( + () => 'rested', + (error: Error) => error.message, + ); + }); + expect(outcome).toBe('rested'); + }); + + test('a CSS transition and animation that start and end inside a trace are reported, and lay out nothing in between', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + await page.addStyleTag({ + content: + '@keyframes harness-fade { from { opacity: 1 } to { opacity: 0.6 } }', + }); + const pop = page.locator('#pop'); + // The recorder runs, and a watch is taken before the trace. + await waitForRest(pop); + const watch = await watchAnimations(pop); + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + const pop = el as HTMLElement; + pop.style.transition = 'transform 400ms linear'; + void pop.offsetWidth; + pop.style.transform = 'translateY(100px)'; + pop.style.animation = 'harness-fade 400ms linear'; + return performance.timeOrigin + performance.now(); + }); + await page.waitForTimeout(600); + }); + // Its reads at the two starts fall before the steady middle. + expect(countBetween(trace, started + 100, started + 300).Layout ?? 0).toBe( + 0, + ); + const ended = await watch.started({timeout: 1_000}); + expect(ended.map(a => a.kind).sort()).toEqual([ + 'CSSAnimation', + 'CSSTransition', + ]); + expect(ended.every(a => a.finishedAt != null)).toBe(true); + }); + + test('the compositor record refuses another open page, whose frames it could not tell apart', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'the compositor record is a Chromium trace (README: engine limits)', + ); + await fixture(page); + // Another page animating: its presented frames would be credited to + // this one, so the record refuses to start. + const other = await page.context().newPage(); + await other.setContent( + '
', + ); + await expect(startCompositorTrace(page)).rejects.toThrow( + /another page is open as the trace starts/, + ); + await other.close(); + // A page opened mid-trace: stop() refuses too, whether it is still open + // or closed again before the record stops. + const record = await startCompositorTrace(page); + const late = await page.context().newPage(); + await expect(record.stop()).rejects.toThrow( + /another page opened during the record/, + ); + await late.close(); + const brief = await startCompositorTrace(page); + const spinner = await page.context().newPage(); + await spinner.setContent( + '
', + ); + await page.waitForTimeout(200); + await spinner.close(); + await expect(brief.stop()).rejects.toThrow( + /another page opened during the record/, + ); + // In another context too. + const browser = page.context().browser(); + if (browser != null) { + const elsewhere = await startCompositorTrace(page); + const context = await browser.newContext(); + await (await context.newPage()).close(); + await context.close(); + await expect(elsewhere.stop()).rejects.toThrow( + /another page opened during the record/, + ); + } + // Alone, it records. + const alone = await startCompositorTrace(page); + await page.waitForTimeout(100); + await expect(alone.stop()).resolves.toBeInstanceOf(Array); + }); + + test('the long-task log fails closed when it never ran on the document', async ({ + page, + }) => { + // No installLongTaskLog before navigating: an empty answer would read as + // "no long task", so it throws instead. + await fixture(page); + await expect(readLongTasks(page)).rejects.toThrow( + /not running on this document/, + ); + }); + + test("the long-task log records the page's own long task but not a DevTools evaluate, which the trace reports apart", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'the Long Tasks API and rendering traces are Chromium-only', + ); + await installLongTaskLog(page); + await fixture(page); + // The page's own task: a timer that spins 120 ms. + const own = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 120; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + await frames(page, 2); + const recorded = await readLongTasks(page, own); + expect(recorded).toHaveLength(1); + expect(recorded[0].durationMs).toBeGreaterThanOrEqual(110); + // A DevTools evaluate that spins 300 ms: the trace reports it apart from + // the page's long tasks, and the log never sees it. + let block = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + block = await blockMainThread(page, 300); + }); + await frames(page, 2); + const inBlock = (t: {start: number}) => + t.start < block.to && t.start >= block.from - 20; + expect( + trace.devtoolsTasks.some(t => t.durationMs >= 290 && inBlock(t)), + ).toBe(true); + expect(trace.longTasks.filter(inBlock)).toEqual([]); + expect(await readLongTasks(page, block)).toEqual([]); + }); + + test("a harness read inside a trace is not the page's long task, while a test's own evaluate is, whether it calls the page directly or sets it off", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + // Enough animated rows that describing their keyframes takes a DevTools + // task over 50 ms. + const rows = Array.from( + {length: 1200}, + (_, i) => `
row ${i}
`, + ).join(''); + await page.setContent( + `
${rows}
`, + ); + const slow = page.locator('#slow'); + await slow.evaluate(el => + el.addEventListener('click', () => { + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + }), + ); + // Page code a test can call directly, as through a story's own API. + await page.evaluate(() => { + (window as unknown as {pageWork: (ms: number) => void}).pageWork = ( + ms: number, + ) => { + const end = performance.now() + ms; + while (performance.now() < end) { + // spin + } + }; + }); + await frames(page, 3); + const now = () => + page.evaluate(() => performance.timeOrigin + performance.now()); + const read = {from: 0, to: 0}; + const click = {from: 0, to: 0}; + const direct = {from: 0, to: 0}; + let timer = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + read.from = await now(); + await waitForAnimation(page.locator('#list')); + read.to = await now(); + // On a slow machine each frame of 1,200 running rows is a long task of + // the page's own; the windows below hold only their own work. + await page.evaluate(() => document.getElementById('list')?.remove()); + await frames(page, 2); + // The page's own task: a timer that spins 60 ms. + timer = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + // Page script run inside a DevTools evaluate: a 60 ms click handler. + click.from = await now(); + await slow.evaluate(el => (el as HTMLElement).click()); + click.to = await now(); + // Page code a test's evaluate calls directly: 60 ms of the page's work. + direct.from = await now(); + await page.evaluate(() => + (window as unknown as {pageWork: (ms: number) => void}).pageWork(60), + ); + direct.to = await now(); + }); + const within = (w: {from: number; to: number}) => (t: {start: number}) => + t.start >= w.from - 20 && t.start < w.to; + // The read itself is set apart. The rows' frames around it may be long + // tasks too, the page's own: they stay in longTasks. + expect(trace.devtoolsTasks.some(within(read))).toBe(true); + expect( + trace.longTasks.some(t => within(timer)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(click)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(direct)(t) && t.durationMs >= 55), + ).toBe(true); + expect(trace.devtoolsTasks.filter(within(direct))).toEqual([]); + }); +}); + test.describe('curve sampling', () => { test('a CSS transition samples onto its cubic-bezier to 0.05 px', async ({ page, @@ -961,6 +1905,91 @@ test.describe('animation state', () => { expect(started.map(a => a.durationMs)).toEqual([300]); }); + test('while a rendering trace runs the recorder reads only at CSS starts, and catches up after it', async ({ + page, + }) => { + await fixture(page); + const box = page.locator('#box'); + const watch = await watchAnimations(box); + // Counts the recorder's reads of the page's animations (each one flushes + // style): the whole document's, and one element's. Then flags a trace, as + // traceRendering does. + await page.evaluate(() => { + const w = window as unknown as { + __reads: number; + __elementReads: number; + __harnessRenderingTrace?: boolean; + }; + w.__reads = 0; + w.__elementReads = 0; + const read = document.getAnimations.bind(document); + document.getAnimations = () => { + w.__reads += 1; + return read(); + }; + const readElement = Element.prototype.getAnimations; + Element.prototype.getAnimations = function ( + this: Element, + ...args: Parameters + ) { + w.__elementReads += 1; + return readElement.apply(this, args); + }; + w.__harnessRenderingTrace = true; + }); + // During the trace: a CSS transition and a CSS animation that both end + // inside it, and a script animation still running after it. + await page.addStyleTag({ + content: + '@keyframes harness-fade { from { opacity: 1 } to { opacity: 0.6 } }', + }); + await box.evaluate(el => { + const box = el as HTMLElement; + box.style.transition = 'transform 120ms linear'; + void box.offsetWidth; + box.style.transform = 'translateX(100px)'; + box.style.animation = 'harness-fade 120ms linear'; + box.animate([{scale: '1'}, {scale: '0.9'}], {duration: 600}); + }); + await page.waitForTimeout(300); + const reads = () => + page.evaluate(() => (window as unknown as {__reads: number}).__reads); + // No frame loop: no document-wide read. One element read per CSS start + // event (transitionrun, transitionstart, animationstart), not per frame. + expect(await reads()).toBe(0); + expect( + await page.evaluate( + () => (window as unknown as {__elementReads: number}).__elementReads, + ), + ).toBeLessThanOrEqual(3); + // The trace ends: traceRendering clears the flag and resumes the recorder, + // which reads again, every frame. + await page.evaluate(() => { + const w = window as unknown as { + __harnessRenderingTrace?: boolean; + __harnessAnimations: {resume: () => void}; + }; + w.__harnessRenderingTrace = false; + w.__harnessAnimations.resume(); + }); + await frames(page, 3); + expect(await reads()).toBeGreaterThan(2); + // All three are reported, the two CSS ones that ended inside the trace + // with the time they finished. + await waitForRest(box); + const started = await watch.started(); + expect(started.map(a => a.kind).sort()).toEqual([ + 'Animation', + 'CSSAnimation', + 'CSSTransition', + ]); + expect( + started + .filter(a => a.kind !== 'Animation') + .map(a => a.finishedAt != null), + ).toEqual([true, true]); + }); + test('a wait answers with the animation the action started, not one that finished before it', async ({ page, }) => { @@ -1025,6 +2054,28 @@ test.describe('animation state', () => { await box.evaluate(el => el.getAnimations().forEach(a => a.finish())); }); + test('inside a rendering trace too, a watch taken right after a style change does not report that change', async ({ + page, + browserName, + }) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + await fixture(page); + const box = page.locator('#box'); + await waitForRest(box); + await traceRendering(page, async () => { + // The step: a transition the next style read creates. + await box.evaluate(el => { + el.style.transition = 'opacity 300ms linear'; + el.style.opacity = '0.5'; + }); + const watch = await watchAnimations(box); + await expect(watch.started({timeout: 200})).rejects.toThrow( + /waitForAnimation/, + ); + }); + await box.evaluate(el => el.getAnimations().forEach(a => a.finish())); + }); + test('an exit whose element the page removes at its end is still reported', async ({ page, }) => { @@ -1076,6 +2127,43 @@ test.describe('animation state', () => { } }); + test('inside a rendering trace too, a wait on an element no longer in the document rejects as missing', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + await page.evaluate(() => { + const gone = document.createElement('div'); + gone.id = 'gone'; + document.body.append(gone); + }); + const gone = page.locator('#gone'); + const watch = await watchAnimations(gone); + await page.evaluate(() => document.getElementById('gone')?.remove()); + const outcomes: string[] = []; + await traceRendering(page, async () => { + for (const wait of [ + () => watch.started({timeout: 300}), + () => waitForAnimation(gone, {timeout: 300}), + ]) { + outcomes.push( + await wait().then( + () => 'resolved', + (error: Error) => error.message, + ), + ); + } + }); + expect(outcomes).toHaveLength(2); + for (const outcome of outcomes) { + expect(outcome).toMatch(/not in the document/); + } + }); + test('a watch reports only what started after it', async ({page}) => { await fixture(page); const box = page.locator('#box'); @@ -1429,6 +2517,34 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { } }); + test("the harness's own waits run on the real timers: a frozen timer gate holds neither blockMainThread's block nor sampleRects", async ({ + page, + }) => { + await fixture(page); + await installTimerGate(page); + const box = page.locator('#box'); + await box.evaluate(el => { + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 1_500, easing: 'linear'}, + ); + }); + await freezeTimers(page); + // Settled, or still held after 5 s. + const within = (work: Promise) => + Promise.race([ + work.then(() => 'settled'), + new Promise(resolve => + setTimeout(() => resolve('held'), 5_000), + ), + ]); + expect( + await within(blockMainThread(page, 100, {during: box, timeout: 2_000})), + ).toBe('settled'); + expect(await within(sampleRects(box, 200))).toBe('settled'); + await thawTimers(page); + }); + test('the timer gate holds a component timer until the filmstrip is done', async ({ page, }, testInfo) => { @@ -1614,4 +2730,58 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { expect(state).toMatchObject({timer: false, frame: false}); expect(state.heldFor).toBeGreaterThanOrEqual(140); }); + + test('a cast filmstrip keeps exactly the presented frames of its window, labelled from its zero', async ({ + page, + }, testInfo) => { + await fixture(page); + const box = page.locator('#box'); + // JPEG frames (WebKit's are always JPEG) take the conversion path. + const cast = await startScreencast(page, {format: 'jpeg'}); + await box.evaluate(el => + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 600}, + ), + ); + await page.waitForTimeout(500); + const presented = await cast.stop(); + expect(presented.length).toBeGreaterThan(8); + // A window of the middle frames, zeroed on one of them. + const zero = presented[2].t; + const from = presented[3].t; + const to = presented[presented.length - 3].t; + const inside = presented.filter(f => f.t >= from && f.t <= to); + const seen: number[] = []; + const film = await castStoryboard(page, presented, { + name: 'harness-self-test-cast', + zero, + from, + to, + data: t => { + seen.push(t); + return {t}; + }, + }); + expect(film.frames.map(f => f.t)).toEqual(inside.map(f => f.t - zero)); + expect(film.frames.map(f => f.label)).toEqual( + inside.map(f => `t=${(f.t - zero).toFixed(1)} ms`), + ); + expect(seen).toEqual(inside.map(f => f.t)); + // Every cell is a PNG, whatever the screencast delivered. + for (const frame of film.frames) { + expect([...frame.png.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]); + } + const files = await film.write(testInfo); + expect(files['sheet.png']).toMatch(/sheet\.png$/); + // A window with no presented frame in it films nothing. + const empty = await castStoryboard(page, presented, { + name: 'harness-self-test-cast-empty', + zero, + from: to + 1, + to: to + 2, + }); + expect(empty.frames).toEqual([]); + await expect(empty.write(testInfo)).rejects.toThrow(/no frames/); + }); }); diff --git a/internal/interaction-harness/src/index.ts b/internal/interaction-harness/src/index.ts index 537bfc4d4beca..7a45ff45dfe41 100644 --- a/internal/interaction-harness/src/index.ts +++ b/internal/interaction-harness/src/index.ts @@ -12,10 +12,13 @@ * SYNC: When a module is added, export it here and describe it in README.md. */ +export * from './busy'; export * from './curves'; +export * from './frames'; export * from './math'; export * from './output'; export * from './scripts'; export * from './story'; export * from './storyboard'; export * from './touch'; +export * from './trace'; diff --git a/internal/interaction-harness/src/story.ts b/internal/interaction-harness/src/story.ts index 598f75d852cfe..cb57d59b50be2 100644 --- a/internal/interaction-harness/src/story.ts +++ b/internal/interaction-harness/src/story.ts @@ -6,7 +6,8 @@ * static server over a built Storybook * @output `phone` (the touch-phone browser context every interaction proof * runs in), `withStorybook` (serve the built Storybook for a spec file), and - * `openStory` (load one story and wait until it has rendered). + * `openStory` (load one story, with Storybook's highlight module off, and + * wait until it has rendered). * @position Stories are the reproduction path, as for every other browser * check in this repository: a scenario used for verification is checked in, * not built in a one-off page. @@ -59,13 +60,47 @@ export function withStorybook(test: TestType): () => string { }; } -/** Loads a story in the page and waits for its first render, its fonts and two frames. */ +/** Pages whose Storybook highlight module `openStory` turned off. */ +const highlightOff = new WeakSet(); + +export interface OpenStoryOptions { + /** + * Keeps Storybook's highlight module on. It is off by default: when on, it + * observes `#storybook-root` and, on every insertion, reads the computed + * style of every element on the page inside the inserting task, so a proof + * would measure Storybook as well as the component. + */ + readonly storybookHighlight?: boolean; +} + +/** + * Loads a story in the page and waits for its first render, its fonts and two + * frames. Storybook's highlight module is off (see `OpenStoryOptions`), and + * that is checked: its root element must be absent once the story renders. + */ export async function openStory( page: Page, origin: string, storyId: string, args?: Readonly>, + {storybookHighlight = false}: OpenStoryOptions = {}, ): Promise { + if (storybookHighlight) { + if (highlightOff.has(page)) { + throw new Error( + "openStory: this page already turned Storybook's highlight module off; open the story in a new page to keep it on", + ); + } + } else if (!highlightOff.has(page)) { + highlightOff.add(page); + // Storybook's highlight module returns at once when this is set before + // its preview runs. + await page.addInitScript(() => { + ( + globalThis as unknown as {__STORYBOOK_HIGHLIGHT_INITIALIZED?: boolean} + ).__STORYBOOK_HIGHLIGHT_INITIALIZED = true; + }); + } const params = new URLSearchParams({id: storyId, viewMode: 'story'}); if (args != null) { params.set( @@ -86,6 +121,14 @@ export async function openStory( requestAnimationFrame(() => requestAnimationFrame(() => resolve())), ); }); + if ( + !storybookHighlight && + (await page.locator('#storybook-highlights-root').count()) > 0 + ) { + throw new Error( + "openStory: Storybook's highlight module is on (#storybook-highlights-root exists), so every insertion would also read the style of every element on the page; this Storybook may no longer read __STORYBOOK_HIGHLIGHT_INITIALIZED", + ); + } } /** Resolves after `count` animation frames have been presented in the page. */ @@ -110,8 +153,9 @@ export async function frames(page: Page, count = 2): Promise { // A held callback is queued under its timer or frame id, so the page's own // clearTimeout and cancelAnimationFrame still cancel it while frozen; a held -// frame callback gets the time it actually runs at. `frame` stays the real -// requestAnimationFrame, for the harness's own waits. +// frame callback gets the time it actually runs at. `frame` and `timeout` +// stay the real requestAnimationFrame and setTimeout, for the harness's own +// waits. const TIMER_GATE = `(() => { if (window.__harnessTimerGate) return; const realTimeout = window.setTimeout.bind(window); @@ -119,7 +163,7 @@ const TIMER_GATE = `(() => { const realInterval = window.setInterval.bind(window); const realFrame = window.requestAnimationFrame.bind(window); const realCancelFrame = window.cancelAnimationFrame.bind(window); - const gate = {frozen: false, queue: [], frame: realFrame}; + const gate = {frozen: false, queue: [], frame: realFrame, timeout: realTimeout}; window.__harnessTimerGate = gate; const drop = key => { gate.queue = gate.queue.filter(entry => entry.key !== key); }; window.setTimeout = (fn, ms, ...args) => { diff --git a/internal/interaction-harness/src/storyboard.ts b/internal/interaction-harness/src/storyboard.ts index 2dc22fdc8cf04..a49207c4f81c4 100644 --- a/internal/interaction-harness/src/storyboard.ts +++ b/internal/interaction-harness/src/storyboard.ts @@ -336,7 +336,8 @@ export interface TouchStoryboardOptions extends StoryboardOptions { * photograph per event, about 100 ms between moves instead of the script's * 16.7. Its release velocity is that much lower, so a flick that dismisses * when played can settle back when filmed: a storyboard cannot show such a - * gesture; record it in real time instead. + * gesture; record it in real time instead (`startScreencast`, + * `castStoryboard`). */ export async function touchStoryboard( page: Page, @@ -364,3 +365,65 @@ export async function touchStoryboard( }); return storyboard; } + +/** + * A filmstrip of screencast frames (real presented frames, e.g. the frames of + * a busy-main-thread window), labelled with their time relative to `zero`. + * Evidence for what the compositor presented, not a deterministic storyboard. + */ +export async function castStoryboard( + page: Page, + frames: readonly { + readonly t: number; + readonly data: string; + readonly format: 'png' | 'jpeg'; + }[], + // A cast is already the screen's frames: no clip is applied to them. + options: Omit & { + readonly zero: number; + readonly from?: number; + readonly to?: number; + readonly data?: (t: number) => Readonly>; + }, +): Promise { + const storyboard = new Storyboard(page, options); + for (const frame of frames) { + if (options.from != null && frame.t < options.from) { + continue; + } + if (options.to != null && frame.t > options.to) { + continue; + } + const png = + frame.format === 'png' + ? Buffer.from(frame.data, 'base64') + : await toPng(page, frame.data); + const t = frame.t - options.zero; + storyboard.frames.push({ + label: `t=${t.toFixed(1)} ms`, + t, + data: options.data?.(frame.t) ?? {}, + png, + }); + } + return storyboard; +} + +async function toPng(page: Page, jpegBase64: string): Promise { + const scratch = await page.context().newPage(); + try { + const url = await scratch.evaluate(async data => { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/jpeg;base64,${data}`)).blob(), + ); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d')?.drawImage(bitmap, 0, 0); + return canvas.toDataURL('image/png'); + }, jpegBase64); + return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'); + } finally { + await scratch.close(); + } +} diff --git a/internal/interaction-harness/src/storybook.interaction.browser.spec.ts b/internal/interaction-harness/src/storybook.interaction.browser.spec.ts new file mode 100644 index 0000000000000..a711feb62cb84 --- /dev/null +++ b/internal/interaction-harness/src/storybook.interaction.browser.spec.ts @@ -0,0 +1,104 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file storybook.interaction.browser.spec.ts + * @input The built Storybook (`withStorybook`) and one of its stories + * @output `openStory` turns Storybook's highlight module off, so an insertion + * in a story carries no `getComputedStyle` scan of the page; with the module + * on (the control), every insertion carries one. Both engines; Chromium's + * trace also counts the style recalculations the scan forces + * @position Self-test for story.ts; it needs the built Storybook, as every + * component proof does + */ + +import {expect, test, type Page} from '@playwright/test'; +import {openStory, withStorybook} from './story'; +import {countBetween, traceRendering} from './trace'; + +const storybook = withStorybook(test); + +/** Counts the page's `getComputedStyle` calls, from before Storybook loads. */ +function countStyleReads(): void { + const w = window as unknown as {__styleReads: number}; + w.__styleReads = 0; + const read = window.getComputedStyle.bind(window); + window.getComputedStyle = ((element: Element, pseudo?: string | null) => { + w.__styleReads += 1; + return read(element, pseudo); + }) as typeof window.getComputedStyle; +} + +/** + * Inserts an exit copy of the story's first element into `#storybook-root`, + * as an exit animation's copy is, lets the insertion's mutation microtasks run, + * and reports what ran between its marks. + */ +async function insertCopy(page: Page) { + return page.evaluate(async () => { + const w = window as unknown as {__styleReads: number}; + const root = document.getElementById('storybook-root') as HTMLElement; + const reads = w.__styleReads; + const from = + performance.timeOrigin + performance.mark('copy:start').startTime; + const copy = (root.firstElementChild as HTMLElement).cloneNode( + true, + ) as HTMLElement; + copy.setAttribute('aria-hidden', 'true'); + root.appendChild(copy); + // A MutationObserver's callback is a microtask the insertion queued: + // these run after it. + await Promise.resolve(); + await Promise.resolve(); + const to = performance.timeOrigin + performance.mark('copy:end').startTime; + const scanned = w.__styleReads - reads; + const elements = document.body.querySelectorAll('*').length; + copy.remove(); + return {from, to, scanned, elements}; + }); +} + +test("openStory turns Storybook's highlight module off: an insertion in a story carries no getComputedStyle scan of the page", async ({ + page, + browserName, +}) => { + // The insertion's reads in every engine, and in Chromium (whose trace the + // harness reads) the style recalculations between its marks. + const measure = async (p: Page) => { + if (browserName !== 'chromium') { + return {copy: await insertCopy(p), styleRecalc: null}; + } + let copy = {from: 0, to: 0, scanned: 0, elements: 0}; + const trace = await traceRendering(p, async () => { + copy = await insertCopy(p); + }); + return { + copy, + styleRecalc: + countBetween(trace, copy.from, copy.to).UpdateLayoutTree ?? 0, + }; + }; + // The harness default. + await page.addInitScript(countStyleReads); + await openStory(page, storybook(), 'core-button--primary'); + expect(await page.locator('#storybook-highlights-root').count()).toBe(0); + const off = await measure(page); + expect(off.copy.scanned).toBe(0); + if (off.styleRecalc != null) { + expect(off.styleRecalc).toBe(0); + } + + // Control: Storybook's module kept on reads the style of every element on + // the page inside the inserting task. + const control = await page.context().newPage(); + await control.addInitScript(countStyleReads); + await openStory(control, storybook(), 'core-button--primary', undefined, { + storybookHighlight: true, + }); + expect(await control.locator('#storybook-highlights-root').count()).toBe(1); + const on = await measure(control); + expect(on.copy.scanned).toBeGreaterThanOrEqual(on.copy.elements); + if (on.styleRecalc != null) { + expect(on.styleRecalc).toBeGreaterThan(0); + } + await control.close(); +}); diff --git a/internal/interaction-harness/src/trace.ts b/internal/interaction-harness/src/trace.ts new file mode 100644 index 0000000000000..ce6a3d853d2cd --- /dev/null +++ b/internal/interaction-harness/src/trace.ts @@ -0,0 +1,466 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file trace.ts + * @input Uses a Playwright `Page` (Chromium: CDP `Tracing`) and the page's + * PerformanceObserver for long tasks + * @output `traceRendering` — counts of the main thread's rendering work + * (Layout, Paint, UpdateLayoutTree, PrePaint, …) between two marks — and + * `installLongTaskLog` / `readLongTasks`. + * @position The "no Layout/Paint during a transform/opacity animation" proof. + * A compositor animation costs the main thread nothing per frame; one that + * animates a layout property, or writes styles from JavaScript each frame, + * shows a Layout (and usually a Paint) per frame. Chromium only: WebKit + * exposes no comparable trace to Playwright. + * + * Counting is scoped to the renderer main thread that ran the page's own + * `performance.mark` calls, and to the time between the start and end marks, + * so other pages, the browser process and setup work are excluded. + * + * SYNC: Keep RENDERING_EVENTS equal to the README's list. + */ + +import type {CDPSession, Page} from '@playwright/test'; + +/** + * The mark a harness function puts on its own page-side work + * (`console.timeStamp('harness-read')` at the start of the function it + * evaluates in the page), so a trace can tell the harness's reads from page + * code a test's own evaluate runs. + */ +export const HARNESS_READ = 'harness-read'; + +/** Trace events that run page script: a listener, a timer, a frame callback. */ +const PAGE_SCRIPT_EVENTS = new Set([ + 'EventDispatch', + 'FunctionCall', + 'TimerFire', + 'FireAnimationFrame', + 'FireIdleCallback', +]); + +/** Main-thread trace events that are rendering work, by their trace names. */ +export const RENDERING_EVENTS = [ + 'UpdateLayoutTree', // style recalculation + 'Layout', + 'PrePaint', + 'Paint', + 'Layerize', + 'UpdateLayer', + 'ScrollLayer', + 'HitTest', +] as const; + +export interface RenderingTrace { + /** Count per trace event name inside the window, on the page's main thread. */ + readonly counts: Readonly>; + /** Shorthand for the four that matter most. */ + readonly layout: number; + readonly paint: number; + readonly styleRecalc: number; + readonly prePaint: number; + /** Window length, ms. */ + readonly durationMs: number; + /** + * The page's own main-thread tasks longer than 50 ms inside the window (long + * tasks), epoch ms. A task that ran a test's own DevTools command (a + * `page.evaluate`) is one, whether it calls page code directly or sets it + * off; only the harness's own marked reads are in `devtoolsTasks`. + */ + readonly longTasks: readonly { + readonly start: number; + readonly durationMs: number; + }[]; + /** + * Main-thread tasks longer than 50 ms inside the window that ran a DevTools + * command the harness marked as its own read (`HARNESS_READ`) and no page + * script, epoch ms: `waitForAnimation` describing every animation's + * keyframes, or `blockMainThread(page, ms)` without `during`. + */ + readonly devtoolsTasks: readonly { + readonly start: number; + readonly durationMs: number; + }[]; + /** Every counted rendering event with its epoch-ms time, for sub-window counts. */ + readonly events: readonly {readonly name: string; readonly t: number}[]; + /** The window, epoch ms on the page's clock. */ + readonly window: {readonly from: number; readonly to: number}; +} + +/** Counts rendering events by name inside `[from, to]` (epoch ms). */ +export function countBetween( + trace: RenderingTrace, + from: number, + to: number, +): Record { + const counts: Record = {}; + for (const e of trace.events) { + if (e.t >= from && e.t <= to) { + counts[e.name] = (counts[e.name] ?? 0) + 1; + } + } + return counts; +} + +interface TraceEvent { + name: string; + cat: string; + ph: string; + ts: number; + dur?: number; + pid: number; + tid: number; + args?: Record; +} + +/** + * Traces the page while `body` runs and counts the rendering work its main + * thread did between a start mark (placed just before `body`) and an end mark + * (just after). The harness's animation recorder, `waitForRest`, + * `waitForAnimation` and a watch's `started()` stand down meanwhile, so a body + * may wait with any of them and add no frame of its own, with one gap: on a + * CSS `@keyframes` animation (and, for an unwatched `waitForAnimation`, a + * cancelled CSS transition) the wait reads its timing, which flushes that + * animation's style, so wait on such an animation with `waitForRest`, or + * after the trace. Taking a watch, or calling `waitForRest` while an + * animation already runs on the element, reads its animations once, which + * restyles it: do either before the window a proof counts. + */ +export async function traceRendering( + page: Page, + body: () => Promise, +): Promise { + const cdp: CDPSession = await page.context().newCDPSession(page); + const events: TraceEvent[] = []; + cdp.on('Tracing.dataCollected', ({value}) => { + events.push(...(value as unknown as TraceEvent[])); + }); + const complete = new Promise(resolve => + cdp.once('Tracing.tracingComplete', () => resolve()), + ); + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { + includedCategories: [ + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'blink.user_timing', + 'toplevel', + ], + excludedCategories: ['*'], + }, + }); + const tag = `harness-${Math.random().toString(36).slice(2, 8)}`; + const mark = (name: string) => + page.evaluate( + n => performance.timeOrigin + performance.mark(n).startTime, + name, + ); + // The harness's animation recorder (curves.ts) runs a callback every frame, + // which makes each frame a main-thread one where Blink restyles every running + // animation's target (and lays out an anchored one), and it reads the page's + // animations, which flushes style: exactly what this counts. It stands down + // while this flag is set, a recorder the body itself starts included, and + // resumes once the trace ends. + const standDown = (on: boolean) => + page.evaluate(value => { + const w = window as unknown as { + __harnessRenderingTrace?: boolean; + __harnessAnimations?: {resume?: () => void}; + }; + w.__harnessRenderingTrace = value; + if (!value) { + w.__harnessAnimations?.resume?.(); + } + }, on); + await standDown(true); + let fromEpoch: number; + let toEpoch: number; + try { + fromEpoch = await mark(`${tag}:start`); + await body(); + toEpoch = await mark(`${tag}:end`); + } finally { + // Its catch-up read lands after the end mark, outside the counted window. + // A failure here would hide the body's own error, which is the one to see. + await standDown(false).catch(() => {}); + } + await cdp.send('Tracing.end'); + await complete; + await cdp.detach().catch(() => {}); + + const start = events.find(e => e.name === `${tag}:start`); + const end = events.find(e => e.name === `${tag}:end`); + if (start == null || end == null) { + throw new Error('traceRendering: the window marks were not traced'); + } + const onMain = (e: TraceEvent) => + e.pid === start.pid && + e.tid === start.tid && + e.ts >= start.ts && + e.ts <= end.ts; + // Trace time (µs, monotonic) -> epoch ms, anchored on the start mark. + const toEpochMs = (ts: number) => fromEpoch + (ts - start.ts) / 1000; + const counts: Record = {}; + const counted: {name: string; t: number}[] = []; + for (const e of events) { + if (!onMain(e) || e.ph === 'E') { + continue; + } + if ((RENDERING_EVENTS as readonly string[]).includes(e.name)) { + counts[e.name] = (counts[e.name] ?? 0) + 1; + counted.push({name: e.name, t: toEpochMs(e.ts)}); + } + } + // A task that ran a DevTools command received it over the DevTools + // session's pipe; page script it set off shows as its listener, timer or + // frame callback. + const main = events.filter(e => onMain(e) && e.ph !== 'E'); + const longTasks: {start: number; durationMs: number}[] = []; + const devtoolsTasks: {start: number; durationMs: number}[] = []; + for (const task of main) { + if (task.name !== 'RunTask' || (task.dur ?? 0) <= 50_000) { + continue; + } + const end = task.ts + (task.dur ?? 0); + const inside = main.filter( + e => e !== task && e.ts >= task.ts && e.ts <= end, + ); + const devtools = inside.some( + e => + e.name === 'Receive mojo message' && + ( + e.args?.chrome_mojo_event_info as + {mojo_interface_tag?: unknown} | undefined + )?.mojo_interface_tag === 'blink.mojom.DevToolsSession', + ); + // Only the harness's own reads are set apart: a test's evaluate may call + // page code directly, which leaves no listener or timer in the trace. + const harnessRead = inside.some( + e => + e.name === 'TimeStamp' && + (e.args?.data as {message?: unknown} | undefined)?.message === + HARNESS_READ, + ); + const pageScript = inside.some(e => PAGE_SCRIPT_EVENTS.has(e.name)); + (devtools && harnessRead && !pageScript ? devtoolsTasks : longTasks).push({ + start: toEpochMs(task.ts), + durationMs: (task.dur ?? 0) / 1000, + }); + } + return { + counts, + layout: counts.Layout ?? 0, + paint: counts.Paint ?? 0, + styleRecalc: counts.UpdateLayoutTree ?? 0, + prePaint: counts.PrePaint ?? 0, + durationMs: (end.ts - start.ts) / 1000, + longTasks, + devtoolsTasks, + events: counted, + window: {from: fromEpoch, to: toEpoch}, + }; +} + +/** + * The browser's open pages. The compositor record is browser-wide: its + * `Display::FrameDisplayed` events carry no page identity. + */ +function openPages(page: Page): Page[] { + const browser = page.context().browser(); + return browser == null + ? page.context().pages() + : browser.contexts().flatMap(context => context.pages()); +} + +/** + * Watches for a page that opens while the record runs, in any context (it + * may close again before the record stops). Returns whether one opened. + */ +async function watchForOtherPages( + page: Page, +): Promise<{stop(): Promise}> { + let opened = false; + const onPage = (other: Page) => { + if (other !== page) { + opened = true; + } + }; + // The page's own context, through Playwright (a persistent context has no + // browser to ask). + page.context().on('page', onPage); + // Every context, through the browser's target discovery: a page in a + // context created during the record is a new target too. + let targets: CDPSession | null = null; + const browser = page.context().browser(); + if (browser != null) { + try { + targets = await browser.newBrowserCDPSession(); + const {targetInfos} = await targets.send('Target.getTargets'); + const known = new Set(targetInfos.map(info => info.targetId)); + targets.on('Target.targetCreated', ({targetInfo}) => { + if (targetInfo.type === 'page' && !known.has(targetInfo.targetId)) { + opened = true; + } + }); + await targets.send('Target.setDiscoverTargets', {discover: true}); + } catch { + targets = null; + } + } + return { + async stop() { + page.context().off('page', onPage); + await targets?.detach().catch(() => {}); + return opened; + }, + }; +} + +function assertOnlyPage(page: Page, when: string): void { + if (openPages(page).some(other => other !== page && !other.isClosed())) { + throw new Error( + `startCompositorTrace: another page is open ${when}; the compositor record is browser-wide (its frames carry no page identity), so another page's frames would be credited to this one`, + ); + } +} + +/** + * Records when the compositor presents each frame, for `busyWindowProof`'s + * `presented`: a Chromium trace of `Display::FrameDisplayed`, on the page + * clock. Start it with the screencast, before the block, and stop it after. + * Engines without a CDP trace (WebKit) resolve `stop()` to [], and the + * verdict then judges the screencast alone. It runs its own trace, so not + * inside `traceRendering`. The record is browser-wide, so it throws when + * another page is open as it starts or stops, or opened while it ran. + */ +export async function startCompositorTrace( + page: Page, +): Promise<{stop(): Promise}> { + let cdp: CDPSession; + try { + cdp = await page.context().newCDPSession(page); + } catch { + return {stop: async () => []}; + } + try { + assertOnlyPage(page, 'as the trace starts'); + } catch (error) { + await cdp.detach().catch(() => {}); + throw error; + } + const others = await watchForOtherPages(page); + const events: TraceEvent[] = []; + cdp.on('Tracing.dataCollected', ({value}) => { + events.push(...(value as unknown as TraceEvent[])); + }); + const complete = new Promise(resolve => + cdp.once('Tracing.tracingComplete', () => resolve()), + ); + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { + includedCategories: [ + 'disabled-by-default-display.framedisplayed', + 'blink.user_timing', + ], + excludedCategories: ['*'], + }, + }); + const tag = `harness-compositor-${Math.random().toString(36).slice(2, 8)}`; + const epoch = await page.evaluate( + n => performance.timeOrigin + performance.mark(n).startTime, + tag, + ); + return { + async stop() { + await cdp.send('Tracing.end'); + await complete; + await cdp.detach().catch(() => {}); + if (await others.stop()) { + throw new Error( + "startCompositorTrace: another page opened during the record; the compositor record is browser-wide (its frames carry no page identity), so that page's frames would be credited to this one", + ); + } + assertOnlyPage(page, 'as the trace stops'); + const mark = events.find(e => e.name === tag); + if (mark == null) { + throw new Error('startCompositorTrace: the clock mark was not traced'); + } + // Trace time (µs, monotonic) -> epoch ms, anchored on the mark. + return events + .filter(e => e.name === 'Display::FrameDisplayed') + .map(e => epoch + (e.ts - mark.ts) / 1000) + .sort((a, b) => a - b); + }, + }; +} + +const LONG_TASK_SCRIPT = `(() => { + if (window.__harnessLongTasks) return; + const tasks = []; + const log = {tasks, supported: false}; + window.__harnessLongTasks = log; + if (typeof PerformanceObserver === 'undefined' || !(PerformanceObserver.supportedEntryTypes || []).includes('longtask')) return; + try { + new PerformanceObserver(list => { + for (const entry of list.getEntries()) tasks.push({start: performance.timeOrigin + entry.startTime, durationMs: entry.duration, name: entry.name}); + }).observe({type: 'longtask', buffered: true}); + log.supported = true; + } catch {} +})()`; + +/** + * Records every long task (> 50 ms) the page runs (install before navigation; + * Chromium), through the Long Tasks API. That sees the tasks of the page's own + * event loop: a timer, an event handler, `blockMainThread(page, ms, {during})`. + * A DevTools `page.evaluate`, such as `blockMainThread(page, ms)` without + * `during`, is not one of them and is never recorded here; `traceRendering` + * reports it in `devtoolsTasks`, apart from the page's `longTasks`. + */ +export async function installLongTaskLog(page: Page): Promise { + await page.addInitScript(LONG_TASK_SCRIPT); +} + +export interface LongTask { + /** Epoch ms. */ + readonly start: number; + readonly durationMs: number; + readonly name: string; +} + +/** + * Long tasks recorded since navigation, optionally only those overlapping a + * window. Fails closed: it throws when the log never ran on this document + * (installed after the page navigated) or the engine has no Long Tasks API + * (WebKit), so an empty answer is evidence of no long task, never of no log. + */ +export async function readLongTasks( + page: Page, + range?: {from: number; to: number}, +): Promise { + const log = await page.evaluate( + () => + ( + window as unknown as { + __harnessLongTasks?: {tasks: LongTask[]; supported: boolean}; + } + ).__harnessLongTasks ?? null, + ); + if (log == null) { + throw new Error( + 'readLongTasks: the long-task log is not running on this document; call installLongTaskLog(page) before navigating', + ); + } + if (!log.supported) { + throw new Error( + 'readLongTasks: this engine has no Long Tasks API, so the log records nothing', + ); + } + const tasks = log.tasks; + return range == null + ? tasks + : tasks.filter( + t => t.start < range.to && t.start + t.durationMs > range.from, + ); +} diff --git a/packages/build/__fixtures__/stylex-consts/App.tsx b/packages/build/__fixtures__/stylex-consts/App.tsx new file mode 100644 index 0000000000000..5e839e5372912 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/App.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Reads `defineConsts` values from other files, as a media-query key and as a + * plain value, from both sides of the layer split and from a file compiled + * with the library class-name prefix. Only the constants are + * imported: once StyleX compiles `stylex.create`, nothing in this file uses + * the imports at runtime any more. + */ + +import * as stylex from '@stylexjs/stylex'; +import {appConsts} from './appConsts.stylex'; +import {libraryConsts} from './library/libraryConsts.stylex'; +import {packageConsts} from './packages/core/packageConsts.stylex'; + +const styles = stylex.create({ + row: { + minHeight: appConsts.rowHeight, + transitionProperty: {default: 'color', [appConsts.narrow]: 'none'}, + }, + panel: { + paddingTop: libraryConsts.inset, + paddingBottom: packageConsts.gap, + maxWidth: {default: null, [libraryConsts.wide]: '640px'}, + }, +}); + +export function App() { + return ( +
+
row
+
+ ); +} diff --git a/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts new file mode 100644 index 0000000000000..5e41328b69880 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts @@ -0,0 +1,9 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** Constants the app defines itself, beside the file that reads them. */ +export const appConsts = stylex.defineConsts({ + narrow: '@media (max-width: 599px)', + rowHeight: '52px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/index.html b/packages/build/__fixtures__/stylex-consts/index.html new file mode 100644 index 0000000000000..ba86fc975ff35 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/index.html @@ -0,0 +1,12 @@ + + + + + + stylex-consts fixture — defineConsts imported across files + + +
+ + + diff --git a/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts new file mode 100644 index 0000000000000..92dda6955e61f --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts @@ -0,0 +1,12 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants on the library side of the layer split (the build test points + * `libraryPattern` at this folder), read by an app file on the product side. + */ +export const libraryConsts = stylex.defineConsts({ + wide: '@media (min-width: 900px)', + inset: '13px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/main.tsx b/packages/build/__fixtures__/stylex-consts/main.tsx new file mode 100644 index 0000000000000..5811fe017bb3e --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/main.tsx @@ -0,0 +1,6 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {createRoot} from 'react-dom/client'; +import {App} from './App'; + +createRoot(document.getElementById('root')!).render(); diff --git a/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts new file mode 100644 index 0000000000000..b4be85d7a3505 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts @@ -0,0 +1,13 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants as an Astryx package defines them: the path holds `packages/core/`, + * so the babel wrapper compiles this file with the library class-name prefix + * (`astryx`), as it does a real package. The app file reading them compiles + * with its own prefix (`x`). + */ +export const packageConsts = stylex.defineConsts({ + gap: '17px', +}); diff --git a/packages/build/src/vite.build.test.ts b/packages/build/src/vite.build.test.ts index ee860275a2752..b0907675792c1 100644 --- a/packages/build/src/vite.build.test.ts +++ b/packages/build/src/vite.build.test.ts @@ -31,7 +31,7 @@ */ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; -import {build} from 'vite'; +import {build, createLogger} from 'vite'; import react from '@vitejs/plugin-react'; import { mkdirSync, @@ -70,6 +70,21 @@ type Built = { async function buildFixture( name: string, htmlNames: string[] = ['index.html'], + { + libraryPattern = 'packages/core/', + zeroConfig = false, + stylexOverrides, + warnings = [], + }: { + /** Which source paths land in the library layer. */ + libraryPattern?: string; + /** Use `astryxStylex({...})` instead of the legacy `{stylexOptions}` form. */ + zeroConfig?: boolean; + /** Zero-config form only: `stylexOverrides`. */ + stylexOverrides?: Record; + /** Receives every warning the build logs. */ + warnings?: string[]; + } = {}, ): Promise { const root = path.join(FIXTURES, name); const outDir = mkdtempSync(path.join(tmpdir(), `astryx-${name}-`)); @@ -77,6 +92,11 @@ async function buildFixture( await build({ root, logLevel: 'error', + customLogger: { + ...createLogger('error'), + warn: (message: string) => void warnings.push(message), + warnOnce: (message: string) => void warnings.push(message), + }, build: { outDir, emptyOutDir: true, @@ -94,17 +114,27 @@ async function buildFixture( resolve: {alias: {'@astryxdesign/core': CORE_SRC}}, plugins: [ react(), - ...astryxStylex({ - stylexOptions: { - dev: false, - unstable_moduleResolution: {type: 'commonJS', rootDir: REPO_ROOT}, - aliases: { - '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], - '@astryxdesign/core': [CORE_SRC], - }, - }, - libraryPattern: 'packages/core/', - }), + ...(zeroConfig + ? astryxStylex({ + dev: false, + rootDir: REPO_ROOT, + libraryPattern, + stylexOverrides, + }) + : astryxStylex({ + stylexOptions: { + dev: false, + unstable_moduleResolution: { + type: 'commonJS', + rootDir: REPO_ROOT, + }, + aliases: { + '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], + '@astryxdesign/core': [CORE_SRC], + }, + }, + libraryPattern, + })), ], }); } catch (error) { @@ -338,6 +368,67 @@ describe('a production build separates Astryx and product styles by layer', () = }); }); +// `defineConsts` values are compiled into the reading file as placeholders, +// `var(--)`, and the defining file contributes the values; the +// final CSS pass substitutes one for the other. Both halves must reach that +// pass. Storybook builds through the legacy `{stylexOptions}` form, whose +// StyleX instance ran without `treeshakeCompensation`: once `stylex.create` +// was compiled, nothing used the import at runtime, the TypeScript transform +// dropped it, and the defining module was never loaded. A media-query key was +// left as `var(--x…){…}` and failed the build ("Invalid empty selector"); a +// value shipped as a custom property nothing defines. And a constant defined +// on one side of the layer split and read on the other was substituted only +// within its own side, and one an Astryx package defines (compiled with the +// library class-name prefix) never matched an app file's placeholder (written +// with the product prefix). +describe('a production build resolves defineConsts imported across files', () => { + /** A constant reference StyleX left unsubstituted. */ + const PLACEHOLDER = /var\(--(?:astryx|x)[a-z0-9]{4,}\)/; + + for (const zeroConfig of [false, true]) { + describe( + zeroConfig ? 'zero-config form' : 'legacy stylexOptions form (Storybook)', + () => { + let built: Built; + beforeAll(async () => { + built = await buildFixture('stylex-consts', ['index.html'], { + libraryPattern: '__fixtures__/stylex-consts/library/', + zeroConfig, + }); + }, 180_000); + afterAll( + () => built && rmSync(built.outDir, {recursive: true, force: true}), + ); + + it('substitutes a constant read as a value', () => { + expect(built.css).toMatch(/min-height:\s*52px/); + }); + + it('substitutes a constant read as a media-query key', () => { + expect(built.css).toMatch( + /@media \((?:max-width: ?599px|width ?<= ?599px)\)\s*\{[^}]*transition-property:\s*none/, + ); + }); + + it('substitutes a library-side constant read from the product side', () => { + expect(built.css).toMatch(/padding-top:\s*13px/); + expect(built.css).toMatch( + /@media \((?:min-width: ?900px|width ?>= ?900px)\)\s*\{[^}]*max-width:\s*640px/, + ); + }); + + it('substitutes a constant an Astryx package defines under the library prefix', () => { + expect(built.css).toMatch(/padding-bottom:\s*17px/); + }); + + it('leaves no constant placeholder behind', () => { + expect(built.css).not.toMatch(PLACEHOLDER); + }); + }, + ); + } +}); + describe('a production build validates StyleX declarations', () => { it('fails with longhand guidance instead of dropping a border shorthand', async () => { await expect(buildFixture('invalid-border')).rejects.toThrow( diff --git a/packages/build/src/vite.ts b/packages/build/src/vite.ts index 0581b58675edb..edaa88ca55a1d 100644 --- a/packages/build/src/vite.ts +++ b/packages/build/src/vite.ts @@ -136,6 +136,12 @@ export function astryxStylex( const libraryLayer = layers.library ?? 'astryx-base'; const productLayer = layers.product ?? 'product'; + // Library files compile with `stylexPrefix`, product files with StyleX's + // `classNamePrefix` (the babel wrapper routes them). + const classNamePrefixes = [ + stylexPrefix, + (stylexOverrides as {classNamePrefix?: string}).classNamePrefix ?? 'x', + ]; const overrideLightningcssOptions = ( stylexOverrides as {lightningcssOptions?: Record} @@ -284,6 +290,7 @@ export function astryxStylex( libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -302,6 +309,7 @@ export function astryxStylex( libraryLayer, productLayer, lightningcssOptions: stylexOptions.lightningcssOptions, + classNamePrefixes, }), ]; } @@ -330,24 +338,43 @@ function renderSplitLayers( libraryPattern: string; libraryLayer: string; productLayer: string; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): string { if (!rulesById || rulesById.size === 0) return ''; const libraryRules: unknown[] = []; const productRules: unknown[] = []; + // `defineConsts` values. A file that reads a constant compiles it to a + // placeholder, `var(--)`, and `processStylexRules` substitutes + // the value from the defining file's rules, but only among the rules it is + // given. The defining file and the reading one can sit on opposite sides of + // the split, so every layer gets every constant, under every class-name + // prefix (see constantRulesForEveryPrefix). Constant rules emit no CSS of + // their own, so repeating them adds nothing to the output. + const collectedConstants: unknown[] = []; for (const [filePath, rules] of rulesById.entries()) { - if (filePath.includes(options.libraryPattern)) { - libraryRules.push(...rules); - } else { - productRules.push(...rules); + const own = filePath.includes(options.libraryPattern) + ? libraryRules + : productRules; + for (const rule of rules) { + if (isConstantRule(rule)) { + collectedConstants.push(rule); + } else { + own.push(rule); + } } } + const constantRules = constantRulesForEveryPrefix( + collectedConstants, + options.classNamePrefixes, + ); const render = (rules: unknown[], layer: string) => rules.length - ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules(rules as never, {useLayers: true})}\n}` + ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules([...constantRules, ...rules] as never, {useLayers: true})}\n}` : ''; return [ @@ -358,6 +385,56 @@ function renderSplitLayers( .join('\n\n'); } +/** + * Each `defineConsts` rule once under every class-name prefix in play. + * + * StyleX keys a constant by its defining file's prefix, `` (or + * `-` with debug class names), where the hash depends on + * the export and key alone, and a file that reads it writes the placeholder + * with its own prefix. An Astryx package compiles with the library prefix and + * an app with the product one, so an app reading a package's constant writes + * `var(--x)` while the package's rule is keyed `astryx`; the + * copies make every prefix's placeholder resolve. A key StyleX took verbatim + * (`'--name'`) carries no prefix and is kept as it is. + */ +function constantRulesForEveryPrefix( + rules: readonly unknown[], + prefixes: readonly string[], +): unknown[] { + const ordered = [...new Set(prefixes)] + .filter(Boolean) + .sort((a, b) => b.length - a.length); + const out: unknown[] = []; + const keys = new Set(); + const add = (rule: unknown[], key: string) => { + if (keys.has(key)) return; + keys.add(key); + const [, body, ...rest] = rule; + out.push([key, {...(body as object), constKey: key}, ...rest]); + }; + for (const rule of rules as unknown[][]) { + const key = String((rule[1] as {constKey: unknown}).constKey); + add(rule, key); + for (const prefix of ordered) { + const match = new RegExp(`^(|.*-)${prefix}([0-9a-z]+)$`).exec(key); + if (match == null) continue; + for (const other of ordered) { + add(rule, `${match[1]}${other}${match[2]}`); + } + break; + } + } + return out; +} + +/** A `defineConsts` value as StyleX collects it: a rule with `constKey`/`constVal`. */ +function isConstantRule(rule: unknown): boolean { + const body = Array.isArray(rule) + ? (rule[1] as {constKey?: unknown; constVal?: unknown} | undefined) + : undefined; + return body?.constKey != null && body?.constVal != null; +} + /** * The build half of the split. StyleX appends one merged block of CSS to the * build's stylesheet, in its own top-level `@layer priority1…priorityN` — which @@ -387,6 +464,8 @@ function buildLayerSplitPlugin( libraryLayer: string; productLayer: string; lightningcssOptions?: unknown; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): Plugin { const stylex = basePlugin as { @@ -586,12 +665,25 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { ).lightningcssOptions; const validatedStylexOptions = { propertyValidationMode: 'throw', + // The unplugin applies its Vite default (true) only to its own StyleX + // instance, which runs after the babel wrapper below has already compiled + // every StyleX call. Without it here, a file that reads only `defineConsts` + // from a `.stylex.ts` module loses that import once `stylex.create` is + // compiled (the TypeScript transform drops it as unused), the module is + // never loaded, and its constants never reach the CSS: a media-query key + // fails the build and a value ships as an undefined custom property. + treeshakeCompensation: true, ...(stylexOptions as any), lightningcssOptions: { targets: LIGHTNINGCSS_TARGETS, ...legacyLightningcssOptions, }, }; + const classNamePrefixes = [ + stylexPrefix, + (validatedStylexOptions as {classNamePrefix?: string}).classNamePrefix ?? + 'x', + ]; const astryxBabelPlugin = path.resolve(__dirname, 'babel.js'); const existingPlugins = validatedStylexOptions.babelConfig?.plugins ?? []; @@ -658,6 +750,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -677,6 +770,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { lightningcssOptions: ( validatedStylexOptions as {lightningcssOptions?: unknown} ).lightningcssOptions, + classNamePrefixes, }), ]; } diff --git a/packages/core/package.json b/packages/core/package.json index 8bd491e6273cb..59a7b26167438 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -859,6 +859,7 @@ "devDependencies": { "@astryxdesign/a11y-spec": "workspace:*", "@astryxdesign/cli": "workspace:*", + "@astryxdesign/interaction-harness": "workspace:*", "@babel/cli": "catalog:", "@babel/core": "catalog:", "@babel/preset-react": "catalog:", diff --git a/packages/core/src/Item/Item.tsx b/packages/core/src/Item/Item.tsx index 34b264d00124d..60cc655f00f9e 100644 --- a/packages/core/src/Item/Item.tsx +++ b/packages/core/src/Item/Item.tsx @@ -4,7 +4,8 @@ /** * @file Item.tsx - * @input Uses React, ReactNode, StyleXStyles, theme tokens, useClickableContainer + * @input Uses React, ReactNode, StyleXStyles, theme tokens, useClickableContainer, + * the shared press overlays and the compositor press layer * @output Exports Item component, ItemProps type; publishes the shared inline inset * @position Core layout primitive; consumed by index.ts, tested by Item.test.tsx * @@ -53,7 +54,10 @@ import {useDevWarning} from '../hooks/useDevWarning'; import {themeProps} from '../utils/themeProps'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; +import {cellPressableProps} from '../utils/pressFeedback'; +import {rowPressLayerStyles} from '../utils/rowPressLayer'; import {usePressFeedback} from '../hooks/usePressFeedback'; +import {useInteractionModalityTracking} from '../utils/interactionModality'; import type {ItemSwipeBridge} from './ItemSwipeLayer'; // The gesture and the panels, loaded only by a row that has swipe actions: a @@ -586,6 +590,10 @@ export function Item({ ...restProps }: ItemProps) { const pressable = usePressFeedback(); + // A selection hold (List/selectionHold.ts) returns focus to this row after + // a pop; its ring follows how the user last interacted, so a finger's pop + // shows none. + useInteractionModalityTracking(); const LinkComponent = useLinkComponent(); // Delegation mode: the row is an enlarged click/tap target for a nested @@ -876,7 +884,7 @@ export function Item({ (isSelected && !allowsAriaSelected ? true : undefined) } aria-disabled={isDisabled || undefined} - {...(isInteractive ? pressable : undefined)} + {...(isInteractive ? {...pressable, ...cellPressableProps} : undefined)} {...mergeProps( themeProps('item', {density, align}), focusOutlineProps.focusWithin( @@ -886,6 +894,10 @@ export function Item({ align === 'first-line' && styles.alignFirstLine, isInteractive && styles.interactive, isInteractive && interactionOverlayStyles.backgroundColor, + // The touch press on a compositor layer (after the overlay, whose + // own touch paint it turns off): the release keeps fading while + // script runs. + isInteractive && rowPressLayerStyles.cell, isLinkRoot && styles.linkRoot, isHighlighted && styles.highlighted, isSelected && styles.selected, diff --git a/packages/core/src/List/List.doc.mjs b/packages/core/src/List/List.doc.mjs index 81b2acaba52ec..7f0d838f97a5c 100644 --- a/packages/core/src/List/List.doc.mjs +++ b/packages/core/src/List/List.doc.mjs @@ -38,7 +38,7 @@ export const docs = { { name: 'hasDividers', type: 'boolean', - description: 'Show dividers between items.', + description: 'Show dividers between items. Under a finger, a touch press hides the dividers on either side of the pressed row until the finger lifts, as iOS lists do.', default: 'false', }, { diff --git a/packages/core/src/List/List.exports.test.ts b/packages/core/src/List/List.exports.test.ts new file mode 100644 index 0000000000000..df27d6627f624 --- /dev/null +++ b/packages/core/src/List/List.exports.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file List.exports.test.ts + * @input vitest, core's package.json and the List entry point (index.ts) + * @output Asserts the selection hold stays internal: `@astryxdesign/core/List` + * exports no `holdSelection`, and core's `exports` do not list + * selectionHold.ts (`architecture:public-component-api` INV12) + * @position Unit test beside List/index.ts + */ + +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {describe, expect, it} from 'vitest'; +import * as list from './index'; + +describe('the selection hold stays internal', () => { + it('is not exported from @astryxdesign/core/List', () => { + expect(list).not.toHaveProperty('holdSelection'); + }); + + it('lives in a module that core’s exports do not list', () => { + const pkg = JSON.parse( + readFileSync(join(process.cwd(), 'packages/core/package.json'), 'utf8'), + ) as {exports: Record}; + const sources = Object.values(pkg.exports).map(entry => + typeof entry === 'string' ? entry : entry.source, + ); + expect(sources).not.toContain('./src/List/selectionHold.ts'); + }); +}); diff --git a/packages/core/src/List/List.test.tsx b/packages/core/src/List/List.test.tsx index 8ed1f09f40b38..2091dbf4bc7ee 100644 --- a/packages/core/src/List/List.test.tsx +++ b/packages/core/src/List/List.test.tsx @@ -13,6 +13,41 @@ import {describe, it, expect, vi} from 'vitest'; import {cleanup, render, screen, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {rulesDeclaredFor} from '../__tests__/pressState'; + +/** + * The element's injected rules split by the touch gate: inside + * `@media (pointer: coarse)` (TOUCH_QUERY, utils/touchPresentation.ts), or + * not. + */ +function touchGatedRules(el: Element): {touch: string[]; other: string[]} { + const classes = el.className + .split(/\s+/) + .filter(Boolean) + .map(name => `.${name}`); + const out = {touch: [] as string[], other: [] as string[]}; + const visit = (rules: CSSRuleList, touch: boolean) => { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSStyleRule) { + if (classes.some(cls => rule.selectorText.startsWith(cls))) { + (touch ? out.touch : out.other).push( + `${rule.selectorText} {${rule.style.cssText}}`, + ); + } + continue; + } + const nested = (rule as CSSGroupingRule).cssRules; + if (nested == null) { + continue; + } + const condition = (rule as CSSConditionRule).conditionText ?? ''; + visit(nested, touch || condition.includes('pointer: coarse')); + } + }; + for (const sheet of Array.from(document.styleSheets)) { + visit(sheet.cssRules, false); + } + return out; +} import {List} from './List'; import {ListItem} from './ListItem'; import {ListMarkerScope} from './ListContext'; @@ -488,6 +523,73 @@ describe('List', () => { expect(hrs).toHaveLength(0); }); + it('hides a touch-pressed row’s divider and the one above it, with the press layer over its own, under a finger only', () => { + render( + + {}} /> + {}} /> + , + ); + const row = screen.getByText('Item 1').closest('li') as HTMLElement; + const {touch: rules, other} = touchGatedRules(row); + // Outside the touch gate (a mouse) no press arm touches the dividers. + expect( + other.some( + rule => + rule.slice(0, rule.indexOf('{')).includes('data-astryx-press') && + /border-(bottom|block-end)-color|(^|[;{\s])(bottom|inset-block-end):/.test( + rule, + ), + ), + ).toBe(false); + const selector = (rule: string) => rule.slice(0, rule.indexOf('{')); + const hides = (condition: string) => + rules.some( + rule => + selector(rule).includes(condition) && + !selector(rule).includes('::before') && + /border-(bottom|block-end)-color:\s*transparent/.test(rule), + ); + // Its own divider while it is pressed, and the divider above the row + // being pressed: the row before a pressed sibling. + expect(hides('[data-astryx-press="on"]')).toBe(true); + expect(hides(':has(+ [data-astryx-press="on"])')).toBe(true); + // Only while the press is on: the release shows both again at once. + expect(hides('[data-astryx-press="fading"]')).toBe(false); + // The pressed row's layer reaches over the hidden divider, except on the + // last row, which has none. + const layer = rules.filter( + rule => + selector(rule).includes('::before') && + selector(rule).includes('[data-astryx-press="on"]') && + /(bottom|inset-block-end):/.test(rule), + ); + expect(layer.some(rule => /calc\(\s*-1\s*\*\s*var\(/.test(rule))).toBe( + true, + ); + expect( + layer.some( + rule => + selector(rule).includes(':last-child') && + /(bottom|inset-block-end):\s*0(px)?[;}\s]/.test(rule), + ), + ).toBe(true); + }); + + it('leaves a list without dividers alone during a press', () => { + render( + + {}} /> + , + ); + const row = screen.getByText('Item 1').closest('li') as HTMLElement; + expect( + rulesDeclaredFor(row).some(rule => + rule.slice(0, rule.indexOf('{')).includes(':has(+ [data-astryx-press'), + ), + ).toBe(false); + }); + // =========================================================================== // Interactive items — onClick (invisible button pattern) // =========================================================================== diff --git a/packages/core/src/List/List.tsx b/packages/core/src/List/List.tsx index b2379d9104181..388baea3d89d1 100644 --- a/packages/core/src/List/List.tsx +++ b/packages/core/src/List/List.tsx @@ -58,7 +58,9 @@ export interface ListProps extends BaseProps< density?: ListDensity; /** - * Whether to show dividers between list items. + * Whether to show dividers between list items. Under a finger, a touch + * press hides the dividers on either side of the pressed row until the + * finger lifts, as iOS lists do. * @default false */ hasDividers?: boolean; diff --git a/packages/core/src/List/ListItem.tsx b/packages/core/src/List/ListItem.tsx index 249d20f960efe..2be5b7250bb52 100644 --- a/packages/core/src/List/ListItem.tsx +++ b/packages/core/src/List/ListItem.tsx @@ -5,7 +5,8 @@ /** * @file ListItem.tsx * @input Uses React, ReactNode, StyleXStyles, theme tokens, the List row edge - * treatment, CheckboxInput, and the marker or task checkbox a + * treatment, the press layer (dividers beside a touch-pressed row hide and + * the layer reaches over the row's own), CheckboxInput, and the marker or task checkbox a * ListMarkerScope sets (Markdown's nested lists and the task items of mixed * lists) * @output Exports ListItem component, ListItemProps type @@ -186,7 +187,26 @@ const styles = stylex.create({ withDivider: { borderBlockEndWidth: borderVars['--border-width'], borderBlockEndStyle: 'solid', - borderBlockEndColor: colorVars['--color-border'], + // Under a finger (a coarse primary pointer, TOUCH_QUERY in + // utils/touchPresentation.ts), a touch press hides this row's divider + // and the one above it, in the frame the press is believed, with no + // transition, as a highlighted UIKit cell hides its separators; both + // come back in the first frame of the release, while the press is still + // fading. A selection hold (the row kept selected through a navigation + // push) hides them the same way until its deselect begins, and again + // after a cancel. With a mouse the dividers stay drawn. + borderBlockEndColor: { + default: colorVars['--color-border'], + '@media (pointer: coarse)': { + default: null, + '[data-astryx-press="on"]': 'transparent', + '[data-astryx-press="held"]:not([data-astryx-deselecting])': + 'transparent', + ':has(+ [data-astryx-press="on"])': 'transparent', + ':has(+ [data-astryx-press="held"]:not([data-astryx-deselecting]))': + 'transparent', + }, + }, // A longhand, not the `borderBlockEnd` shorthand: StyleX's default // property-specificity mode drops border shorthands silently, so the // shorthand never reached the shipped CSS and the last item kept its @@ -194,6 +214,25 @@ const styles = stylex.create({ ':last-child': { borderBlockEndWidth: 0, }, + // While its divider is hidden, the press layer (the `cell` style of + // utils/interactionOverlay.stylex.ts) reaches over it, so the whole row + // is pressed. The last row has no divider to cover. + '::before': { + insetBlockEnd: { + default: null, + '@media (pointer: coarse)': { + default: null, + '[data-astryx-press="on"]': { + default: `calc(-1 * ${borderVars['--border-width']})`, + ':last-child': 0, + }, + '[data-astryx-press="held"]:not([data-astryx-deselecting])': { + default: `calc(-1 * ${borderVars['--border-width']})`, + ':last-child': 0, + }, + }, + }, + }, }, }); diff --git a/packages/core/src/List/__tests__/ListDividers.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListDividers.interaction.browser.spec.ts new file mode 100644 index 0000000000000..84668ab2029db --- /dev/null +++ b/packages/core/src/List/__tests__/ListDividers.interaction.browser.spec.ts @@ -0,0 +1,247 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListDividers.interaction.browser.spec.ts + * @input The built List story with dividers, real touch input (Chromium CDP / + * WebKit's input command) and the interaction harness + * @output Interaction proofs for a divided list under a touch press: the + * pressed row's divider and the one above it are hidden in the frame the + * press is believed, with the press layer reaching over the pressed row's + * own; both come back in the first frame of the release; the last row's + * layer never reaches past the list; a storyboard of rest, press and the + * release's first frame + * @position Interaction evidence for ListItem's divider styles together with + * the press layer (the `cell` style of utils/interactionOverlay.stylex.ts). + * The behaviour is the one touch model, under any finger: the cases open + * the story as WebKit on iOS (./touchStory.ts), one as Android, and a mouse + * case shows the dividers staying drawn + * + * UIKit's reference: a highlighted cell hides its own separator and the one + * above it, unanimated, in the frame the highlight appears; on an animated + * deselect both return in the first frame while the gray is still fading. + * The states are read from the engine's computed styles, which carry the + * real cascade (layers, specificity), and photographed in the storyboard. + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + holdAnimations, + releaseAnimations, + seekAnimations, + Storyboard, + tap, + touchDriver, + withStorybook, +} from '@astryxdesign/interaction-harness'; +import {openStory} from './touchStory'; + +const storybook = withStorybook(test); + +const STORY = 'core-list--with-media'; +const HOLD_MS = 400; +/** Past the press onset (150 ms), well before the lift. */ +const PRESSED_AT_MS = 260; + +const rows = (page: Page) => page.locator('li[data-astryx-pressable]'); + +interface RowState { + readonly press: string | null; + /** The divider at the row's bottom edge, as painted. */ + readonly divider: string; + readonly dividerWidth: string; + /** Where the press layer's bottom edge sits, relative to the row's padding box. */ + readonly layerBottom: string; + readonly layerOpacity: string; +} + +async function rowStates(page: Page): Promise { + return rows(page).evaluateAll(elements => + elements.map(el => { + const style = getComputedStyle(el); + const layer = getComputedStyle(el, '::before'); + return { + press: el.getAttribute('data-astryx-press'), + divider: style.borderBottomColor, + dividerWidth: style.borderBottomWidth, + layerBottom: layer.bottom, + layerOpacity: layer.opacity, + }; + }), + ); +} + +const TRANSPARENT = 'rgba(0, 0, 0, 0)'; + +async function center(row: Locator) { + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +/** + * Holds row `index` with a finger and returns the rows' states while the + * press is on and in the release's first frame (its animations held at 0). + */ +async function pressRow(page: Page, index: number) { + const driver = await touchDriver(page); + const at = await center(rows(page).nth(index)); + let pressed: RowState[] = []; + let released: RowState[] = []; + await driver.play(tap(at, {holdMs: HOLD_MS}), { + onEvent: async (_, event) => { + if (event === 0) { + await page.waitForTimeout(PRESSED_AT_MS); + pressed = await rowStates(page); + } else { + await holdAnimations(page); + await seekAnimations(page, 0); + released = await rowStates(page); + } + }, + }); + await releaseAnimations(page); + return {pressed, released}; +} + +test('Android: a touch press hides the same dividers (one touch model)', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'Android is Chromium'); + await openStory(page, storybook(), STORY); + await expect(rows(page)).toHaveCount(3); + const rest = await rowStates(page); + const {pressed, released} = await pressRow(page, 1); + expect(pressed.map(row => row.press)).toEqual([null, 'on', null]); + expect(pressed[0].divider).toBe(TRANSPARENT); + expect(pressed[1].divider).toBe(TRANSPARENT); + expect(released[0].divider).toBe(rest[0].divider); + expect(released[1].divider).toBe(rest[1].divider); +}); + +test('a mouse press leaves the dividers drawn: touch work costs nothing under a fine pointer', async ({ + browser, +}) => { + const context = await browser.newContext({ + hasTouch: false, + isMobile: false, + viewport: {width: 900, height: 700}, + }); + const page = await context.newPage(); + try { + await openStory(page, storybook(), STORY); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await expect(rows(page)).toHaveCount(3); + const rest = await rowStates(page); + const box = await rows(page).nth(1).boundingBox(); + if (box == null) { + throw new Error('no row'); + } + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + const pressed = await rowStates(page); + await page.mouse.up(); + expect(pressed.map(row => row.divider)).toEqual( + rest.map(row => row.divider), + ); + } finally { + await context.close(); + } +}); + +test.describe('a divided list under a touch press', () => { + test.beforeEach(async ({page}) => { + await openStory(page, storybook(), STORY); + await expect(rows(page)).toHaveCount(3); + }); + + test('hides the pressed row’s divider and the one above it; the release shows both at once', async ({ + page, + }) => { + const rest = await rowStates(page); + // At rest: a divider under every row but the last. + expect(rest.map(row => row.divider === TRANSPARENT)).toEqual([ + false, + false, + false, + ]); + expect(rest.map(row => row.dividerWidth)).toEqual(['1px', '1px', '0px']); + const resting = rest[0].divider; + + const {pressed, released} = await pressRow(page, 1); + expect(pressed.map(row => row.press)).toEqual([null, 'on', null]); + // The divider above the pressed row (the first row's) and its own. + expect(pressed[0].divider).toBe(TRANSPARENT); + expect(pressed[1].divider).toBe(TRANSPARENT); + // The layer covers the pressed row down through its hidden divider. + expect(pressed[1].layerOpacity).toBe('1'); + expect(pressed[1].layerBottom).toBe(`-${rest[1].dividerWidth}`); + // Rows that are not pressed have no layer at all. + expect(pressed[0].layerBottom).toBe('auto'); + + // The first frame of the release: both dividers are back while the + // press is still at full strength, and the layer is back inside the row. + expect(released[1].press).toBe('fading'); + expect(released[1].layerOpacity).toBe('1'); + expect(released[0].divider).toBe(resting); + expect(released[1].divider).toBe(resting); + expect(released[1].layerBottom).toBe('0px'); + }); + + test('the last row hides the divider above it and keeps its layer inside the list', async ({ + page, + }) => { + const rest = await rowStates(page); + const {pressed} = await pressRow(page, 2); + expect(pressed[2].press).toBe('on'); + // The second row's divider is the one above the pressed last row. + expect(pressed[1].divider).toBe(TRANSPARENT); + // The divider above that stays. + expect(pressed[0].divider).toBe(rest[0].divider); + // The last row has no divider, so its layer does not reach below it. + expect(pressed[2].layerBottom).toBe('0px'); + }); + + test('storyboard: rest, press, the release’s first frame @storyboard', async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== 'chromium', + 'One engine is enough for the record', + ); + // The list the rows live in. + const list = rows(page).first().locator('xpath=..'); + const storyboard = new Storyboard(page, { + name: 'list-dividers-press', + clip: list, + columns: 3, + // Full size: the dividers are hairlines. + cellScale: 1, + }); + await storyboard.capture('rest', 0, {rows: await rowStates(page)}); + const driver = await touchDriver(page); + const at = await center(rows(page).nth(1)); + await driver.play(tap(at, {holdMs: HOLD_MS}), { + onEvent: async (_, event) => { + if (event === 0) { + await page.waitForTimeout(PRESSED_AT_MS); + await storyboard.capture('pressed', PRESSED_AT_MS, { + rows: await rowStates(page), + }); + } else { + await holdAnimations(page); + await seekAnimations(page, 0); + await storyboard.capture('release, first frame', HOLD_MS, { + rows: await rowStates(page), + }); + } + }, + }); + await releaseAnimations(page); + await storyboard.write(testInfo); + }); +}); diff --git a/packages/core/src/List/__tests__/ListDividersPaths.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListDividersPaths.interaction.browser.spec.ts new file mode 100644 index 0000000000000..9389101a9933c --- /dev/null +++ b/packages/core/src/List/__tests__/ListDividersPaths.interaction.browser.spec.ts @@ -0,0 +1,174 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListDividersPaths.interaction.browser.spec.ts + * @input The built List "With Media" story (dividers), the interaction + * harness, Storybook's colour-mode and direction globals + * @output The divider paths beside the main press proofs: a press the + * browser takes for a scroll brings both dividers back at once; a mouse + * press on a fine pointer leaves them drawn; dark mode and right-to-left + * hide and restore them the same way + * @position Interaction evidence for List/ListItem.tsx's divider arms, beside + * ListDividers.interaction.browser.spec.ts; under a finger + * (./touchStory.ts), where the arms apply + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + drag, + holdAnimations, + releaseAnimations, + seekAnimations, + tap, + touchDriver, + withStorybook, +} from '@astryxdesign/interaction-harness'; +import {openStory} from './touchStory'; + +const storybook = withStorybook(test); + +const STORY = 'core-list--with-media'; +const TRANSPARENT = 'rgba(0, 0, 0, 0)'; + +const rows = (page: Page) => page.locator('li[data-astryx-pressable]'); + +const dividers = async (page: Page) => + rows(page).evaluateAll(elements => + elements.map(el => getComputedStyle(el).borderBottomColor), + ); + +async function open(page: Page, globals?: string) { + await openStory(page, storybook(), STORY, {globals}); + await expect(rows(page)).toHaveCount(3); +} + +async function center(row: Locator) { + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +async function touchOnly(page: Page) { + const touch = await page.evaluate( + () => matchMedia('(pointer: coarse)').matches, + ); + test.skip(!touch, 'the divider arms serve a touch screen'); +} + +/** Holds the middle row; returns the dividers while pressed and at the release's start. */ +async function pressMiddle(page: Page) { + const driver = await touchDriver(page); + const at = await center(rows(page).nth(1)); + let pressed: string[] = []; + let released: string[] = []; + await driver.play(tap(at, {holdMs: 400}), { + onEvent: async (_, event) => { + if (event === 0) { + await page.waitForTimeout(260); + pressed = await dividers(page); + } else { + await holdAnimations(page); + await seekAnimations(page, 0); + released = await dividers(page); + } + }, + }); + await releaseAnimations(page); + return {pressed, released}; +} + +test('a press the browser takes for a scroll brings both dividers back at once', async ({ + page, +}) => { + await open(page); + await touchOnly(page); + // A page tall enough to scroll, so a vertical pan is the browser's. + await page.evaluate(() => { + document.body.style.minHeight = '300vh'; + }); + const rest = await dividers(page); + const driver = await touchDriver(page); + const from = await center(rows(page).nth(1)); + let pressed: string[] = []; + await driver.play( + drag(from, {x: from.x, y: from.y - 80}, {holdMs: 300, durationMs: 160}), + { + onEvent: async (_, event) => { + if (event === 0) { + await page.waitForTimeout(260); + pressed = await dividers(page); + } + }, + }, + ); + expect(pressed[0]).toBe(TRANSPARENT); + expect(pressed[1]).toBe(TRANSPARENT); + await expect + .poll(async () => rows(page).nth(1).getAttribute('data-astryx-press')) + .toBeNull(); + expect(await dividers(page)).toEqual(rest); +}); + +test('a mouse press on a fine pointer leaves the dividers drawn', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 1024, height: 768}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + try { + await open(page); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const rest = await dividers(page); + expect(rest[0]).not.toBe(TRANSPARENT); + const at = await center(rows(page).nth(1)); + await page.mouse.move(at.x, at.y); + await page.mouse.down(); + expect(await dividers(page)).toEqual(rest); + await page.mouse.up(); + expect(await dividers(page)).toEqual(rest); + } finally { + await context.close(); + } +}); + +for (const {name, globals} of [ + {name: 'dark mode', globals: 'colorMode:dark'}, + {name: 'right to left', globals: 'direction:rtl'}, +]) { + test(`${name}: a press hides the same two dividers and the release restores them`, async ({ + page, + }) => { + await open(page, globals); + await touchOnly(page); + if (name === 'right to left') { + expect( + await rows(page) + .first() + .evaluate(el => getComputedStyle(el).direction), + ).toBe('rtl'); + } + const rest = await dividers(page); + expect(rest[0]).not.toBe(TRANSPARENT); + const {pressed, released} = await pressMiddle(page); + expect(pressed[0]).toBe(TRANSPARENT); + expect(pressed[1]).toBe(TRANSPARENT); + expect(released[0]).toBe(rest[0]); + expect(released[1]).toBe(rest[1]); + }); +} + +test('dark mode keeps its own divider colour at rest', async ({page}) => { + await open(page); + const light = await dividers(page); + await open(page, 'colorMode:dark'); + const dark = await dividers(page); + expect(dark[0]).not.toBe(TRANSPARENT); + expect(dark[0]).not.toBe(light[0]); +}); diff --git a/packages/core/src/List/__tests__/ListRowPress.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListRowPress.interaction.browser.spec.ts new file mode 100644 index 0000000000000..5a0cf3a552805 --- /dev/null +++ b/packages/core/src/List/__tests__/ListRowPress.interaction.browser.spec.ts @@ -0,0 +1,528 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListRowPress.interaction.browser.spec.ts + * @input The built List, TreeList and press feedback stories, real touch input + * (Chromium CDP / WebKit's input command), the interaction harness, and the + * cell release clock and curve from the UIKit constants table + * (motion/uikit/list.ts) + * @output Interaction and animation proofs for a list row's touch press + * release: a held row paints the pressed token at full strength, and the + * release follows the cell release curve sampled frame by frame (both + * engines); the release keeps fading on every frame while the main thread + * is blocked (Chromium screencast, WebKit video); the release does no + * Layout or Paint; storyboards of the release and of the blocked window + * @position Interaction evidence for the row press layer (utils/interactionOverlay.stylex.ts `cell`) as composed + * by Item (List rows) and TreeList rows + * + * The press strength is read from pixels: the row's own background before + * the press is strength 0, the frame the press is believed is strength 1, and + * every other frame is placed between the two. The pressed overlay is a + * translucent token, so the sampled channel moves linearly with the layer's + * opacity. + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + blockMainThread, + castStoryboard, + compareCurve, + countBetween, + cubicBezier, + freezeTimers, + holdAnimations, + installLongTaskLog, + installTimerGate, + openStory, + readLongTasks, + releaseAnimations, + seekAnimations, + startScreencast, + tap, + thawTimers, + timeline, + touchDriver, + traceRendering, + waitForAnimation, + withStorybook, + writeEvidence, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {list} from '../../motion/uikit/list'; +import { + blockBehindBeaconOn, + installBeacon, + samplePixel, + sampleVideo, +} from './rowPixels'; + +const storybook = withStorybook(test); + +const RELEASE_MS = list.highlight.releaseMs; +const releaseEase = cubicBezier(...list.highlight.releaseCurve); +/** The layer's opacity t ms into the release: the strength, 1 → 0. */ +const expectedStrength = (t: number) => + 1 - releaseEase(Math.min(1, Math.max(0, t / RELEASE_MS))); + +/** Long enough that the press is believed (scroll.delaysContentTouchesMs). */ +const HOLD_MS = 400; + +interface RowStory { + readonly name: string; + readonly story: string; + /** The pressable row under test. */ + readonly row: (page: Page) => Locator; +} + +const LIST_ROW: RowStory = { + name: 'List row', + story: 'core-list--interactive', + // "Sent": not the selected row, so its resting background is the page's. + row: page => + page.locator('[data-astryx-pressable]').filter({hasText: 'Sent'}).first(), +}; + +const SCROLLER_ROW: RowStory = { + name: 'Item row in a scroller', + story: 'core-press-feedback-touch--rows', + row: page => + page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Conversation 2'}) + .first(), +}; + +const TREE_ROW: RowStory = { + name: 'TreeList row', + story: 'core-treelist--basic', + row: page => + page + .locator('[data-astryx-pressable]') + .filter({hasText: 'App.tsx'}) + .first(), +}; + +interface OpenRow { + driver: TouchDriver; + row: Locator; + /** + * A point inside the row's trailing padding, clear of its text, icons and + * (in a tree) the guide lines, CSS px. + */ + probe: {x: number; y: number}; + center: {x: number; y: number}; +} + +async function openRow(page: Page, story: RowStory): Promise { + await installLongTaskLog(page); + await openStory(page, storybook(), story.story); + const driver = await touchDriver(page); + const row = story.row(page); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error(`${story.name}: no row`); + } + return { + driver, + row, + probe: { + x: Math.round(box.x + box.width - 3), + y: Math.round(box.y + box.height / 2), + }, + center: {x: box.x + box.width / 2, y: box.y + box.height / 2}, + }; +} + +/** Opacity of the row's press layer (the `::before`), as the engine presents it. */ +/** The press layer's strength: 0 while the row has no layer box (at rest). */ +const layerOpacity = async (row: Locator) => + row.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return before.content === 'none' || before.content === 'normal' + ? 0 + : Number(before.opacity); + }); + +/** The press attribute the controller writes on the row. */ +const pressState = async (row: Locator) => + row.evaluate(el => el.getAttribute('data-astryx-press')); + +test('a held row paints the pressed token and its release follows the cell release curve, sampled frame by frame', async ({ + page, +}, testInfo) => { + const {driver, row, center} = await openRow(page, LIST_ROW); + expect(await layerOpacity(row)).toBe(0); + // Gate the page's timers before the press, so the controller's own release + // timer (which removes the attribute after the clock) can be held while the + // release is seeked. + await installTimerGate(page); + await driver.play(tap(center, {holdMs: HOLD_MS})); + expect(await pressState(row)).toBe('fading'); + await freezeTimers(page); + const held = await holdAnimations(page, row); + const release = held.find( + a => a.target?.startsWith('li') && a.kind === 'CSSAnimation', + ); + expect(release, JSON.stringify(held)).toBeDefined(); + expect(release?.durationMs).toBe(RELEASE_MS); + const samples: {t: number; value: number}[] = []; + for (const t of timeline(RELEASE_MS)) { + await seekAnimations(page, t); + samples.push({t, value: await layerOpacity(row)}); + } + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + const comparison = compareCurve(samples, expectedStrength, 0.005); + await writeEvidence( + 'curves/list-row-press-release', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + releaseMs: RELEASE_MS, + curve: list.highlight.releaseCurve, + held, + samples, + comparison, + }, + null, + 2, + ), + }, + testInfo, + ); + expect(samples[0].value).toBeCloseTo(1, 2); + expect( + comparison.maxError, + JSON.stringify(comparison.worst), + ).toBeLessThanOrEqual(0.005); + // At rest after the release: no press, no layer. + await expect.poll(async () => pressState(row)).toBeNull(); + expect(await layerOpacity(row)).toBe(0); +}); + +for (const story of [LIST_ROW, SCROLLER_ROW, TREE_ROW]) { + test(`${story.name}: the release keeps fading on every frame while the main thread is blocked`, async ({ + page, + browserName, + }, testInfo) => { + test.skip( + browserName !== 'chromium', + 'screencast frame evidence is Chromium-only in Playwright; WebKit has its own video proof below', + ); + const {driver, row, center, probe} = await openRow(page, story); + const cast = await startScreencast(page); + // Strength 0 is the row at rest; strength 1 the believed press. + await driver.play(tap(center, {holdMs: HOLD_MS})); + const [release] = await waitForAnimation(row, {minProgressMs: 16}); + const block = await blockMainThread(page, 700); + await page.waitForTimeout(150); + const frames = await cast.stop(); + const pixels = await samplePixel(page, frames, probe); + const rest = pixels[0].value; + const pressedFrames = pixels.filter(p => p.t < release.from); + const pressed = Math.min(...pressedFrames.map(p => p.value)); + const strength = (v: number) => (rest - v) / (rest - pressed); + // Judge the release while it was still visibly moving inside the block. + const end = Math.min(block.to, release.to) - 10; + const during = pixels.filter(p => p.t > block.from && p.t <= end); + const strengths = during.map(p => ({ + t: +(p.t - block.from).toFixed(1), + strength: +strength(p.value).toFixed(3), + })); + const intervals = during + .map((p, i) => (i === 0 ? p.t - block.from : p.t - during[i - 1].t)) + .filter(dt => dt > 0); + const longestGap = Math.max( + end - (during.at(-1)?.t ?? block.from), + ...intervals, + ); + await writeEvidence( + `busy-main-thread/${story.story}`, + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {block, release, rest, pressed, strengths}, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, frames, { + name: `${story.story}-release-busy-main-thread`, + zero: block.from, + from: release.from - 40, + to: release.to + 40, + columns: 6, + cellScale: 0.45, + data: t => { + const p = pixels.find(f => f.t === t); + return {strength: p == null ? -1 : +strength(p.value).toFixed(3)}; + }, + }); + await film.write(testInfo, {block, release}); + // The press painted at full strength before the lift. + expect(rest - pressed).toBeGreaterThan(10); + // Frames kept coming through the block, each one further along the fade. + expect(during.length, JSON.stringify(strengths)).toBeGreaterThanOrEqual( + Math.floor((end - block.from) / (1000 / 60)) - 2, + ); + expect(longestGap).toBeLessThan(40); + for (let i = 1; i < strengths.length; i++) { + expect(strengths[i].strength).toBeLessThanOrEqual( + strengths[i - 1].strength + 0.01, + ); + } + const last = strengths.at(-1); + expect(last).toBeDefined(); + expect(strengths[0].strength).toBeGreaterThan((last?.strength ?? 1) + 0.2); + }); +} + +test('a busy first frame after the lift delays the release instead of skipping into it', async ({ + page, +}) => { + const {driver, row, center} = await openRow(page, LIST_ROW); + // When the press controller writes the release, 150 ms of script hold the + // main thread before the first frame. The release starts when the browser + // styles it, so the first frame due after the block shows it at most one + // frame in. + await row.evaluate(el => { + (window as unknown as {__first: Promise}).__first = new Promise( + resolve => { + const observer = new MutationObserver(() => { + if (el.getAttribute('data-astryx-press') !== 'fading') { + return; + } + observer.disconnect(); + const until = performance.now() + 150; + while (performance.now() < until) { + // Busy: the main thread is held. + } + // The first frame due after the block: a frame scheduled before + // it runs with that earlier time. + const read = () => { + if ((document.timeline.currentTime as number) < until) { + requestAnimationFrame(read); + return; + } + resolve(Number(getComputedStyle(el, '::before').opacity)); + }; + requestAnimationFrame(read); + }); + observer.observe(el, {attributeFilter: ['data-astryx-press']}); + }, + ); + }); + await driver.play(tap(center, {holdMs: 300})); + const first = await page.evaluate( + async () => (window as unknown as {__first: Promise}).__first, + ); + // At most one frame into the release: its ease-out curve takes the first + // frame to about 0.74; a start pinned before the block would be past 0.4. + expect(first).toBeGreaterThan(0.7); + await expect.poll(async () => layerOpacity(row)).toBe(0); +}); + +test('a busy first frame after the lift still plays the whole release before the press attribute goes', async ({ + page, +}) => { + const {driver, row, center} = await openRow(page, LIST_ROW); + // When the press controller writes the release, 150 ms of script hold the + // main thread before the first frame, so the release starts after the + // block. Every frame after it is read until the attribute goes: the layer + // must reach rest on the curve, not vanish while it is still painted. + await row.evaluate(el => { + const frames: [number, number, string | null][] = []; + (window as unknown as {__frames: typeof frames}).__frames = frames; + const observer = new MutationObserver(() => { + if (el.getAttribute('data-astryx-press') !== 'fading') { + return; + } + observer.disconnect(); + const until = performance.now() + 150; + while (performance.now() < until) { + // Busy: the main thread is held. + } + const sample = (time: number) => { + const layer = getComputedStyle(el, '::before'); + const strength = + layer.content === 'none' || layer.content === 'normal' + ? 0 + : Number(layer.opacity); + const press = el.getAttribute('data-astryx-press'); + if ((document.timeline.currentTime as number) >= until) { + frames.push([+(time - until).toFixed(1), strength, press]); + } + if (press === 'fading' && frames.length < 120) { + requestAnimationFrame(sample); + } + }; + requestAnimationFrame(sample); + }); + observer.observe(el, {attributeFilter: ['data-astryx-press']}); + }); + await driver.play(tap(center, {holdMs: 300})); + await expect.poll(async () => pressState(row)).toBeNull(); + const frames = await page.evaluate( + () => + (window as unknown as {__frames: [number, number, string | null][]}) + .__frames, + ); + const fading = frames.filter(([, , press]) => press === 'fading'); + expect(fading.length, JSON.stringify(frames)).toBeGreaterThan(3); + // The last frame still fading is at rest or within one frame of it: the + // attribute goes once the release has played, however late it started. + expect(fading.at(-1)?.[1], JSON.stringify(frames)).toBeLessThanOrEqual(0.05); + // And the release played on the way there, frame by frame. + for (let i = 1; i < fading.length; i++) { + expect(fading[i][1], JSON.stringify(frames)).toBeLessThanOrEqual( + fading[i - 1][1] + 0.01, + ); + } +}); + +test('the release does no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {driver, row, center} = await openRow(page, LIST_ROW); + let release = {from: 0, to: 0}; + const started = Date.now(); + const trace = await traceRendering(page, async () => { + await driver.play(tap(center, {holdMs: HOLD_MS})); + [release] = await waitForAnimation(row); + await page.waitForTimeout(RELEASE_MS + 100); + }); + // The steady part of the release: its first and last frames may commit. + const during = countBetween(trace, release.from + 30, release.to - 30); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); + expect(await readLongTasks(page, {from: started, to: Date.now()})).toEqual( + [], + ); +}); + +test('WebKit: the release keeps fading while the main thread is blocked (video)', async ({ + browser, + browserName, +}, testInfo) => { + test.skip( + browserName !== 'webkit', + 'the Chromium screencast test covers Chromium', + ); + const size = {width: 402, height: 714}; + const context = await browser.newContext({ + viewport: size, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + recordVideo: {dir: testInfo.outputPath('video'), size}, + }); + const page = await context.newPage(); + const {driver, center, probe} = await openRow(page, LIST_ROW); + await installBeacon(page); + await blockBehindBeaconOn(page, 'touchend', 700); + await page.waitForTimeout(300); + await driver.play(tap(center, {holdMs: HOLD_MS})); + await page.waitForTimeout(1300); + const video = page.video(); + await context.close(); + const path = await video?.path(); + expect(path).toBeTruthy(); + const samples = await sampleVideo(path as string, probe, size); + const blocked = samples.filter(s => s.beacon === 'blocked'); + const rest = Math.max( + ...samples.filter(s => s.beacon === 'before').map(s => s.value), + ); + // Judge the release up to the frame it reaches the row's resting color. + // (Measured in the Linux WebKit build: once an accelerated animation has + // finished while script is still blocked, the compositor shows the value + // the main thread last committed until script runs again. That is this + // build's end-of-animation hand-back, after the release; iOS Safari 26.2 + // holds the resting color on the simulator. It is recorded below, not + // judged here.) + const restIndex = blocked.findIndex(s => s.value >= rest - 1); + const fading = restIndex === -1 ? blocked : blocked.slice(0, restIndex + 1); + const distinct = fading + .map(s => Math.round(s.value)) + .filter((v, i, all) => i === 0 || v !== all[i - 1]); + const afterEnd = restIndex === -1 ? [] : blocked.slice(restIndex + 1); + await writeEvidence( + 'busy-main-thread/list-row-release-webkit', + { + 'webkit.json': JSON.stringify( + {rest, blocked, distinct, afterEnd}, + null, + 1, + ), + }, + testInfo, + ); + testInfo.annotations.push({ + type: 'WebKit frames during the block', + description: `release: ${distinct.join(' ')}; after it ended, still blocked: ${[ + ...new Set(afterEnd.map(s => Math.round(s.value))), + ].join(' ')}`, + }); + expect(blocked.length).toBeGreaterThan(0); + // The row kept getting lighter while script could not run, all the way + // to its resting color: from a frame still short of rest, it reached rest + // inside the blocked window. A release on the main thread would hold its + // first frame's value until the block ends, 700 ms on, and could not. (The + // recording's frames are 40 ms apart, so how many land inside the 250 ms + // release depends on its phase: the count is not judged.) + const values = JSON.stringify(blocked.map(s => Math.round(s.value))); + expect(blocked[0].value, values).toBeLessThan(rest - 1); + expect(restIndex, values).toBeGreaterThan(0); + for (let i = 1; i < distinct.length; i++) { + expect(distinct[i], values).toBeGreaterThan(distinct[i - 1]); + } +}); + +test( + 'storyboard: a row released after a hold', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, row, center} = await openRow(page, LIST_ROW); + await installTimerGate(page); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const clip = { + x: 0, + y: Math.max(0, box.y - 16), + width: page.viewportSize()?.width ?? 402, + height: box.height + 32, + }; + await driver.play(tap(center, {holdMs: HOLD_MS})); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'list-row-press-release', + clip, + scope: row, + measure: async () => ({ + opacity: await layerOpacity(row), + state: await pressState(row), + }), + columns: 7, + cellScale: 0.6, + }); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + await storyboard.write(testInfo); + // The trace is the assertion; the sheet is the evidence. + const opacities = storyboard.frames.map(f => f.data.opacity as number); + expect(opacities[0]).toBeCloseTo(1, 2); + expect(opacities.at(-1)).toBeLessThan(0.01); + for (let i = 1; i < opacities.length; i++) { + expect(opacities[i]).toBeLessThanOrEqual(opacities[i - 1] + 1e-6); + } + storyboard.frames.forEach(f => + expect(f.data.opacity as number).toBeCloseTo(expectedStrength(f.t), 2), + ); + }, +); diff --git a/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts new file mode 100644 index 0000000000000..7e5f9ad6b62e8 --- /dev/null +++ b/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts @@ -0,0 +1,360 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListRowPressPaths.interaction.browser.spec.ts + * @input The built List "Interactive" and Press Feedback "Rows" stories, the + * interaction harness, and the cell release clock (motion/uikit/list.ts) + * @output The row press paths beside the main release proofs: a press the + * browser takes for a scroll drops the highlight at once (the press + * controller's cancel, unchanged by the compositor layer); Reduce + * Motion keeps the fade (a fade, not a movement); a mouse press and hover on + * a fine pointer leave the press layer out of it + * @position Interaction evidence for the row press layer (utils/interactionOverlay.stylex.ts `cell`), beside + * ListRowPress.interaction.browser.spec.ts + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + drag, + holdAnimations, + openStory, + releaseAnimations, + seekAnimations, + tap, + touchDriver, + withStorybook, +} from '@astryxdesign/interaction-harness'; +import {list} from '../../motion/uikit/list'; + +const storybook = withStorybook(test); + +const RELEASE_MS: number = list.highlight.releaseMs; + +/** The press layer's strength: 0 while the row has no layer box (at rest). */ +const layerOpacity = async (row: Locator) => + row.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return before.content === 'none' || before.content === 'normal' + ? 0 + : Number(before.opacity); + }); + +const pressState = async (row: Locator) => + row.evaluate(el => el.getAttribute('data-astryx-press')); + +async function touchOnly(page: Page) { + const touch = await page.evaluate( + () => matchMedia('(pointer: coarse)').matches, + ); + test.skip(!touch, 'the press layer serves a touch screen'); +} + +test("a press the browser takes for a scroll drops the highlight at once (the controller's cancel, unchanged)", async ({ + page, +}) => { + await openStory(page, storybook(), 'core-press-feedback-touch--rows'); + await touchOnly(page); + const driver = await touchDriver(page); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Conversation 2'}) + .first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + // Every press state the controller writes, and every animation the row's + // layer starts, with page times. + await row.evaluate(el => { + const log: string[] = []; + (window as unknown as {__pressLog: string[]}).__pressLog = log; + new MutationObserver(() => + log.push(`press:${el.getAttribute('data-astryx-press')}`), + ).observe(el, {attributeFilter: ['data-astryx-press']}); + el.addEventListener('animationstart', event => + log.push(`start:${(event as AnimationEvent).pseudoElement}`), + ); + }); + const from = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + // Held long enough for the highlight to show, then a vertical pan the + // scroller takes: the browser cancels the touch for the page. + await driver.play( + drag(from, {x: from.x, y: from.y - 80}, {holdMs: 300, durationMs: 160}), + ); + await expect.poll(async () => pressState(row)).toBeNull(); + const log = await page.evaluate( + () => (window as unknown as {__pressLog: string[]}).__pressLog, + ); + // Highlighted while held, then the scroll's cancel removes it outright: + // no release fade starts, and the row is at rest. + expect(log, JSON.stringify(log)).toContain('press:on'); + expect(log.at(-1), JSON.stringify(log)).toBe('press:null'); + expect(log, JSON.stringify(log)).not.toContain('press:fading'); + expect(log, JSON.stringify(log)).not.toContain('start:::before'); + expect(await layerOpacity(row)).toBe(0); +}); + +test('Reduce Motion keeps the release fade: a fade, not a movement', async ({ + page, +}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await openStory(page, storybook(), 'core-list--interactive'); + await touchOnly(page); + const driver = await touchDriver(page); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + await driver.play( + tap({x: box.x + box.width / 2, y: box.y + box.height / 2}, {holdMs: 300}), + ); + await holdAnimations(page); + await seekAnimations(page, 0); + expect(await layerOpacity(row)).toBeCloseTo(1, 2); + await seekAnimations(page, RELEASE_MS / 2); + const midway = await layerOpacity(row); + expect(midway).toBeGreaterThan(0.05); + expect(midway).toBeLessThan(0.95); + await releaseAnimations(page, {finish: true}); + await expect.poll(async () => layerOpacity(row)).toBe(0); +}); + +test('a mouse press and hover on a fine pointer leave the press layer out of it', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 1024, height: 768}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + try { + await openStory(page, storybook(), 'core-list--interactive'); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const fine = await page.evaluate( + () => matchMedia('(pointer: fine)').matches, + ); + expect(fine).toBe(true); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + // No layer at all with a fine pointer: hovering, pressing and lifting + // never give the row a `::before` box. + const layer = async () => + row.evaluate(el => getComputedStyle(el, '::before').content); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + expect(await layer()).toBe('none'); + await page.mouse.down(); + expect(await pressState(row)).toBeNull(); + expect(await layer()).toBe('none'); + await page.mouse.up(); + expect(await pressState(row)).toBeNull(); + expect(await layer()).toBe('none'); + } finally { + await context.close(); + } +}); + +test('a fine pointer: a row is as it was, with no press layer and no stacking context (desktop unchanged)', async ({ + browser, +}) => { + // AST-017 FR59/DEC-25: touch work leaves a fine pointer's rows as they were. The + // press layer and the stacking context it needs exist under a finger only, + // so a caller's own `::before` and z-index behave as before. + const context = await browser.newContext({ + viewport: {width: 1024, height: 768}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + try { + const rowBox = async (story: string, selector: string, label: string) => { + await openStory(page, storybook(), story); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const row = page.locator(selector).filter({hasText: label}).first(); + await expect(row).toBeVisible(); + return row.evaluate(el => ({ + before: getComputedStyle(el, '::before').content, + isolation: getComputedStyle(el).isolation, + })); + }; + expect( + await rowBox('core-list--interactive', '[data-astryx-pressable]', 'Sent'), + ).toEqual({before: 'none', isolation: 'auto'}); + // A row's own `::before` is exactly as its author wrote it: nothing of + // the layer (its opacity, stacking, hit testing) reaches it. + const own = await page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first() + .evaluate(el => { + const style = document.createElement('style'); + style.textContent = + '[data-test-own]::before{content:"•";position:static;opacity:1}'; + document.head.append(style); + el.setAttribute('data-test-own', ''); + const before = getComputedStyle(el, '::before'); + return { + content: before.content, + opacity: before.opacity, + position: before.position, + zIndex: before.zIndex, + pointerEvents: before.pointerEvents, + }; + }); + expect(own).toEqual({ + content: '"•"', + opacity: '1', + position: 'static', + zIndex: 'auto', + pointerEvents: 'auto', + }); + expect( + await rowBox( + 'core-treelist--interactive', + '#storybook-root [role="treeitem"] [data-astryx-pressable]', + 'Settings', + ), + ).toEqual({before: 'none', isolation: 'auto'}); + } finally { + await context.close(); + } +}); + +test('a radio row under a finger paints its circle’s press, and keeps its bare look: no row layer', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'the held touch is driven with Chromium touch events (CDP)', + ); + await openStory(page, storybook(), 'core-radiolist--default'); + await touchOnly(page); + const row = page.locator('#storybook-root [data-astryx-pressable]').first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + // On the label, away from the circle: the row takes the press. + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{x: box.x + box.width * 0.6, y: box.y + box.height / 2}], + }); + await expect.poll(async () => pressState(row)).toBe('on'); + const look = await row.evaluate(el => { + const wrapper = [...el.querySelectorAll('*')].find( + child => + child.querySelector(':scope > input[type="radio"]') != null && + getComputedStyle(child, '::after').content !== 'none', + ); + return { + circle: + wrapper == null + ? null + : getComputedStyle(wrapper, '::after').backgroundImage, + layer: getComputedStyle(el, '::before').content, + }; + }); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchCancel', + touchPoints: [], + }); + // The circle's own press paint: the pressed token, not a transparent + // gradient. + expect(look.circle).toMatch(/gradient/); + expect(look.circle).not.toMatch( + /^linear-gradient\(rgba\(0, 0, 0, 0\), rgba\(0, 0, 0, 0\)\)$/, + ); + // Only the circle paints, as with a mouse: the row has no layer. + expect(look.layer).toBe('none'); +}); + +test('under a finger, a row at rest is as it was: its own ::before keeps its look and it is no stacking context; the press layer takes the ::before only while pressed', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'the held touch is driven with Chromium touch events (CDP)', + ); + await openStory(page, storybook(), 'core-list--interactive'); + await touchOnly(page); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first(); + await expect(row).toBeVisible(); + const look = async () => + row.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return { + content: before.content, + opacity: before.opacity, + position: before.position, + zIndex: before.zIndex, + pointerEvents: before.pointerEvents, + isolation: getComputedStyle(el).isolation, + }; + }); + // A caller's own decoration on the row's `::before` (a page rule beats + // the components' layered styles for what it sets; the rest is theirs). + await row.evaluate(el => { + const style = document.createElement('style'); + style.textContent = '[data-test-own]::before{content:"•"}'; + document.head.append(style); + el.setAttribute('data-test-own', ''); + }); + const own = { + content: '"•"', + opacity: '1', + position: 'static', + zIndex: 'auto', + pointerEvents: 'auto', + isolation: 'auto', + }; + expect(await look()).toEqual(own); + // A press: the layer takes the `::before`, in its own stacking context. + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{x: box.x + box.width / 2, y: box.y + box.height / 2}], + }); + await expect.poll(async () => pressState(row)).toBe('on'); + // The layer's box (with the caller's content), painted, in its own + // stacking context. + const pressed = await look(); + expect(pressed.content).toBe('"•"'); + expect(pressed.position).toBe('absolute'); + expect(pressed.zIndex).toBe('-1'); + expect(pressed.opacity).toBe('1'); + expect(pressed.isolation).toBe('isolate'); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchCancel', + touchPoints: [], + }); + // The press over, the row's own decoration is back as it was. + await expect.poll(async () => pressState(row)).toBeNull(); + expect(await look()).toEqual(own); +}); diff --git a/packages/core/src/List/__tests__/ListSelectionHold.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListSelectionHold.interaction.browser.spec.ts new file mode 100644 index 0000000000000..b1913283f499e --- /dev/null +++ b/packages/core/src/List/__tests__/ListSelectionHold.interaction.browser.spec.ts @@ -0,0 +1,567 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListSelectionHold.interaction.browser.spec.ts + * @input The built List "Selection Hold" story (buttons and a slider standing + * in for a navigation stack), real touch input, the interaction harness, + * and the in-transition deselect clock (motion/uikit/list.ts) + * @output Interaction and animation proofs for a row kept selected through a + * push: the tapped row stays selected after its press would have faded; a + * pop deselects it on UIKit's 350 ms linear curve, then the row's control + * has focus, with the keyboard ring only after a keyboard's pop; a scrub + * sets the highlight from the progress and a cancel selects the row again + * at once; the slider follows a running deselect, so a finger takes it over + * from the value on screen; the deselect keeps advancing while the + * main thread is blocked (judged on the frames the screencast delivers, + * in both engines, whatever their rate), with no Layout or Paint; under + * reduced motion + * the deselect cross-fades, opacity only, within 200 ms + * (`design:reduced-motion`); a storyboard of the deselect + * @position Interaction evidence for List/selectionHold.ts on Item rows, in + * the one touch model (./touchStory.ts), with an Android case that holds the + * same way and a mouse case where there is no + * hold + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + blockMainThread, + castStoryboard, + compareCurve, + countBetween, + freezeTimers, + holdAnimations, + installTimerGate, + releaseAnimations, + seekAnimations, + startScreencast, + thawTimers, + timeline, + touchDriver, + traceRendering, + waitForAnimation, + withStorybook, + writeEvidence, +} from '@astryxdesign/interaction-harness'; +import {openStory} from './touchStory'; +import {expectReducedMotionCrossFade} from './reducedMotion'; +import {list} from '../../motion/uikit/list'; +import {samplePixel} from './rowPixels'; + +const storybook = withStorybook(test); + +const STORY = 'core-list--selection-hold'; +const POP_MS = list.deselect.navigationPopMs; +/** Highlight = 1 − progress; the in-transition deselect is linear. */ +const expectedHighlight = (t: number) => + 1 - Math.min(1, Math.max(0, t / POP_MS)); + +const row = (page: Page) => + page.locator('[data-astryx-pressable]').filter({hasText: 'Sent'}).first(); + +/** The press layer's strength: 0 while the row has no layer box (at rest). */ +const layerOpacity = async (target: Locator) => + target.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return before.content === 'none' || before.content === 'normal' + ? 0 + : Number(before.opacity); + }); + +const pressState = async (target: Locator) => + target.evaluate(el => el.getAttribute('data-astryx-press')); + +/** Taps "Sent" with a finger: the activation pushes, and the row is held. */ +async function push(page: Page) { + await openStory(page, storybook(), STORY); + const target = row(page); + await expect(target).toBeVisible(); + const box = await target.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const driver = await touchDriver(page); + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect(page.getByTestId('pushed-screen')).toBeVisible(); + return { + target, + probe: { + x: Math.round(box.x + box.width - 3), + y: Math.round(box.y + box.height / 2), + }, + }; +} + +test('a row tapped again during its slow deselect is held again, with nothing marking it deselecting', async ({ + page, +}) => { + const {target} = await push(page); + await page.getByRole('button', {name: 'Back, slowly'}).click(); + const deselecting = async () => + target.evaluate(el => el.hasAttribute('data-astryx-deselecting')); + await expect.poll(deselecting).toBe(true); + // A finger taps the row again mid-deselect: the new push holds it. + const box = await target.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const driver = await touchDriver(page); + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect.poll(async () => pressState(target)).toBe('held'); + // Fully held: its dividers stay hidden, as for the first hold. + expect(await deselecting()).toBe(false); + await expect.poll(async () => layerOpacity(target)).toBe(1); +}); + +test("a busy first frame delays the pop's deselect instead of skipping into it", async ({ + page, + browserName, +}) => { + const {target} = await push(page); + expect(await layerOpacity(target)).toBe(1); + // Back's handler starts the deselect; 150 ms of script then hold the main + // thread before the first frame. Every frame due after the block is read + // until the row rests. + const {first, frames} = await target.evaluate( + async row => + new Promise<{first: number; frames: [number, number][]}>(resolve => { + document.addEventListener( + 'click', + () => { + const until = performance.now() + 150; + while (performance.now() < until) { + // Busy: the main thread is held. + } + const frames: [number, number][] = []; + const sample = (time: number) => { + const layer = getComputedStyle(row, '::before'); + const strength = + layer.content === 'none' || layer.content === 'normal' + ? 0 + : Number(layer.opacity); + // A frame scheduled before the block runs with that + // earlier time: only frames due after it count. + if ((document.timeline.currentTime as number) >= until) { + frames.push([time - until, strength]); + } + if (strength > 0 && frames.length < 60) { + requestAnimationFrame(sample); + return; + } + resolve({first: frames[0]?.[1] ?? Number.NaN, frames}); + }; + requestAnimationFrame(sample); + }, + {once: true}, + ); + [...document.querySelectorAll('button')] + .find(button => button.textContent?.trim() === 'Back') + ?.click(); + }), + ); + if (browserName === 'webkit') { + // WebKit may pin a motion created just before a block to the last frame + // before it (in some runs it does, in others it starts at the commit), + // so the first frame after the 150 ms block can be up to about 150 ms + // into the 350 ms deselect (1 − 200/350 leaves one more frame). + // Chromium starts it at the commit after the block. Once the animator + // starts a motion at the next frame in WebKit too, this branch goes. + expect(first, JSON.stringify(frames)).toBeGreaterThan(1 - 200 / 350); + } else { + // At most one frame into the 350 ms deselect (1 − 17/350 ≈ 0.95). + expect(first, JSON.stringify(frames)).toBeGreaterThan(0.9); + } + // In both engines every later frame moves on toward rest, none skipped. + for (let i = 1; i < frames.length; i++) { + expect(frames[i][1], JSON.stringify(frames)).toBeLessThanOrEqual( + frames[i - 1][1] + 0.01, + ); + expect( + frames[i][0] - frames[i - 1][0], + JSON.stringify(frames), + ).toBeLessThan(40); + } + expect(frames.at(-1)?.[1]).toBe(0); + await expect.poll(async () => layerOpacity(target)).toBe(0); +}); + +test('Android: the same hold; Back deselects the row (one touch model)', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'Android is Chromium'); + const {target} = await push(page); + expect(await pressState(target)).toBe('held'); + expect(await layerOpacity(target)).toBe(1); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + await expect(page.getByTestId('pushed-screen')).toHaveCount(0); + await expect.poll(async () => layerOpacity(target)).toBe(0); +}); + +test('a mouse: no hold; the press releases and Back just pops', async ({ + browser, +}) => { + const context = await browser.newContext({ + hasTouch: false, + isMobile: false, + viewport: {width: 900, height: 700}, + }); + const page = await context.newPage(); + try { + await openStory(page, storybook(), STORY); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const target = row(page); + await expect(target).toBeVisible(); + await target.click(); + await expect(page.getByTestId('pushed-screen')).toBeVisible(); + expect(await pressState(target)).toBeNull(); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + await expect(page.getByTestId('pushed-screen')).toHaveCount(0); + expect(await pressState(target)).toBeNull(); + } finally { + await context.close(); + } +}); + +test('the tapped row stays selected while the pushed screen covers it', async ({ + page, +}) => { + const {target} = await push(page); + // Well past the press's own release clock: the hold keeps it selected. + await page.waitForTimeout(list.highlight.releaseMs + 300); + expect(await pressState(target)).toBe('held'); + expect(await layerOpacity(target)).toBe(1); +}); + +test('the dividers around a held row stay hidden while it is covered, return when the deselect begins, and hide again on a cancel', async ({ + page, +}) => { + const {target} = await push(page); + const dividers = async () => + page + .locator('li[data-astryx-pressable]') + .evaluateAll(elements => + elements.map(el => getComputedStyle(el).borderBottomColor), + ); + const hidden = 'rgba(0, 0, 0, 0)'; + // Sent is the second row: its own divider and Inbox's above it. + await page.waitForTimeout(list.highlight.releaseMs + 300); + const covered = await dividers(); + expect(covered[0]).toBe(hidden); + expect(covered[1]).toBe(hidden); + // An interactive pop begins: both come back in its first frame while the + // highlight is still mostly on. + await page.getByLabel('Pop progress').fill('0.1'); + await expect.poll(async () => layerOpacity(target)).toBeCloseTo(0.9, 2); + const deselecting = await dividers(); + expect(deselecting[0]).not.toBe(hidden); + expect(deselecting[1]).not.toBe(hidden); + // A cancel selects the row again, and the dividers hide again. + await page.getByRole('button', {name: 'Cancel pop'}).click(); + await expect.poll(async () => layerOpacity(target)).toBe(1); + const reselected = await dividers(); + expect(reselected[0]).toBe(hidden); + expect(reselected[1]).toBe(hidden); + // The pop: back in its first frame, and they stay once it ends. + await page.getByRole('button', {name: 'Back', exact: true}).click(); + await holdAnimations(page, target); + await seekAnimations(page, 0); + const popStart = await dividers(); + expect(popStart[0]).not.toBe(hidden); + expect(popStart[1]).not.toBe(hidden); + await releaseAnimations(page, {finish: true}); + await expect.poll(async () => pressState(target)).toBeNull(); + expect(await dividers()).toEqual(popStart); +}); + +test('a pop deselects the row on the in-transition curve, then its control has focus', async ({ + page, +}, testInfo) => { + const {target} = await push(page); + // Gate the page's timers before the pop: the animator settles a motion on + // its own timer at the curve's end, by wall time, and that would end the + // hold while the deselect is seeked. + await installTimerGate(page); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + await freezeTimers(page); + const held = await holdAnimations(page, target); + // The deselect on the row's layer. (The motion foundation keeps a + // pseudo-element's animation in effect past the curve, holding its end + // value until script runs: only the first POP_MS are the curve.) + const deselect = held.find(a => a.target?.startsWith('li')); + expect(deselect, JSON.stringify(held)).toBeDefined(); + // More wall time than the pop passes while it is held, as a slow seek loop + // takes: the held deselect stays where it is seeked. + await page.waitForTimeout(POP_MS + 50); + const samples: {t: number; value: number}[] = []; + for (const t of timeline(POP_MS)) { + await seekAnimations(page, t); + samples.push({t, value: await layerOpacity(target)}); + } + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + const comparison = compareCurve(samples, expectedHighlight, 0.01); + await writeEvidence( + 'curves/list-selection-hold-pop', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {popMs: POP_MS, held, samples, comparison}, + null, + 2, + ), + }, + testInfo, + ); + expect( + comparison.maxError, + JSON.stringify(comparison.worst), + ).toBeLessThanOrEqual(0.01); + // The pop completed: the row is deselected and its own control has focus, + // where a screen reader lands back, with no keyboard ring after a pointer's + // pop (UIKit draws none for a finger). + await expect.poll(async () => pressState(target)).toBeNull(); + expect(await layerOpacity(target)).toBe(0); + expect(await target.evaluate(el => el.contains(document.activeElement))).toBe( + true, + ); + expect( + await page.evaluate( + () => document.activeElement?.matches(':focus-visible') ?? null, + ), + ).toBe(false); +}); + +test("under reduced motion a pop's deselect cross-fades: opacity only, within 200 ms", async ({ + page, +}) => { + // design:reduced-motion DR1 and DR2, under a finger. + await page.emulateMedia({reducedMotion: 'reduce'}); + const {target} = await push(page); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + const held = await holdAnimations(page, target); + expectReducedMotionCrossFade(held); + await releaseAnimations(page, {finish: true}); + await expect.poll(async () => layerOpacity(target)).toBe(0); +}); + +test('a pop from the keyboard returns focus to the row with the keyboard ring', async ({ + page, +}) => { + const {target} = await push(page); + const back = page.getByRole('button', {name: 'Back', exact: true}); + await back.focus(); + await page.keyboard.press('Enter'); + await expect.poll(async () => pressState(target)).toBeNull(); + await expect(page.getByTestId('pushed-screen')).toHaveCount(0); + expect(await target.evaluate(el => el.contains(document.activeElement))).toBe( + true, + ); + expect( + await page.evaluate( + () => document.activeElement?.matches(':focus-visible') ?? null, + ), + ).toBe(true); +}); + +test('a scrub sets the highlight from the progress; a cancel selects the row again at once', async ({ + page, +}) => { + const {target} = await push(page); + await page.getByLabel('Pop progress').fill('0.4'); + await expect.poll(async () => layerOpacity(target)).toBeCloseTo(0.6, 2); + await page.getByLabel('Pop progress').fill('0.7'); + await expect.poll(async () => layerOpacity(target)).toBeCloseTo(0.3, 2); + await page.getByRole('button', {name: 'Cancel pop'}).click(); + // Back to selected once the cancel settles, and still held for the next pop. + await expect.poll(async () => layerOpacity(target)).toBe(1); + expect(await pressState(target)).toBe('held'); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + await expect.poll(async () => pressState(target)).toBeNull(); +}); + +test('while the deselect runs, the slider shows where it is, so a finger takes it over from the value on screen', async ({ + page, +}) => { + const {target} = await push(page); + await page.getByRole('button', {name: 'Back, slowly'}).click(); + // About a third of the way through the slow, linear 3 s deselect. + await page.waitForTimeout(1000); + const slider = page.getByLabel('Pop progress'); + const read = async () => { + const [value, opacity] = await Promise.all([ + slider.evaluate(el => Number((el as HTMLInputElement).value)), + layerOpacity(target), + ]); + return {value, opacity}; + }; + const {value, opacity} = await read(); + // The thumb sits at the deselect's progress (1 − the highlight), not at 0, + // where a finger landing on it would snap the highlight back to full. + expect(value).toBeGreaterThan(0.15); + expect(value).toBeLessThan(0.85); + expect(value).toBeCloseTo(1 - opacity, 1); + // And it keeps following. + await page.waitForTimeout(400); + const later = await read(); + expect(later.value).toBeGreaterThan(value); + expect(later.value).toBeCloseTo(1 - later.opacity, 1); +}); + +/** + * Pops a held row and blocks the main thread for 600 ms while its deselect + * runs, capturing the frames the screencast delivers. The proof is judged on + * the delivered frames inside the block, up to the deselect's own end (the + * animation holds its end value past it, and the Linux WebKit build hands an + * animation that ends inside a block back to its last committed value), and + * counts none: a loaded machine's screencast delivers fewer frames than the + * compositor presents, so a missing frame is not a skipped one. + */ +async function popThroughBlock(page: Page, everyNthFrame = 1) { + const {target, probe} = await push(page); + const cast = await startScreencast(page, {everyNthFrame}); + await page.waitForTimeout(100); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + const [started] = await waitForAnimation(target, {minProgressMs: 16}); + const deselect = {from: started.from, to: started.from + POP_MS}; + const block = await blockMainThread(page, 600); + await page.waitForTimeout(150); + const frames = await cast.stop(); + const pixels = await samplePixel(page, frames, probe); + const selected = pixels[0].value; + const after = pixels.at(-1)?.value ?? 255; + const highlight = (v: number) => (after - v) / (after - selected); + const end = Math.min(block.to, deselect.to) - 10; + const highlights = pixels + .filter(p => p.t > block.from && p.t <= end) + .map(p => ({ + t: +(p.t - block.from).toFixed(1), + highlight: +highlight(p.value).toFixed(3), + })); + return { + frames, + pixels, + block, + deselect, + selected, + after, + highlight, + highlights, + }; +} + +/** + * The deselect advanced while script could not run: selected before the pop + * and deselected after; inside the block, two delivered frames at least (a + * change needs two), each as far along as the one before it, and the row well + * lighter by the end. + */ +function expectAdvancedThroughBlock({ + selected, + after, + highlights, +}: Awaited>): void { + const summary = JSON.stringify(highlights); + expect(after - selected).toBeGreaterThan(10); + expect(highlights.length, summary).toBeGreaterThanOrEqual(2); + for (let i = 1; i < highlights.length; i++) { + expect(highlights[i].highlight, summary).toBeLessThanOrEqual( + highlights[i - 1].highlight + 0.01, + ); + } + const last = highlights.at(-1); + expect(last, summary).toBeDefined(); + expect(highlights[0].highlight, summary).toBeGreaterThan( + (last?.highlight ?? 1) + 0.3, + ); +} + +test('the deselect keeps advancing while the main thread is blocked', async ({ + page, +}, testInfo) => { + const run = await popThroughBlock(page); + const {frames, block, deselect, selected, after, highlight, highlights} = run; + await writeEvidence( + 'busy-main-thread/list-selection-hold-pop', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {block, deselect, selected, after, highlights}, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, frames, { + name: `list-selection-hold-pop-busy-main-thread-${testInfo.project.name}`, + zero: block.from, + from: deselect.from - 40, + to: deselect.to + 40, + columns: 8, + cellScale: 0.45, + data: t => { + const p = run.pixels.find(f => f.t === t); + return {highlight: p == null ? -1 : +highlight(p.value).toFixed(3)}; + }, + }); + await film.write(testInfo, {block, deselect}); + expectAdvancedThroughBlock(run); +}); + +test('the same proof holds on a screencast that delivers one frame in four', async ({ + page, +}) => { + // A loaded machine's screencast keeps a fraction of the frames the + // compositor presents; the verdict must not depend on how many arrive. + expectAdvancedThroughBlock(await popThroughBlock(page, 4)); +}); + +test('the deselect does no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {target} = await push(page); + let deselect = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + // Back is activated without a pointer press: on a touch-first device a + // mouse press paints Back's own press, and the trace measures the row's + // deselect alone. + await page + .getByRole('button', {name: 'Back', exact: true}) + .dispatchEvent('click'); + const [started] = await waitForAnimation(target); + // The curve's own window (the animation holds its end value past it). + deselect = {from: started.from, to: started.from + POP_MS}; + await page.waitForTimeout(POP_MS + 100); + }); + const during = countBetween(trace, deselect.from + 30, deselect.to - 30); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); +}); + +test( + 'storyboard: a held row deselected by a pop', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {target} = await push(page); + const box = await target.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + await page.getByRole('button', {name: 'Back', exact: true}).click(); + const {storyboard} = await animationStoryboard(page, { + name: 'list-selection-hold-pop', + clip: {x: 0, y: box.y - 8, width: 402, height: box.height + 16}, + scope: target, + times: [0, 50, 100, 175, 250, 300, POP_MS], + columns: 7, + cellScale: 0.5, + measure: async () => ({opacity: await layerOpacity(target)}), + }); + await releaseAnimations(page, {finish: true}); + await storyboard.write(testInfo); + }, +); diff --git a/packages/core/src/List/__tests__/reducedMotion.ts b/packages/core/src/List/__tests__/reducedMotion.ts new file mode 100644 index 0000000000000..bf901dd6b5b5e --- /dev/null +++ b/packages/core/src/List/__tests__/reducedMotion.ts @@ -0,0 +1,102 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file reducedMotion.ts + * @input The interaction harness's held animations + * @output `expectReducedMotionCrossFade`: the browser check of + * `design:reduced-motion` DR1 and DR2 over what a change started + * @position Shared by the List and TreeList interaction specs; each case runs + * under reduced motion with a coarse pointer (a finger) + */ + +import {expect} from '@playwright/test'; +import type {HeldAnimation} from '@astryxdesign/interaction-harness'; + +/** DR2's limit on a touch presentation's cross-fade. */ +export const REDUCED_MOTION_FADE_MS = 200; + +const TIMING_KEYS = new Set([ + 'offset', + 'computedOffset', + 'easing', + 'composite', +]); + +/** Properties that move, turn or scale an element. */ +const TRAVEL = new Set(['translate', 'rotate', 'scale', 'transform']); + +/** The properties an animation's keyframes change. */ +function animatedProperties(animation: HeldAnimation): string[] { + const properties = new Set(); + for (const frame of animation.keyframes) { + for (const key of Object.keys(frame)) { + if (!TIMING_KEYS.has(key)) { + properties.add(key); + } + } + } + return [...properties]; +} + +/** + * Whether the animation only lands `property` at a new value (a step: the + * value changes between keyframes at the same offset, or under a step + * easing) rather than moving it through the values between. + */ +function onlyLands(animation: HeldAnimation, property: string): boolean { + const frames = animation.keyframes.filter(frame => property in frame); + for (let index = 0; index + 1 < frames.length; index += 1) { + const from = frames[index]; + const to = frames[index + 1]; + if (String(from[property]) === String(to[property])) { + continue; + } + const sameOffset = + Number(from.computedOffset) === Number(to.computedOffset); + const easing = typeof from.easing === 'string' ? from.easing : ''; + const stepped = /^step|steps\(/.test(easing); + if (!sameOffset && !stepped) { + return false; + } + } + return true; +} + +/** + * `design:reduced-motion` DR1 and DR2, under a coarse pointer: the change + * cross-fades (at least one opacity animation runs, its curve ending within + * 200 ms), nothing travels, scales or resizes on the way (an element may + * land at its new place in one step, hidden by the fade), and nothing but + * opacity animates. `held` is what `holdAnimations` returned right after the + * change. + */ +export function expectReducedMotionCrossFade( + held: ReadonlyArray, +): void { + const summary = JSON.stringify( + held.map(animation => ({ + target: animation.target, + properties: animatedProperties(animation), + curveEndMs: animation.curveEndMs, + })), + ); + let fades = 0; + for (const animation of held) { + for (const property of animatedProperties(animation)) { + if (property === 'opacity') { + fades += 1; + expect(animation.curveEndMs, summary).toBeGreaterThan(0); + expect(animation.curveEndMs, summary).toBeLessThanOrEqual( + REDUCED_MOTION_FADE_MS + 1, + ); + } else if (TRAVEL.has(property)) { + expect(onlyLands(animation, property), `${property}: ${summary}`).toBe( + true, + ); + } else { + expect(property, summary).toBe('opacity'); + } + } + } + expect(fades, summary).toBeGreaterThan(0); +} diff --git a/packages/core/src/List/__tests__/rowPixels.ts b/packages/core/src/List/__tests__/rowPixels.ts new file mode 100644 index 0000000000000..3dd39d14f5d0b --- /dev/null +++ b/packages/core/src/List/__tests__/rowPixels.ts @@ -0,0 +1,249 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file rowPixels.ts + * @input Playwright pages and screencast frames from the interaction harness + * @output `samplePixel` (one point's value in every screencast frame) and + * WebKit's video evidence: `installBeacon`, `blockBehindBeacon` (a main-thread + * beacon that marks the frames presented while script could not run) and + * `sampleVideo` + * @position Test helper for the List interaction proofs (row press release, + * selection hold), which read a row's press strength from pixels + */ + +import {readFile} from 'node:fs/promises'; +import {chromium, type Page} from '@playwright/test'; +import type {CastFrame} from '@astryxdesign/interaction-harness'; + +export interface PixelFrame { + readonly t: number; + /** Mean of the RGB channels at the probe point, 0–255. */ + readonly value: number; +} + +/** + * The probe point's value in every frame. Decoding runs in a scratch page of + * the same browser (no image decoder in Node), like the harness's own marker + * tracking. + */ +export async function samplePixel( + page: Page, + frames: ReadonlyArray, + probe: {x: number; y: number}, +): Promise { + const scratch = await page.context().newPage(); + try { + const out: PixelFrame[] = []; + for (let i = 0; i < frames.length; i += 12) { + const batch = frames.slice(i, i + 12); + const values = await scratch.evaluate( + async ({items, at}) => { + const results: number[] = []; + for (const item of items) { + const bitmap = await createImageBitmap( + await ( + await fetch(`data:image/${item.format};base64,${item.data}`) + ).blob(), + ); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + if (context == null) { + throw new Error('no 2d context'); + } + context.drawImage(bitmap, 0, 0); + const scale = bitmap.width / item.viewportWidth; + const px = context.getImageData( + Math.round(at.x * scale), + Math.round(at.y * scale), + 1, + 1, + ).data; + results.push((px[0] + px[1] + px[2]) / 3); + } + return results; + }, + { + items: batch.map(f => ({ + data: f.data, + format: f.format, + viewportWidth: f.viewportWidth, + })), + at: probe, + }, + ); + values.forEach((value, k) => out.push({t: batch[k].t, value})); + } + return out; + } finally { + await scratch.close(); + } +} + +/** The beacon's size, CSS px, in the viewport's bottom-right corner. */ +const BEACON = 40; + +/** + * Adds the main-thread beacon: a corner square the page paints grey, blue + * while it is about to block, and green once script runs again. A recorded + * frame showing blue was presented while script could not run. + */ +export async function installBeacon(page: Page): Promise { + await page.evaluate(size => { + const beacon = document.createElement('div'); + beacon.id = 'beacon'; + Object.assign(beacon.style, { + position: 'fixed', + right: '0', + bottom: '0', + width: `${size}px`, + height: `${size}px`, + background: 'rgb(128, 128, 128)', + zIndex: '2147483647', + }); + document.body.append(beacon); + }, BEACON); +} + +/** + * Paints the beacon blue, lets two frames commit it, blocks the main thread + * for `ms`, then paints it green. + */ +export async function blockBehindBeacon(page: Page, ms: number): Promise { + await page.evaluate( + async duration => + new Promise(resolve => { + const beacon = document.getElementById('beacon') as HTMLElement; + beacon.style.background = 'rgb(0, 0, 255)'; + requestAnimationFrame(() => + requestAnimationFrame(() => { + const end = performance.now() + duration; + while (performance.now() < end) { + // blocked + } + beacon.style.background = 'rgb(0, 200, 0)'; + resolve(); + }), + ); + }), + ms, + ); +} + +/** + * Arms the same block in the page for the next `event` (e.g. the lift's + * `touchend`): beacon blue, two frames, the block, beacon green. Armed in + * the page, the block starts two frames after the event with no + * test-runner round trip in between, so a short motion cannot finish first. + */ +export async function blockBehindBeaconOn( + page: Page, + event: string, + ms: number, +): Promise { + await page.evaluate( + ({type, duration}) => { + addEventListener( + type, + () => { + const beacon = document.getElementById('beacon') as HTMLElement; + beacon.style.background = 'rgb(0, 0, 255)'; + requestAnimationFrame(() => + requestAnimationFrame(() => { + const end = performance.now() + duration; + while (performance.now() < end) { + // blocked + } + beacon.style.background = 'rgb(0, 200, 0)'; + }), + ); + }, + {once: true, passive: true, capture: true}, + ); + }, + {type: event, duration: ms}, + ); +} + +export interface VideoSample { + /** Video time, s. */ + readonly t: number; + /** Mean of the RGB channels at the probe point, 0–255. */ + readonly value: number; + readonly beacon: 'before' | 'blocked' | 'after'; +} + +/** + * The probe point's value and the beacon's state every 10 ms of a recorded + * video (Playwright's `recordVideo`, WebKit's frame evidence), decoded in a + * Chromium page. + */ +export async function sampleVideo( + videoPath: string, + probe: {x: number; y: number}, + viewport: {width: number; height: number}, +): Promise { + const webm = (await readFile(videoPath)).toString('base64'); + const decoder = await chromium.launch(); + try { + const player = await decoder.newPage(); + return await player.evaluate( + async ({at, data, beaconAt}) => { + const element = document.createElement('video'); + element.muted = true; + element.src = URL.createObjectURL( + new Blob([Uint8Array.from(atob(data), c => c.charCodeAt(0))], { + type: 'video/webm', + }), + ); + document.body.append(element); + if (element.readyState < 1) { + await new Promise(r => + element.addEventListener('loadedmetadata', r, {once: true}), + ); + } + element.pause(); + const canvas = document.createElement('canvas'); + canvas.width = element.videoWidth; + canvas.height = element.videoHeight; + const context2d = canvas.getContext('2d', { + willReadFrequently: true, + }) as CanvasRenderingContext2D; + const out: { + t: number; + value: number; + beacon: 'before' | 'blocked' | 'after'; + }[] = []; + const duration = Number.isFinite(element.duration) + ? element.duration + : 10; + for (let t = 0; t < duration; t += 0.01) { + element.currentTime = t; + await new Promise(r => + element.addEventListener('seeked', r, {once: true}), + ); + context2d.drawImage(element, 0, 0); + const px = context2d.getImageData(at.x, at.y, 1, 1).data; + const b = context2d.getImageData(beaconAt.x, beaconAt.y, 1, 1).data; + const beacon = + b[2] > 180 && b[0] < 80 + ? 'blocked' + : b[1] > 150 && b[2] < 80 + ? 'after' + : 'before'; + out.push({t, value: (px[0] + px[1] + px[2]) / 3, beacon}); + } + return out; + }, + { + at: probe, + data: webm, + beaconAt: { + x: viewport.width - BEACON / 2, + y: viewport.height - BEACON / 2, + }, + }, + ); + } finally { + await decoder.close(); + } +} diff --git a/packages/core/src/List/__tests__/touchStory.ts b/packages/core/src/List/__tests__/touchStory.ts new file mode 100644 index 0000000000000..e33cdd9f32111 --- /dev/null +++ b/packages/core/src/List/__tests__/touchStory.ts @@ -0,0 +1,44 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file touchStory.ts + * @input Playwright's Page and a built Storybook's origin + * @output `openStory(page, origin, storyId, options)`: opens a story and + * waits for its first rows, its fonts and two frames + * @position Shared helper for the list specs. Their behaviour is the one + * touch model under any finger (TOUCH_QUERY in utils/touchPresentation.ts): + * the projects' contexts already have the finger, and nothing is emulated, + * so Chromium shows it as Android's engine does and WebKit as Safari's. A + * mouse case opens its own context without touch. + * + * SYNC: When modified, update the specs that import it. + */ + +import type {Page} from '@playwright/test'; + +/** + * Opens a story and waits for its first rows, its fonts and two frames. + * `globals`: Storybook globals such as `direction:rtl`. + */ +export async function openStory( + page: Page, + origin: string, + storyId: string, + {globals}: {globals?: string} = {}, +): Promise { + const params = new URLSearchParams({id: storyId, viewMode: 'story'}); + if (globals != null) { + params.set('globals', globals); + } + await page.goto(`${origin}/iframe.html?${params.toString()}`); + await page + .locator('#storybook-root > *') + .first() + .waitFor({state: 'attached'}); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + }); +} diff --git a/packages/core/src/List/index.ts b/packages/core/src/List/index.ts index 7126a7fbba4d3..da602926bd17b 100644 --- a/packages/core/src/List/index.ts +++ b/packages/core/src/List/index.ts @@ -5,7 +5,9 @@ /** * @file index.ts * @input Imports from List.tsx and ListItem.tsx - * @output Exports List, ListItem components and their props/types + * @output Exports List, ListItem components and their props/types. The + * selection hold (selectionHold.ts) is internal: no public API records it + * (`architecture:public-component-api` INV12) * @position Package entry point for List * * SYNC: When modified, update /packages/core/src/List/List.doc.mjs diff --git a/packages/core/src/List/selectionHold.test.ts b/packages/core/src/List/selectionHold.test.ts new file mode 100644 index 0000000000000..16909acd84d91 --- /dev/null +++ b/packages/core/src/List/selectionHold.test.ts @@ -0,0 +1,422 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file selectionHold.test.ts + * @input vitest, Item and TreeList rows, the spring and Bézier solvers, and + * the in-transition deselect clock (motion/uikit/list.ts) + * @output Unit tests for holdSelection: which targets it holds (under any + * finger, never a mouse), the held state it writes and leaves, presentation values + * from each timing's closed-form curve, the deselect's clock and curve as + * internal constants no theme variable reaches, interruption, completion, + * cancel and replacement + * @position Beside List/selectionHold.ts; the compositor and pixel proofs are + * in List/__tests__/ListSelectionHold.interaction.browser.spec.ts + */ + +import {createElement} from 'react'; +import {render, screen} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {Item} from '../Item'; +import {List} from './List'; +import {ListItem} from './ListItem'; +import {TreeList} from '../TreeList'; +import {cubicBezier} from '../motion/bezier'; +import {solveSpring, toSpring} from '../motion/spring'; +import {list} from '../motion/uikit/list'; +import {timing} from '../motion/uikit/timing'; +import { + __resetInteractionModalityForTest, + __startInteractionModalityTrackingForTest, +} from '../utils/interactionModality'; +import { + CELL_PRESS_PROFILE, + PRESSABLE_ATTRIBUTE, + PRESSED_ATTRIBUTE, +} from '../utils/pressFeedback'; +import {DESELECTING_ATTRIBUTE, holdSelection} from './selectionHold'; + +let clock = 0; +const now = () => clock; + +/** + * The page under a finger (a coarse primary pointer: one touch model, iOS + * and Android alike) or a mouse, optionally with the person preferring + * reduced motion. + */ +function stubPointer( + pointer: 'touch' | 'mouse', + {reducedMotion = false}: {reducedMotion?: boolean} = {}, +) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + (pointer === 'touch' && query.includes('pointer: coarse')) || + (reducedMotion && query.includes('prefers-reduced-motion: reduce')), + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + })), + ); +} + +beforeEach(() => { + clock = 1000; + vi.useFakeTimers(); + stubPointer('touch'); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function listRow(label = 'Inbox') { + render( + createElement( + List, + null, + createElement(ListItem, {label, onClick: () => {}}), + createElement(ListItem, {label: 'Static'}), + ), + ); + const row = screen.getByText(label).closest('li') as HTMLElement; + // The activation's target: the row's own control. + const control = row.querySelector('button') as HTMLElement; + return {row, control}; +} + +describe('what a hold holds', () => { + it('holds the list row around the activation target, marked as a cell', () => { + const {row, control} = listRow(); + expect(row.getAttribute(PRESSABLE_ATTRIBUTE)).toBe(CELL_PRESS_PROFILE); + const hold = holdSelection(control, {now}); + expect(hold?.element).toBe(row); + expect(row.getAttribute(PRESSED_ATTRIBUTE)).toBe('held'); + }); + + it('holds a TreeList row by its row box', () => { + render( + createElement(TreeList, { + items: [{id: 'a', label: 'App.tsx', onClick: () => {}}], + }), + ); + const box = screen + .getByRole('treeitem') + .querySelector(`[${PRESSABLE_ATTRIBUTE}]`) as HTMLElement; + const hold = holdSelection(screen.getByText('App.tsx'), {now}); + expect(hold?.element).toBe(box); + }); + + it("returns focus in a tree to the row's treeitem, the tree's Tab stop", () => { + render( + createElement(TreeList, { + items: [ + {id: 'a', label: 'App.tsx', onClick: () => {}}, + {id: 'b', label: 'index.tsx', onClick: () => {}}, + ], + }), + ); + const item = screen + .getByText('index.tsx') + .closest('[role="treeitem"]') as HTMLElement; + const hold = holdSelection(screen.getByText('index.tsx'), {now}); + hold?.end(true); + hold?.focus({preventScroll: true}); + expect(document.activeElement).toBe(item); + }); + + it('holds nothing under a mouse: touch work costs nothing there', () => { + stubPointer('mouse'); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const {row, control} = listRow('Inbox mouse'); + expect(holdSelection(control, {now})).toBeNull(); + expect(row.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + }); + + it('holds nothing outside an enabled row with an action', () => { + render( + createElement( + 'div', + null, + createElement(Item, {label: 'Static', 'data-testid': 'static'}), + createElement(Item, { + label: 'Off', + onClick: () => {}, + isDisabled: true, + 'data-testid': 'off', + }), + createElement('button', {'data-testid': 'button'}, 'Plain'), + ), + ); + expect(holdSelection(screen.getByTestId('static'))).toBeNull(); + expect(holdSelection(screen.getByTestId('off'))).toBeNull(); + expect(holdSelection(screen.getByTestId('button'))).toBeNull(); + expect(holdSelection(null)).toBeNull(); + expect(holdSelection(window)).toBeNull(); + }); +}); + +describe('presentation follows each timing’s curve', () => { + it('a programmatic pop: UIKit’s in-transition deselect, 350 ms linear', () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + expect(hold?.presentation()).toBe(0); + void hold?.animate(1, {startTime: 1000}); + const ms = list.deselect.navigationPopMs; + expect(hold?.presentation(1000)).toBe(0); + expect(hold?.presentation(1000 + ms / 2)).toBeCloseTo(0.5, 6); + expect(hold?.presentation(1000 + ms / 4)).toBeCloseTo(0.25, 6); + expect(hold?.presentation(1000 + ms)).toBe(1); + }); + + it('an interactive finish: the navigation’s spring from the scrubbed value, with its velocity', () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + hold?.setProgress(0.4); + expect(hold?.presentation()).toBe(0.4); + const spring = {response: 0.3, dampingRatio: 0.85}; + void hold?.animate(1, {spring, velocity: 2, startTime: 1000}); + const expected = solveSpring(toSpring(spring), { + from: 0.4, + to: 1, + velocity: 2, + }); + for (const t of [0, 0.05, 0.1, 0.2]) { + expect(hold?.presentation(1000 + t * 1000)).toBeCloseTo( + Math.min(1, Math.max(0, expected.value(t))), + 6, + ); + } + }); + + it("under reduced motion, every deselect takes the animator's cross-fade: no spring, done within 200 ms", () => { + // design:reduced-motion DR1 and DR2: the in-transition deselect, a + // navigation's spring and a caller's own long curve alike. + stubPointer('touch', {reducedMotion: true}); + const timings = [ + {}, + {spring: {response: 0.35, dampingRatio: 1}, velocity: 1}, + {durationMs: 3000, curve: timing.curves.move}, + ] as const; + for (const each of timings) { + const {control} = listRow(`Row ${timings.indexOf(each)}`); + const hold = holdSelection(control, {now}); + void hold?.animate(1, {...each, startTime: 1000}); + expect( + hold?.presentation(1000 + 100), + JSON.stringify(each), + ).toBeGreaterThan(0); + expect(hold?.presentation(1000 + 200), JSON.stringify(each)).toBe(1); + } + }); + + it("a fixed duration on a Bézier curve (a navigation's own fade)", () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + const curve = timing.curves.move; + void hold?.animate(1, { + durationMs: timing.reducedMotionCrossFadeMs, + curve, + startTime: 1000, + }); + const ease = cubicBezier(curve); + const d = timing.reducedMotionCrossFadeMs; + expect(hold?.presentation(1000 + d * 0.3)).toBeCloseTo(ease(0.3), 6); + expect(hold?.presentation(1000 + d)).toBe(1); + }); + + it('keeps the in-transition deselect’s own clock and curve whatever a theme writes', () => { + // The deselect's clock and curve are internal constants: variables a + // theme's scope reports for them change nothing. + const theme: Record = { + '--motion-list-deselect-navigation-pop-ms': '700ms', + '--motion-list-deselect-navigation-pop-curve': + 'cubic-bezier(0.42, 0, 0.58, 1)', + }; + const computed = window.getComputedStyle.bind(window); + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element, pseudo) => { + const style = computed(element, pseudo); + if (!element.hasAttribute('data-astryx-theme')) { + return style; + } + // The theme's scope: only its role variables are read. + return { + getPropertyValue: (name: string) => + theme[name] ?? style.getPropertyValue(name), + } as unknown as CSSStyleDeclaration; + }, + ); + render( + createElement( + 'div', + {'data-astryx-theme': 'slow'}, + createElement( + List, + null, + createElement(ListItem, {label: 'Themed', onClick: () => {}}), + ), + ), + ); + const control = screen + .getByText('Themed') + .closest('li') + ?.querySelector('button') as HTMLElement; + const hold = holdSelection(control, {now}); + void hold?.animate(1, {startTime: 1000}); + const ms = list.deselect.navigationPopMs; + const ease = cubicBezier(list.deselect.navigationPopCurve); + expect(hold?.presentation(1000 + ms * 0.25)).toBeCloseTo(ease(0.25), 6); + expect(hold?.presentation(1000 + ms)).toBe(1); + // A caller's own timing is left as it is. + hold?.setProgress(0); + void hold?.animate(1, { + durationMs: 100, + curve: [0, 0, 1, 1], + startTime: 2000, + }); + expect(hold?.presentation(2050)).toBeCloseTo(0.5, 6); + }); + + it('a pop that starts from a scrubbed value takes its share of the clock', () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + hold?.setProgress(0.5); + void hold?.animate(1, {startTime: 1000}); + const half = list.deselect.navigationPopMs / 2; + expect(hold?.presentation(1000 + half / 2)).toBeCloseTo(0.75, 6); + expect(hold?.presentation(1000 + half)).toBe(1); + }); +}); + +describe('interruption, completion and cancel', () => { + it('resolves true when the deselect arrives', async () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + const arrived = hold?.animate(1, {startTime: 1000}); + clock = 1000 + list.deselect.navigationPopMs; + vi.advanceTimersByTime(list.deselect.navigationPopMs); + await expect(arrived).resolves.toBe(true); + expect(hold?.presentation()).toBe(1); + }); + + it('a finger grabbing mid-flight stops it where it is: the promise resolves false', async () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + const arrived = hold?.animate(1, {startTime: 1000}); + clock = 1000 + list.deselect.navigationPopMs * 0.3; + // Where it is on screen, from the curve: the finger starts there. + expect(hold?.presentation()).toBeCloseTo(0.3, 6); + hold?.setProgress(hold.presentation()); + await expect(arrived).resolves.toBe(false); + expect(hold?.presentation()).toBeCloseTo(0.3, 6); + }); + + it('a completed pop deselects the row and drops the hold', () => { + const {row, control} = listRow(); + const hold = holdSelection(control, {now}); + hold?.end(true); + expect(row.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + // Inert from here on. + hold?.end(false); + hold?.setProgress(0.5); + expect(row.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + }); + + it('a cancelled pop selects the row again at once and keeps the hold', async () => { + const {row, control} = listRow(); + const hold = holdSelection(control, {now}); + hold?.setProgress(0.6); + const back = hold?.animate(0, { + spring: {response: 0.3, dampingRatio: 0.85}, + startTime: 1000, + }); + hold?.end(false); + await expect(back).resolves.toBe(false); + expect(row.getAttribute(PRESSED_ATTRIBUTE)).toBe('held'); + expect(hold?.presentation()).toBe(0); + // The next pop still deselects it. + hold?.end(true); + expect(row.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + }); + + it('a hold taken while the previous one deselects starts fully held, with no deselecting mark', () => { + const {row, control} = listRow(); + const first = holdSelection(control, {now}); + void first?.animate(1, {startTime: 1000}); + clock = 1000 + list.deselect.navigationPopMs * 0.4; + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(true); + // Tapped again mid-deselect: a new push holds the row. + const second = holdSelection(control, {now}); + expect(second).not.toBeNull(); + expect(row.getAttribute(PRESSED_ATTRIBUTE)).toBe('held'); + expect(second?.presentation()).toBe(0); + // The dividers beside it stay hidden: nothing marks it deselecting. + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(false); + }); + + it("marks the row deselecting from the deselect's first frame until a cancel or the end", async () => { + const {row, control} = listRow(); + const hold = holdSelection(control, {now}); + // Covered: the dividers stay hidden. + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(false); + hold?.setProgress(0.1); + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(true); + const back = hold?.animate(0, { + spring: {response: 0.3, dampingRatio: 0.85}, + startTime: 1000, + }); + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(false); + hold?.end(false); + await expect(back).resolves.toBe(false); + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(false); + void hold?.animate(1, {startTime: 1000}); + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(true); + hold?.end(true); + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(false); + }); + + it('a second hold on the row takes over; the first goes inert', () => { + const {row, control} = listRow(); + const first = holdSelection(control, {now}); + const second = holdSelection(control, {now}); + first?.end(true); + expect(row.getAttribute(PRESSED_ATTRIBUTE)).toBe('held'); + second?.end(true); + expect(row.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + }); + + it('returns focus to the row’s own control', () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + hold?.end(true); + hold?.focus({preventScroll: true}); + expect(document.activeElement).toBe(control); + }); + + it('returns focus with no keyboard ring after a finger’s pop, and with one after a key’s', () => { + __startInteractionModalityTrackingForTest(); + const {control} = listRow(); + const focus = vi.spyOn(control, 'focus'); + const hold = holdSelection(control, {now}); + hold?.end(true); + // A finger pressed Back: focus returns without `:focus-visible`. + document.dispatchEvent(new Event('pointerdown')); + hold?.focus({preventScroll: true}); + expect(focus).toHaveBeenLastCalledWith({ + preventScroll: true, + focusVisible: false, + }); + expect(document.activeElement).toBe(control); + // A key pressed Back: the browser shows the ring where focus went. + control.blur(); + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'})); + hold?.focus({preventScroll: true}); + expect(focus).toHaveBeenLastCalledWith({preventScroll: true}); + __resetInteractionModalityForTest(); + }); +}); diff --git a/packages/core/src/List/selectionHold.ts b/packages/core/src/List/selectionHold.ts new file mode 100644 index 0000000000000..3084be1e0f16b --- /dev/null +++ b/packages/core/src/List/selectionHold.ts @@ -0,0 +1,387 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file selectionHold.ts + * @input A list row under an activation target, the press layer's held state + * (the `cell` style of interactionOverlay.stylex.ts), the motion + * foundation's animator and Bézier solver (motion/), and the + * in-transition deselect clock and curve + * (motion/uikit/list.ts, internal constants), and the last input's + * modality (focusReturn.ts) for the focus it returns + * @output Exports `holdSelection`, which keeps a tapped list row selected + * while a navigation covers it and deselects it as the navigation pops + * (`SelectionHold`), plus its timing types + * @position Interaction utility. A navigation container (a stack) calls it + * with the target of the activation that pushed; Item and TreeList rows are + * the rows it finds. + * + * UIKit's reference (iOS 26.2): the row a user taps to push stays selected + * while the pushed screen covers it. The deselect starts when the pop does + * (the back button, or the edge swipe beginning) and runs inside the + * navigation transition, linearly over the transition's 0.35 s, so an + * interactive pop scrubs it with its progress. A cancelled pop re-selects the + * row at once. VoiceOver focus returns to the row. + * + * HOW IT PAINTS. The hold writes `data-astryx-press="held"` on the row, which + * shows the press layer (the row's `::before`) at full strength; the touch + * press controller never overwrites a value it did not write. The deselect is + * the layer's opacity, `1 − progress`, driven by the motion foundation's + * animator on the `::before` (Web Animations: the compositor runs it while + * script is busy, on the caller's clock). Its curves are evaluated in closed + * form, never read back from the DOM: `presentation` answers where the + * highlight is at a time from the curve itself, so a finger landing + * mid-animation starts from the value on screen. + * + * The deselect's clock and curve are the list table's internal constants + * (`list.deselect`): no theme variable reaches them. Under reduced motion + * every deselect that `animate` plays takes the animator's own Reduced + * Motion handling (`design:reduced-motion`): no spring, opacity only, 200 ms + * or less under a finger; the hold has no option to keep its curve. + * + * Progress, as the navigation measures it: 0 = the pushed screen covers the + * row, 1 = the row is revealed. Highlight = 1 − progress. + * + * SSR-safe: nothing runs at import time. + * + * SYNC: When modified, update: + * - /packages/core/src/List/selectionHold.test.ts + * - /packages/core/src/List/__tests__/ListSelectionHold.interaction.browser.spec.ts + * - /packages/core/src/utils/interactionOverlay.stylex.ts (the held arm) + */ + +import { + createAnimator, + type Animator, + type MotionCurve, +} from '../motion/animator'; +import {cubicBezier} from '../motion/bezier'; +import {list} from '../motion/uikit/list'; +import {focusForLastInput} from '../utils/focusReturn'; +import { + CELL_PRESS_PROFILE, + PRESSABLE_ATTRIBUTE, + PRESSED_ATTRIBUTE, +} from '../utils/pressFeedback'; +import {TOUCH_QUERY} from '../utils/touchPresentation'; + +/** The press state a hold writes; owned by the hold, never by the controller. */ +export const HELD_PRESS_STATE = 'held'; + +/** + * Written on a held row once its deselect has begun (a pop's animation or an + * interactive pop's first frame) and removed by a cancel: a deselecting UIKit + * cell shows its separators again in the deselect's first frame, while its + * background is still fading, and hides them again when a cancel selects it. + */ +export const DESELECTING_ATTRIBUTE = 'data-astryx-deselecting'; + +interface StartTime { + /** + * Start on the document timeline (ms, `performance.now()` clock). Pass the + * transition's own start to ride its clock. Without one, a deselect from + * rest starts when the browser commits it, so a busy first frame delays it + * rather than skipping into it (motion/animator.ts, start time). + */ + readonly startTime?: number; +} + +/** + * UIKit's in-transition deselect: `list.deselect`, 350 ms linear. + */ +export type HoldTransitionTiming = StartTime; + +/** + * A spring, described the way the navigation describes its own: physically + * (`stiffness`, `damping`, `mass` 1 by default), by its perceptual duration + * and bounce (seconds; 0 settles without overshoot), or by its response and + * damping ratio (seconds; 1 settles without overshoot). + */ +export type HoldSpring = + | { + readonly mass?: number; + readonly stiffness: number; + readonly damping: number; + } + | {readonly duration: number; readonly bounce: number} + | {readonly response: number; readonly dampingRatio: number}; + +/** A cubic Bézier timing curve's two control points: `[x1, y1, x2, y2]`. */ +export type HoldCurve = readonly [number, number, number, number]; + +/** A spring, as the navigation's own (`velocity` in progress per second). */ +export interface HoldSpringTiming extends StartTime { + readonly spring: HoldSpring; + readonly velocity?: number; +} + +/** A fixed duration on a Bézier curve (Reduce Motion's cross-fade). */ +export interface HoldCurveTiming extends StartTime { + readonly durationMs: number; + readonly curve: HoldCurve; +} + +export type HoldTiming = + HoldTransitionTiming | HoldSpringTiming | HoldCurveTiming; + +export interface SelectionHold { + /** The row kept selected. */ + readonly element: HTMLElement; + /** + * Puts the deselect at `progress` (0 covered … 1 revealed) now: the + * per-frame write of an interactive pop. Stops a running `animate`, whose + * promise resolves false. + */ + setProgress(progress: number): void; + /** + * Moves the deselect to `to` from its presentation value: 1 when the pop + * completes, 0 when it cancels. With no spring or duration, UIKit's + * in-transition deselect (350 ms linear). Resolves true when it arrives, + * false when `setProgress`, another `animate` or `end` interrupts it. + */ + animate(to: 0 | 1, timing?: HoldTiming): Promise; + /** The deselect's progress at `time` (default now), from its curve. */ + presentation(time?: number): number; + /** + * Focuses the row's own control (the element a screen reader lands on), + * with a keyboard ring only when the user last used a keyboard. + */ + focus(options?: FocusOptions): void; + /** + * Ends the hold. `true`: the row was revealed for good (a completed pop, + * or the held screen left the stack another way); the row is deselected at + * once, with no animation of its own. `false`: the pop was cancelled; the + * row is selected again at once, and the hold stays for the next pop. + */ + end(completed: boolean): void; +} + +const clamp01 = (value: number) => Math.min(1, Math.max(0, value)); + +function defaultNow(): number { + return typeof performance === 'undefined' ? Date.now() : performance.now(); +} + +/** + * A fixed duration on a Bézier curve, from `from` to `to`, as the + * animator plays it (seconds; velocity in units per second). + */ +function bezierCurve( + from: number, + to: number, + durationMs: number, + points: HoldCurve, +): MotionCurve { + const ease = cubicBezier(points); + const duration = Math.max(0, durationMs) / 1000; + const delta = to - from; + const progress = (t: number) => (duration <= 0 ? 1 : clamp01(t / duration)); + return { + duration, + at: t => from + delta * ease(progress(t)), + velocity: t => { + if (duration <= 0) { + return 0; + } + const x = progress(t); + const x0 = Math.max(0, x - 1e-4); + const x1 = Math.min(1, x + 1e-4); + return (delta * (ease(x1) - ease(x0))) / ((x1 - x0) * duration); + }, + }; +} + +/** + * The row's own control: the element focus and a screen reader land on. In a + * tree, that is the row's treeitem, which holds the tree's roving Tab stop, + * not a control inside it. + */ +function focusTarget(row: HTMLElement): HTMLElement { + const item = row.closest('[role="treeitem"]'); + if (item != null) { + return item; + } + if (row.matches('a[href], button, [tabindex]:not([tabindex="-1"])')) { + return row; + } + return ( + row.querySelector( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) ?? row + ); +} + +/** + * The live hold of each row, by its retire function: a second hold on a row + * takes over from the first, which goes inert without touching the row. + */ +const holds = new WeakMap void>(); + +/** A finger is the primary pointer (TOUCH_QUERY, utils/touchPresentation.ts). */ +function isTouch(view: Window | null): boolean { + return ( + view != null && + typeof view.matchMedia === 'function' && + view.matchMedia(TOUCH_QUERY).matches + ); +} + +/** + * Keeps the list row under `target` selected while a navigation covers it, + * and returns the handle that deselects it as the navigation pops. Null when + * `target` is not inside an enabled list row (an Item or TreeList row with an + * action), and under a mouse: a hold is part of the one touch model, under + * any finger (a coarse primary pointer, iOS and Android alike); with a mouse + * the press releases at the lift as before and the caller's `hold?.` calls + * do nothing. + * + * @example + * ``` + * // In the same task as the activation that pushes: + * const hold = holdSelection(event.target); + * // A programmatic pop, on the transition's own clock: + * await hold?.animate(1, {startTime}); + * hold?.end(true); + * hold?.focus({preventScroll: true}); + * ``` + */ +export function holdSelection( + target: EventTarget | null | undefined, + options: {readonly now?: () => number} = {}, +): SelectionHold | null { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return null; + } + const row = target.closest( + `[${PRESSABLE_ATTRIBUTE}="${CELL_PRESS_PROFILE}"]`, + ); + if ( + row == null || + !isTouch(row.ownerDocument.defaultView) || + row.matches(':disabled, [aria-disabled="true"]') || + row.closest('[aria-disabled="true"]') != null + ) { + return null; + } + // A new hold takes the row from any earlier one, even mid-deselect: it + // starts fully held, so nothing marks the row deselecting and the + // dividers beside it stay hidden. + holds.get(row)?.(); + row.removeAttribute(DESELECTING_ATTRIBUTE); + + const now = options.now ?? defaultNow; + let ended = false; + /** + * The layer's opacity (the highlight), once something moves it; until + * then, and after a cancel, the held state shows it at 1. + */ + let animator: Animator | null = null; + const layer = () => { + animator ??= createAnimator(row, { + pseudoElement: '::before', + initial: {opacity: 1}, + now, + }); + return animator; + }; + const drop = () => { + animator?.dispose(); + animator = null; + }; + const retire = () => { + drop(); + ended = true; + }; + + const presentation = (time = now()): number => + animator == null ? 0 : clamp01(1 - animator.get('opacity', time)); + + const hold: SelectionHold = { + element: row, + + setProgress(progress) { + if (!ended) { + row.setAttribute(DESELECTING_ATTRIBUTE, ''); + layer().set({opacity: 1 - clamp01(progress)}); + } + }, + + async animate(to, timing = {}) { + if (ended) { + return false; + } + // The transition's start when the caller has one; otherwise none, so + // a deselect from rest starts with the browser's commit. + const at = timing.startTime; + const start = at == null ? {} : {at}; + const target = 1 - to; + if (to === 1) { + row.setAttribute(DESELECTING_ATTRIBUTE, ''); + } else { + row.removeAttribute(DESELECTING_ATTRIBUTE); + } + const motion = layer(); + if ('spring' in timing) { + return motion.animate( + {opacity: target}, + { + spring: timing.spring, + // Progress per second; the highlight moves the other way. + ...(timing.velocity == null + ? {} + : {velocity: {opacity: -timing.velocity}}), + }, + start, + ).finished; + } + const from = motion.get('opacity', at ?? now()); + const durationMs = + 'durationMs' in timing + ? timing.durationMs + : // The in-transition deselect covers the whole way in its clock; + // a partial way (a pop started from a scrubbed value) takes its + // share. + list.deselect.navigationPopMs * Math.abs(target - from); + const points = + 'curve' in timing ? timing.curve : list.deselect.navigationPopCurve; + return motion.play( + {opacity: bezierCurve(from, target, durationMs, points)}, + start, + ).finished; + }, + + presentation, + + focus(focusOptions) { + if (row.isConnected) { + // The ring follows the last input (architecture:interaction-modality + // INV1): after a finger's pop, focus lands with no keyboard ring, as + // UIKit draws none; after a keyboard's, the ring shows where it went. + focusForLastInput(focusTarget(row), focusOptions); + } + }, + + end(completed) { + if (ended) { + return; + } + drop(); + row.removeAttribute(DESELECTING_ATTRIBUTE); + if (completed) { + ended = true; + if (holds.get(row) === retire) { + holds.delete(row); + } + if (row.getAttribute(PRESSED_ATTRIBUTE) === HELD_PRESS_STATE) { + row.removeAttribute(PRESSED_ATTRIBUTE); + } + } else { + // Selected again at once; the hold stays for the next pop. + row.setAttribute(PRESSED_ATTRIBUTE, HELD_PRESS_STATE); + } + }, + }; + + row.setAttribute(PRESSED_ATTRIBUTE, HELD_PRESS_STATE); + holds.set(row, retire); + return hold; +} diff --git a/packages/core/src/RadioList/RadioListItem.tsx b/packages/core/src/RadioList/RadioListItem.tsx index 683f7dbee9280..d701adf00b727 100644 --- a/packages/core/src/RadioList/RadioListItem.tsx +++ b/packages/core/src/RadioList/RadioListItem.tsx @@ -31,6 +31,7 @@ import {usePressFeedback} from '../hooks/usePressFeedback'; import {useIndicator} from '../Indicator'; import {Item, type ItemAlign} from '../Item'; import {themeProps} from '../utils/themeProps'; +import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; // The touch press's paint, declared by the shared overlay styles on the // element the controller writes to (`pressedAlpha`) at the press's strength, @@ -148,6 +149,10 @@ const rowStyles = stylex.create({ // strength still lands on the row, which is where the layer over the // circle reads it. backgroundImage: 'none', + // ...and the row's touch press layer (Item's `cell`): no box. + '::before': { + content: 'none', + }, }, // The mouse press (a finger's lives on `data-astryx-press`): the row is // pressed, but not through a nested link or button, which keep their own @@ -395,6 +400,10 @@ export function RadioListItem({ // the painting row element (Item), the same element that carries the // theme target, so a theme's hover styling stays in step with the tint. !isDisabled && indicatorScope, + // The row owns the strength the circle's layer reads, and its + // release (Item's touch layer drops the row's own under a finger: + // this row has no layer of its own, so it keeps the release). + !isDisabled && interactionOverlayStyles.pressedAlpha, // Restore the bare default look: zero Item's padding/radius and its // interactive hover/press background. Applied after Item's own base // styles so it wins within the base layer; a `radio-list-item` theme diff --git a/packages/core/src/TreeList/TreeListItem.tsx b/packages/core/src/TreeList/TreeListItem.tsx index 97edf6064d6d6..adc99b1974bd7 100644 --- a/packages/core/src/TreeList/TreeListItem.tsx +++ b/packages/core/src/TreeList/TreeListItem.tsx @@ -4,7 +4,8 @@ /** * @file TreeListItem.tsx - * @input Uses React, StyleX, theme tokens, TreeListBranches + * @input Uses React, StyleX, theme tokens, TreeListBranches, the shared press + * overlays and the compositor press layer * @output Exports TreeListItem component (internal, not publicly exported) * @position Internal implementation; consumed by TreeList.tsx * @@ -26,7 +27,10 @@ import { } from '../theme/tokens.stylex'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; +import {cellPressableProps} from '../utils/pressFeedback'; +import {rowPressLayerStyles} from '../utils/rowPressLayer'; import {usePressFeedback} from '../hooks/usePressFeedback'; +import {useInteractionModalityTracking} from '../utils/interactionModality'; import { hasInteractiveAncestor, useClickableContainer, @@ -406,6 +410,9 @@ export function TreeListItem({ isTabbable, }: TreeListItemInternalProps) { const pressable = usePressFeedback(); + // A selection hold returns focus to this row after a pop; its ring follows + // how the user last interacted (List/selectionHold.ts). + useInteractionModalityTracking(); const t = useTranslator(); const labelId = useId(); const descriptionId = useId(); @@ -664,7 +671,7 @@ export function TreeListItem({
path.join(SRC, file))); + return files.map(file => path.relative(SRC, file)); +} + +describe('internal modules', () => { + it('declare no @experimental: only an experimental public API carries it', () => { + const offenders: string[] = []; + for (const file of checkedFiles()) { + fs.readFileSync(path.join(SRC, file), 'utf8') + .split('\n') + .forEach((line, index) => { + if (/@experimental\b/.test(line)) { + offenders.push(`${file}:${index + 1}`); + } + }); + } + expect(offenders).toEqual([]); + }); + + it('checks the directories and files it names', () => { + expect(fs.existsSync(path.join(SRC, 'motion'))).toBe(true); + expect(fs.existsSync(path.join(SRC, 'gestures'))).toBe(true); + expect(sourcesUnder(path.join(SRC, 'motion')).length).toBeGreaterThan(5); + for (const file of INTERNAL_FILES) { + expect(fs.existsSync(path.join(SRC, file)), file).toBe(true); + } + }); + + it('leaves the public utils entry alone: of src/utils, only its package-private files are checked', () => { + const utils = checkedFiles().filter(file => + file.startsWith('utils' + path.sep), + ); + expect(utils).toEqual([path.join('utils', 'touchPresentation.ts')]); + }); +}); diff --git a/packages/core/src/__tests__/pressState.ts b/packages/core/src/__tests__/pressState.ts index 352f450af397d..9ed3a6447b414 100644 --- a/packages/core/src/__tests__/pressState.ts +++ b/packages/core/src/__tests__/pressState.ts @@ -179,7 +179,23 @@ export function declaresPressPaint(el: Element): boolean { /** `.2s`, `200ms`, `0.2s` → 200. */ export function durationToMs(value: string): number { - const match = value.trim().match(/^([\d.]+)(ms|s)$/); + const text = value.trim(); + // StyleX folds a constant cap into `calc(
', + ); + const double = new TapGestureRecognizer({ + name: 'double', + numberOfTapsRequired: 2, + }); + detachers.push(host.attach(row, double)); + const clicks: {target: EventTarget | null; detail: number}[] = []; + button.addEventListener('click', event => + clicks.push({target: event.target, detail: event.detail}), + ); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', button, [finger], [finger], 0); + const end = touch('touchend', button, [finger], [], 60); + expect(end.defaultPrevented).toBe(true); + expect(clicks).toEqual([]); + const interval = UIKIT_GESTURES.tap.maximumIntervalBetweenTaps.value; + host.arena.advanceTime(60 + interval); + expect(clicks).toEqual([{target: button, detail: 1}]); + }); + + it('collects recognizers across shadow roots', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = 'x'; + const inner = shadow.getElementById('inner') as HTMLElement; + const tap = new TapGestureRecognizer({name: 'tap'}); + const actions: string[] = []; + tap.addListener(r => actions.push(r.state)); + detachers.push(host.attach(outer, tap)); + const finger = {id: 1, x: 5, y: 5, target: inner}; + touch('touchstart', inner, [finger], [finger], 0); + touch('touchend', inner, [finger], [], 50); + expect(actions).toEqual(['ended']); + }); + + it('drives hover recognizers from a hovering mouse', () => { + const {box, other} = mount('
'); + const hover = new HoverGestureRecognizer(); + const states: string[] = []; + hover.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, hover)); + pointer('pointermove', box, {pointerType: 'mouse', x: 1, y: 1, time: 0}); + pointer('pointermove', box, {pointerType: 'mouse', x: 2, y: 1, time: 16}); + pointer('pointermove', other, { + pointerType: 'mouse', + x: 300, + y: 1, + time: 32, + }); + expect(states).toEqual(['began', 'changed', 'ended']); + // A finger never hovers. + pointer('pointermove', box, {pointerType: 'touch', x: 1, y: 1, time: 48}); + expect(states).toHaveLength(3); + }); + + it('cancels every touch in flight when the window loses focus', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); + window.dispatchEvent(new Event('blur')); + expect(states).toEqual(['began', 'cancelled']); + }); + + it('finishes a gesture whose target left the document mid-gesture', () => { + const {box, child} = mount('
x
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', child, [finger], [finger], 0); + touch('touchmove', child, [{...finger, x: 40}], [{...finger, x: 40}], 16); + // A re-render replaces the span: its events no longer reach the document. + child.remove(); + const move = touch( + 'touchmove', + child, + [{...finger, x: 60}], + [{...finger, x: 60}], + 32, + ); + expect(move.defaultPrevented).toBe(true); + touch('touchend', child, [{...finger, x: 60}], [], 48); + // The move and the lift after the removal still reached the pan. + expect(states).toEqual(['began', 'changed', 'ended']); + expect(host.arena.touches).toEqual([]); + }); + + it('cancels a finger the events say is no longer down', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const first = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [first], [first], 0); + touch('touchmove', box, [{...first, x: 40}], [{...first, x: 40}], 16); + // Its touchend went to a node outside the document; the next event for + // another finger no longer lists it. + const other = {id: 2, x: 300, y: 300}; + touch('touchmove', document.body, [other], [other], 32); + expect(states).toEqual(['began', 'cancelled']); + }); + + it('treats a reused finger number as a new touch', () => { + const {box} = mount('
'); + const tap = new TapGestureRecognizer({name: 'tap'}); + const states: string[] = []; + tap.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, tap)); + const finger = {id: 4, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + // No touchend reached us; the same identifier lands again. + touch('touchstart', box, [finger], [finger], 500); + touch('touchend', box, [finger], [], 560); + expect(states).toEqual(['ended']); + }); + + it('cancels every finger on a touchcancel that names none', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); + box.dispatchEvent(new Event('touchcancel', {bubbles: true})); + expect(states).toEqual(['began', 'cancelled']); + }); + + it('reads epoch-stamped events on the arena clock', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + let released = {x: 0, y: 0}; + pan.addListener(r => { + if (r.state === 'ended') { + released = r.velocity(); + } + }); + detachers.push(host.attach(box, pan)); + const epoch = performance.timeOrigin + 5000; + const finger = {id: 1, x: 0, y: 10}; + touch('touchstart', box, [finger], [finger], epoch); + for (let t = 8; t <= 96; t += 8) { + const at = {...finger, x: t}; + touch('touchmove', box, [at], [at], epoch + t); + } + touch('touchend', box, [{...finger, x: 96}], [], epoch + 100); + expect(released.x).toBeCloseTo(1000, 0); + expect(host.arena.now()).toBeLessThan(1e9); + }); + + it('ignores touches that land outside every attached element', () => { + const {box, elsewhere} = mount( + '
', + ); + const pan = new PanGestureRecognizer({name: 'pan'}); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', elsewhere, [finger], [finger], 0); + const move = touch( + 'touchmove', + elsewhere, + [{...finger, x: 60}], + [{...finger, x: 60}], + 16, + ); + expect(move.defaultPrevented).toBe(false); + expect(host.arena.touches).toEqual([]); + }); +}); + +describe('an action group under a fine pointer (family:action-groups FR5; spec:AST-094 FR19, FR24)', () => { + const listenerTypes = (spy: ReturnType) => + (spy.mock.calls as unknown[][]) + .map(call => call[0] as string) + .filter(t => /^(touch|pointer|click|blur|scroll|contextmenu)/.test(t)); + + it('keeps one passive pointerdown listener on the group while idle, and none for a recognizer that is not a group', () => { + const {group, other} = mount( + '
', + ); + const added = vi.spyOn(document, 'addEventListener'); + const viewAdded = vi.spyOn(window, 'addEventListener'); + const groupAdded = vi.spyOn(group, 'addEventListener'); + const otherAdded = vi.spyOn(other, 'addEventListener'); + added.mockClear(); + viewAdded.mockClear(); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + detachers.push( + fine.attach(group, new TapGestureRecognizer(), {actionGroup: true}), + fine.attach(other, new TapGestureRecognizer()), + ); + expect(listenerTypes(added)).toEqual([]); + expect(listenerTypes(viewAdded)).toEqual([]); + expect(groupAdded.mock.calls).toEqual([ + ['pointerdown', expect.any(Function), {passive: true}], + ]); + expect(otherAdded).not.toHaveBeenCalled(); + }); + + it('adds document listeners only during a press, removes them at the release, and serves the press to the group', () => { + const {group, one} = mount( + '
', + ); + const added = vi.spyOn(document, 'addEventListener'); + const removed = vi.spyOn(document, 'removeEventListener'); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const tap = new TapGestureRecognizer(); + const states: string[] = []; + tap.addListener(r => states.push(r.state)); + detachers.push(fine.attach(group, tap, {actionGroup: true})); + added.mockClear(); + removed.mockClear(); + pointer('pointerdown', one, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + expect(listenerTypes(added)).toEqual( + expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel']), + ); + expect(listenerTypes(removed)).toEqual([]); + pointer('pointerup', one, {pointerType: 'mouse', x: 10, y: 10, time: 80}); + expect(states).toContain('ended'); + for (const type of ['pointermove', 'pointerup', 'pointercancel']) { + expect(listenerTypes(removed)).toContain(type); + } + }); + + it('serves only the group: a recognizer that is not a group, on the press path, takes no part', () => { + const {outer, group, one} = mount( + '
', + ); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const groupTap = new TapGestureRecognizer(); + const outerTap = new TapGestureRecognizer(); + const outerStates: string[] = []; + outerTap.addListener(r => outerStates.push(r.state)); + detachers.push( + fine.attach(group, groupTap, {actionGroup: true}), + fine.attach(outer, outerTap), + ); + pointer('pointerdown', one, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + pointer('pointerup', one, {pointerType: 'mouse', x: 10, y: 10, time: 80}); + expect(outerStates).toEqual([]); + }); + + it('removes the group listener on detach, and a coarse pointer keeps the usual document listeners instead', () => { + const {group} = mount('
'); + const groupRemoved = vi.spyOn(group, 'removeEventListener'); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const detach = fine.attach(group, new TapGestureRecognizer(), { + actionGroup: true, + }); + detach(); + expect(groupRemoved).toHaveBeenCalledWith( + 'pointerdown', + expect.any(Function), + ); + const groupAdded = vi.spyOn(group, 'addEventListener'); + const added = vi.spyOn(document, 'addEventListener'); + added.mockClear(); + const coarse = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + detachers.push( + coarse.attach(group, new TapGestureRecognizer(), {actionGroup: true}), + ); + expect(listenerTypes(added)).toContain('pointerdown'); + expect( + groupAdded.mock.calls.filter(call => call[0] === 'pointerdown'), + ).toEqual([]); + }); +}); diff --git a/packages/core/src/gestures/__tests__/HoverGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/HoverGestureRecognizer.test.ts new file mode 100644 index 0000000000000..6f1ed17faa15b --- /dev/null +++ b/packages/core/src/gestures/__tests__/HoverGestureRecognizer.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file HoverGestureRecognizer.test.ts + * @input Hover entries, moves and exits from a mouse or a hovering pen + * @output HoverGestureRecognizer's state machine: enter, move and exit + * @position Unit tests of packages/core/src/gestures/HoverGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {HoverGestureRecognizer} from '../HoverGestureRecognizer'; + +describe('HoverGestureRecognizer', () => { + it('begins on enter, changes on move and ends on exit', () => { + const hover = new HoverGestureRecognizer(); + const states: string[] = []; + hover.addListener(r => states.push(r.state)); + hover.hoverEntered({x: 1, y: 1}, 'mouse'); + hover.hoverMoved({x: 2, y: 2}); + hover.hoverExited({x: 3, y: 3}); + expect(states).toEqual(['began', 'changed', 'ended']); + expect(hover.state).toBe('possible'); + expect(hover.claimsTouches).toBe(false); + }); + + it('starts over at the next enter after a hover was cancelled mid-way', () => { + const hover = new HoverGestureRecognizer(); + const states: string[] = []; + hover.addListener(r => states.push(r.state)); + hover.hoverEntered({x: 1, y: 1}, 'mouse'); + hover.setEnabled(false); + hover.setEnabled(true); + hover.hoverEntered({x: 4, y: 4}, 'mouse'); + expect(states).toEqual(['began', 'cancelled', 'began']); + }); +}); diff --git a/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts index 1cf18ec0b6549..04049b935b608 100644 --- a/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts +++ b/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts @@ -8,15 +8,15 @@ */ import {describe, expect, it} from 'vitest'; -import {UIKIT_GESTURES} from '../../motion/uikit/gestures'; +import {UIKIT_PAN, UIKIT_VELOCITY} from '../../motion/uikit/gestures'; import {PanGestureRecognizer} from '../PanGestureRecognizer'; -import {createHarness, scrollProbe} from './harness'; +import {createHarness} from './harness'; describe('PanGestureRecognizer', () => { it('begins past the hysteresis and leaves the threshold out of the translation', () => { const h = createHarness(); const pan = new PanGestureRecognizer({name: 'pan'}); - const hysteresis = UIKIT_GESTURES.pan.hysteresis.value; + const hysteresis = UIKIT_PAN.hysteresis.value; h.finger(1).down(100, 100, 0, {recognizers: [pan]}); h.finger(1).move(100 + hysteresis - 0.1, 100, 16); expect(pan.state).toBe('possible'); @@ -75,7 +75,7 @@ describe('PanGestureRecognizer', () => { }); it('releases at 0 after a rest longer than the stillness interval (AST-100 FR2)', () => { - const stillness = UIKIT_GESTURES.velocity.stillness.value; + const stillness = UIKIT_VELOCITY.stillness.value; const release = (liftAt: number) => { const h = createHarness(); const pan = new PanGestureRecognizer({name: 'pan'}); diff --git a/packages/core/src/gestures/__tests__/PinchGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/PinchGestureRecognizer.test.ts new file mode 100644 index 0000000000000..bccdf40aca43d --- /dev/null +++ b/packages/core/src/gestures/__tests__/PinchGestureRecognizer.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file PinchGestureRecognizer.test.ts + * @input Synthetic touches with explicit timestamps through a GestureArena + * @output PinchGestureRecognizer's state machine: the hysteresis, the scale against the moved starting span, the velocity, extra fingers, re-basing + * @position Unit tests of packages/core/src/gestures/PinchGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {PinchGestureRecognizer} from '../PinchGestureRecognizer'; +import {createHarness} from './harness'; + +describe('PinchGestureRecognizer', () => { + it('begins after the hysteresis and reports scale and velocity', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + h.finger(1).down(100, 100, 0, {recognizers: [pinch]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch]}); + h.finger(2).move(205, 100, 16); + expect(pinch.state).toBe('possible'); + h.finger(2).move(210, 100, 32); + expect(pinch.state).toBe('began'); + // UIKit: against the starting span moved by the hysteresis (100 + 8). + expect(pinch.scale).toBeCloseTo(110 / 108, 5); + for (let t = 48; t <= 160; t += 16) { + h.finger(2).move(210 + (t - 32) * 0.1, 100, t); + } + // 0.1 px/ms over 108 px. + expect(pinch.velocity).toBeCloseTo(100 / 108, 5); + h.finger(1).up(170); + expect(h.log).toContain('pinch:ended@170'); + }); + + it('ignores a third finger and can be re-based', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + h.finger(1).down(0, 0, 0, {recognizers: [pinch]}); + h.finger(2).down(100, 0, 0, {recognizers: [pinch]}); + h.finger(3).down(50, 50, 5, {recognizers: [pinch]}); + expect(pinch.numberOfTouches).toBe(2); + h.finger(2).move(150, 0, 16); + pinch.scale = 1; + h.finger(2).move(300, 0, 32); + expect(pinch.scale).toBeCloseTo(2, 5); + }); + + it('lets the velocity settle when the span holds while the fingers move', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + h.finger(1).down(100, 100, 0, {recognizers: [pinch]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch]}); + let time = 0; + for (let x = 210; x <= 260; x += 10) { + time += 16; + h.finger(2).move(x, 100, time); + } + const spreading = pinch.velocity; + expect(spreading).toBeGreaterThan(0); + // The second finger circles the first at exactly the same distance (160: + // 3-4-5 triangles), so the span holds while it moves. + for (const [dx, dy] of [ + [128, 96], + [96, 128], + [0, 160], + [-96, 128], + [-128, 96], + ]) { + time += 16; + h.finger(2).move(100 + dx, 100 + dy, time); + } + expect(Math.abs(pinch.velocity)).toBeLessThan(spreading * 0.25); + }); +}); diff --git a/packages/core/src/gestures/__tests__/PressTracker.test.ts b/packages/core/src/gestures/__tests__/PressTracker.test.ts new file mode 100644 index 0000000000000..7dcda9396bbdd --- /dev/null +++ b/packages/core/src/gestures/__tests__/PressTracker.test.ts @@ -0,0 +1,240 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file PressTracker.test.ts + * @input A PressTracker fed through a GestureArena by synthetic touches + * @output UIControl semantics: events, the expanded tracking bounds, the + * highlight timing inside and outside scrollers, cancellation + * @position Unit tests of packages/core/src/gestures/PressTracker.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {UIKIT_GESTURES} from '../../motion/uikit/gestures'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PRESS_PROFILES, PressTracker, type ControlEvent} from '../PressTracker'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {createHarness, scrollProbe} from './harness'; + +const BOUNDS = {left: 100, top: 100, right: 200, bottom: 140}; +const EXPANSION = UIKIT_GESTURES.control.trackingExpansion.value; +const DELAY = UIKIT_GESTURES.press.contentTouchDelay.value; +const FLASH = UIKIT_GESTURES.press.quickTapFlash.value; + +function setup() { + const events: ControlEvent[] = []; + const highlights: boolean[] = []; + const tracker = new PressTracker({ + getBounds: () => BOUNDS, + onControlEvent: event => events.push(event), + onHighlightChange: value => highlights.push(value), + }); + return {events, highlights, tracker, h: createHarness()}; +} + +describe('PressTracker', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('highlights in the same frame outside a scroller and acts on touch up inside', () => { + const {events, highlights, tracker, h} = setup(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + expect(highlights).toEqual([true]); + h.finger(1).move(152, 121, 16); + h.finger(1).up(80); + expect(events).toEqual(['touchDown', 'touchDragInside', 'touchUpInside']); + expect(highlights).toEqual([true, false]); + }); + + it('keeps tracking inside the expanded bounds and exits past them', () => { + const {events, highlights, tracker, h} = setup(); + const finger = h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + }); + finger.move(200 + EXPANSION, 120, 16); + expect(tracker.isTouchInside).toBe(true); + finger.move(200 + EXPANSION + 1, 120, 32); + expect(tracker.isTouchInside).toBe(false); + finger.move(150, 120, 48); + finger.up(64, 200 + EXPANSION + 10, 120); + expect(events).toEqual([ + 'touchDown', + 'touchDragInside', + 'touchDragExit', + 'touchDragOutside', + 'touchDragEnter', + 'touchDragInside', + 'touchUpOutside', + ]); + expect(highlights).toEqual([true, false, true, false]); + }); + + it('waits the content-touch delay inside a scroller, and flashes a quicker tap', () => { + const {highlights, tracker, h} = setup(); + const probe = scrollProbe(() => true, true); + h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + scroll: probe, + }); + expect(highlights).toEqual([]); + vi.advanceTimersByTime(DELAY - 1); + expect(highlights).toEqual([]); + vi.advanceTimersByTime(1); + expect(highlights).toEqual([true]); + h.finger(1).up(400); + expect(highlights).toEqual([true, false]); + + const quick = setup(); + quick.h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: quick.tracker, + scroll: probe, + }); + quick.h.finger(1).up(60); + expect(quick.highlights).toEqual([true]); + vi.advanceTimersByTime(FLASH); + expect(quick.highlights).toEqual([true, false]); + }); + + it('never paints a pending press whose finger travelled (a flick)', () => { + const {highlights, tracker, h} = setup(); + const probe = scrollProbe(() => true, true); + h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + scroll: probe, + }); + h.finger(1).move(150, 140, 20); + vi.advanceTimersByTime(DELAY * 2); + expect(highlights).toEqual([]); + }); + + it('is cancelled, with no release, when a recognizer takes the touch', () => { + const {events, highlights, tracker, h} = setup(); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1).down(150, 120, 0, {recognizers: [pan], consumer: tracker}); + h.finger(1).move(175, 120, 16); + expect(events).toEqual(['touchDown', 'touchCancel']); + expect(highlights).toEqual([true, false]); + }); + + it('is cancelled when the browser takes the touch to scroll', () => { + const {events, tracker, h} = setup(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + h.finger(1).takenByBrowser(30); + expect(events).toEqual(['touchDown', 'touchCancel']); + expect(tracker.isTracking).toBe(false); + }); + + it('tracks one finger at a time', () => { + const {events, tracker, h} = setup(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + h.finger(2).down(160, 120, 10, {recognizers: [], consumer: tracker}); + h.finger(2).up(20); + h.finger(1).up(30); + expect(events).toEqual(['touchDown', 'touchUpInside']); + }); +}); + +describe('PressTracker cell profile', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + function cell() { + const events: ControlEvent[] = []; + const highlights: boolean[] = []; + const tracker = new PressTracker({ + getBounds: () => BOUNDS, + ...PRESS_PROFILES.cell, + onControlEvent: event => events.push(event), + onHighlightChange: value => highlights.push(value), + }); + return {events, highlights, tracker, h: createHarness()}; + } + + it('highlights at once outside a scroller: the delay applies only inside one (AST-094 FR11)', () => { + const {highlights, tracker, h} = cell(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + expect(highlights).toEqual([true]); + vi.advanceTimersByTime(DELAY); + expect(highlights).toEqual([true]); + h.finger(1).up(DELAY + 40); + expect(highlights).toEqual([true, false]); + }); + + it('waits the content-touch delay inside a scroller', () => { + const {highlights, tracker, h} = cell(); + h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + scroll: scrollProbe(() => true, true), + }); + expect(highlights).toEqual([]); + vi.advanceTimersByTime(DELAY); + expect(highlights).toEqual([true]); + h.finger(1).up(DELAY + 40); + expect(highlights).toEqual([true, false]); + }); + + it('is cancelled for good once the finger travels past the pan hysteresis', () => { + const {events, highlights, tracker, h} = cell(); + const finger = h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + }); + vi.advanceTimersByTime(DELAY); + const limit = UIKIT_GESTURES.pan.hysteresis.value; + finger.move(150, 120 + limit, DELAY + 16); + expect(tracker.isTracking).toBe(true); + finger.move(150, 120 + limit + 1, DELAY + 32); + expect(tracker.isTracking).toBe(false); + // Back inside: no re-highlight, and the lift does nothing. + finger.move(150, 120, DELAY + 48); + finger.up(DELAY + 64); + expect(events).toEqual(['touchDown', 'touchDragInside', 'touchCancel']); + expect(highlights).toEqual([true, false]); + }); + + it('shows a quicker tap at the lift straight into the release, with no flash', () => { + const {events, highlights, tracker, h} = cell(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + h.finger(1).up(60); + expect(events).toEqual(['touchDown', 'touchUpInside']); + expect(highlights).toEqual([true, false]); + }); + + it('keeps the control profile on today defaults, with the button release clock', () => { + expect(PRESS_PROFILES.control.highlightDelay).toBe('inScrollers'); + expect(PRESS_PROFILES.control.quickTapFlash).toBe(FLASH); + expect(PRESS_PROFILES.cell.cancelTravel).toBe( + UIKIT_GESTURES.pan.hysteresis.value, + ); + }); +}); + +describe('PressTracker in the arena', () => { + it("lets a control veto an ancestor's single tap (UIControl's rule)", () => { + const h = createHarness(); + const button = new PressTracker({getBounds: () => null, view: 'button'}); + const tap = new TapGestureRecognizer({name: 'tap'}); + tap.view = 'row'; + const double = new TapGestureRecognizer({ + name: 'double', + numberOfTapsRequired: 2, + }); + double.view = 'row'; + h.finger(1).down(0, 0, 0, {recognizers: [tap, double], consumer: button}); + expect(h.finger(1).up(50)).toBe('defer'); + expect(h.log[0]).toBe('tap:failed@50'); + expect(double.state).toBe('possible'); + }); +}); diff --git a/packages/core/src/gestures/__tests__/RotationGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/RotationGestureRecognizer.test.ts new file mode 100644 index 0000000000000..4b472cdb52799 --- /dev/null +++ b/packages/core/src/gestures/__tests__/RotationGestureRecognizer.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file RotationGestureRecognizer.test.ts + * @input Synthetic touches with explicit timestamps through a GestureArena + * @output RotationGestureRecognizer's state machine: the hysteresis and unwrapping past half a turn + * @position Unit tests of packages/core/src/gestures/RotationGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {RotationGestureRecognizer} from '../RotationGestureRecognizer'; +import {createHarness} from './harness'; + +describe('RotationGestureRecognizer', () => { + it('begins after the hysteresis and unwraps past half a turn', () => { + const h = createHarness(); + const rotation = new RotationGestureRecognizer({name: 'rotation'}); + h.finger(1).down(0, 0, 0, {recognizers: [rotation]}); + h.finger(2).down(100, 0, 0, {recognizers: [rotation]}); + const at = (angle: number, time: number) => + h.finger(2).move(100 * Math.cos(angle), 100 * Math.sin(angle), time); + at((3 * Math.PI) / 180, 16); + expect(rotation.state).toBe('possible'); + at((6 * Math.PI) / 180, 32); + expect(rotation.state).toBe('began'); + let time = 32; + for (let degrees = 30; degrees <= 270; degrees += 30) { + time += 16; + at((degrees * Math.PI) / 180, time); + } + expect(rotation.rotation).toBeCloseTo((270 * Math.PI) / 180, 5); + expect(rotation.velocity).toBeGreaterThan(0); + }); + + it('setting rotation (UIKit\u2019s incremental read) never moves the velocity', () => { + const h = createHarness(); + const rotation = new RotationGestureRecognizer({name: 'rotation'}); + h.finger(1).down(0, 0, 0, {recognizers: [rotation]}); + h.finger(2).down(100, 0, 0, {recognizers: [rotation]}); + const at = (degrees: number, time: number) => + h + .finger(2) + .move( + 100 * Math.cos((degrees * Math.PI) / 180), + 100 * Math.sin((degrees * Math.PI) / 180), + time, + ); + let time = 0; + for (let degrees = 10; degrees <= 60; degrees += 10) { + time += 16; + at(degrees, time); + // Read and reset each frame, as an incremental consumer does. + rotation.rotation = 0; + } + expect(rotation.velocity).toBeGreaterThan(0); + }); + + it('lets the velocity settle when the angle holds while the fingers move', () => { + const h = createHarness(); + const rotation = new RotationGestureRecognizer({name: 'rotation'}); + h.finger(1).down(0, 0, 0, {recognizers: [rotation]}); + h.finger(2).down(100, 0, 0, {recognizers: [rotation]}); + let time = 0; + for (const degrees of [10, 20, 40, 60, 80]) { + time += 16; + const a = (degrees * Math.PI) / 180; + h.finger(2).move(100 * Math.cos(a), 100 * Math.sin(a), time); + } + // Straight up from the first finger: exactly a quarter turn. + time += 16; + h.finger(2).move(0, 100, time); + const turning = rotation.velocity; + expect(turning).toBeGreaterThan(0); + // The second finger slides outward along the same line: the angle holds + // exactly while the finger moves. + for (let y = 110; y <= 160; y += 10) { + time += 16; + h.finger(2).move(0, y, time); + } + expect(Math.abs(rotation.velocity)).toBeLessThan(turning * 0.25); + }); +}); diff --git a/packages/core/src/gestures/__tests__/ScreenEdgePanGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/ScreenEdgePanGestureRecognizer.test.ts new file mode 100644 index 0000000000000..0ca3efc69ccb5 --- /dev/null +++ b/packages/core/src/gestures/__tests__/ScreenEdgePanGestureRecognizer.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ScreenEdgePanGestureRecognizer.test.ts + * @input Synthetic touches with explicit timestamps through a GestureArena + * @output ScreenEdgePanGestureRecognizer's state machine (the edge band, RTL edges, inward movement) and its edge-zone touch-action + * @position Unit tests of packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {UIKIT_SCREEN_EDGE_PAN} from '../../motion/uikit/gestures'; +import {ALL_NATIVE_TOUCH} from '../GestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {createHarness} from './harness'; + +describe('ScreenEdgePanGestureRecognizer', () => { + it('begins from the leading band and fails elsewhere at touch-down', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.finger(1) + .down(10, 300, 0, {recognizers: [edge]}) + .drag(40, 4, 50); + expect(h.log[0]).toMatch(/^edge:began@/); + expect(edge.activeEdge).toBe('left'); + + const middle = createHarness(); + const miss = new ScreenEdgePanGestureRecognizer({name: 'miss'}); + middle.finger(1).down(60, 300, 0, {recognizers: [miss]}); + expect(middle.log).toEqual(['miss:failed@0']); + }); + + it('resolves the leading edge under RTL and requires inward movement', () => { + const h = createHarness(); + h.environment.rtl = true; + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.finger(1) + .down(385, 300, 0, {recognizers: [edge]}) + .drag(-40, 2, 50); + expect(edge.activeEdge).toBe('right'); + expect(h.log[0]).toMatch(/^edge:began@/); + + const outward = createHarness(); + const back = new ScreenEdgePanGestureRecognizer({name: 'back'}); + outward + .finger(1) + .down(10, 300, 0, {recognizers: [back]}) + .drag(-8, 30, 50); + expect(back.state).toBe('failed'); + }); + + it('a drag 5 pt inward and then down the edge is the page\u2019s scroll: it fails and claims nothing (spec:AST-094 FR19)', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.finger(1) + .down(10, 300, 0, {recognizers: [edge]}) + .drag(5, 0, 30) + .drag(0, 20, 50); + expect(edge.state).toBe('failed'); + expect(h.claims).toEqual([]); + }); + + it('leaves a view wider than the edge zone every touch behavior, so its scrolling is untouched (spec:AST-094 FR18)', () => { + expect(new ScreenEdgePanGestureRecognizer().nativeTouchBehavior).toEqual( + ALL_NATIVE_TOUCH, + ); + const page = new ScreenEdgePanGestureRecognizer(); + page.view = viewOfSize(390, 844); + expect(page.nativeTouchBehavior).toEqual(ALL_NATIVE_TOUCH); + // A pan from several edges too, off their zones. + expect( + new ScreenEdgePanGestureRecognizer({edges: ['left', 'right']}) + .nativeTouchBehavior, + ).toEqual(ALL_NATIVE_TOUCH); + }); + + it('takes the axis across its edge in the edge zone, a view no wider than the edge region (spec:AST-094 FR18)', () => { + const leading = new ScreenEdgePanGestureRecognizer(); + leading.view = viewOfSize(leading.edgeRegion, 844); + expect(leading.nativeTouchBehavior).toEqual({ + panX: false, + panY: true, + pinchZoom: true, + doubleTapZoom: false, + }); + const bottom = new ScreenEdgePanGestureRecognizer({edges: ['bottom']}); + bottom.view = viewOfSize(390, 12); + expect(bottom.nativeTouchBehavior).toEqual({ + panX: true, + panY: false, + pinchZoom: true, + doubleTapZoom: false, + }); + // One pixel wider than the band is not the edge zone. + const wide = new ScreenEdgePanGestureRecognizer(); + wide.view = viewOfSize(wide.edgeRegion + 1, 844); + expect(wide.nativeTouchBehavior).toEqual(ALL_NATIVE_TOUCH); + }); + + it('takes a strip authored at the edge region as the edge zone though layout lays it out at 40.203 px', () => { + const strip = new ScreenEdgePanGestureRecognizer(); + // 40.2 px snapped to layout's 1/64 px. + strip.view = viewOfSize(Math.ceil(strip.edgeRegion * 64) / 64, 844); + expect(strip.nativeTouchBehavior).toEqual({ + panX: false, + panY: true, + pinchZoom: true, + doubleTapZoom: false, + }); + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.environment.view = {width: 40.203, height: 844}; + const hysteresis = UIKIT_SCREEN_EDGE_PAN.hysteresis.value; + h.finger(1) + .down(10, 300, 0, {recognizers: [edge]}) + .drag(hysteresis - 1, 0, 30); + expect(edge.state).toBe('possible'); + h.finger(1).drag(2, 0, 50); + expect(h.log[0]).toMatch(/^edge:began@/); + }); + + it('in the edge zone, still decides at its hysteresis', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.environment.view = {width: edge.edgeRegion, height: 844}; + const hysteresis = UIKIT_SCREEN_EDGE_PAN.hysteresis.value; + h.finger(1) + .down(10, 300, 0, {recognizers: [edge]}) + .drag(hysteresis - 1, 0, 30); + expect(edge.state).toBe('possible'); + h.finger(1).drag(2, 0, 50); + expect(h.log[0]).toMatch(/^edge:began@/); + }); +}); + +describe('where an in-band touch decides (spec:AST-094 FR18, DEC-22)', () => { + it('on an element that reaches beyond the band, at 10 px, before the browser commits to a scroll', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + // A stack container: as wide as the screen, the touch 4 px from its edge. + h.environment.view = {width: 390, height: 844}; + h.finger(1) + .down(4, 300, 0, {recognizers: [edge]}) + .drag(9, 0, 30, 9); + expect(edge.state).toBe('possible'); + h.finger(1).drag(1, 0, 10, 1); + expect(h.log[0]).toMatch(/^edge:began@/); + }); + + it('on an element within the band, at 15 px', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.environment.view = {width: edge.edgeRegion, height: 844}; + const hysteresis = UIKIT_SCREEN_EDGE_PAN.hysteresis.value; + h.finger(1) + .down(4, 300, 0, {recognizers: [edge]}) + .drag(hysteresis - 1, 0, 30, 14); + expect(edge.state).toBe('possible'); + h.finger(1).drag(1, 0, 10, 1); + expect(h.log[0]).toMatch(/^edge:began@/); + }); + + it('from several edges, at 10 px on an element beyond their bands', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({ + name: 'edge', + edges: ['left', 'right'], + }); + h.environment.view = {width: 390, height: 844}; + h.finger(1) + .down(4, 300, 0, {recognizers: [edge]}) + .drag(9, 0, 30, 9); + expect(edge.state).toBe('possible'); + h.finger(1).drag(1, 0, 10, 1); + expect(h.log[0]).toMatch(/^edge:began@/); + }); +}); + +describe('a pan from several edges (spec:AST-094 FR18)', () => { + it('outside every edge zone leaves the browser every touch behavior', () => { + const edge = new ScreenEdgePanGestureRecognizer({ + edges: ['left', 'bottom'], + }); + edge.view = viewOfSize(390, 844); + expect(edge.nativeTouchBehavior).toEqual(ALL_NATIVE_TOUCH); + }); + + it('inside a side edge zone claims pan-x only', () => { + const edge = new ScreenEdgePanGestureRecognizer({ + edges: ['left', 'bottom'], + }); + edge.view = viewOfSize(edge.edgeRegion, 844); + expect(edge.nativeTouchBehavior).toEqual({ + panX: false, + panY: true, + pinchZoom: true, + doubleTapZoom: false, + }); + }); + + it('inside a bottom edge zone claims pan-y only', () => { + const edge = new ScreenEdgePanGestureRecognizer({ + edges: ['left', 'bottom'], + }); + edge.view = viewOfSize(390, edge.edgeRegion); + expect(edge.nativeTouchBehavior).toEqual({ + panX: true, + panY: false, + pinchZoom: true, + doubleTapZoom: false, + }); + }); +}); + +/** An element whose box has `width` × `height`, as layout would give it. */ +function viewOfSize(width: number, height: number): HTMLElement { + const view = document.createElement('div'); + view.getBoundingClientRect = () => + ({width, height, top: 0, left: 0, right: width, bottom: height}) as DOMRect; + return view; +} diff --git a/packages/core/src/gestures/__tests__/SwipeGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/SwipeGestureRecognizer.test.ts new file mode 100644 index 0000000000000..d195d7d2e212e --- /dev/null +++ b/packages/core/src/gestures/__tests__/SwipeGestureRecognizer.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file SwipeGestureRecognizer.test.ts + * @input Synthetic touches with explicit timestamps through a GestureArena + * @output SwipeGestureRecognizer's state machine: recognition during the motion, and failure when too slow, off-axis or the wrong way + * @position Unit tests of packages/core/src/gestures/SwipeGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {SwipeGestureRecognizer} from '../SwipeGestureRecognizer'; +import {createHarness} from './harness'; + +describe('SwipeGestureRecognizer', () => { + it('is recognized during the motion, once the distance is covered', () => { + const h = createHarness(); + const swipe = new SwipeGestureRecognizer({name: 'swipe'}); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [swipe]}); + finger.move(30, 2, 30); + expect(swipe.state).toBe('possible'); + finger.move(50, 4, 50); + expect(swipe.recognizedDirection).toBe('right'); + expect(h.log[0]).toBe('swipe:ended@50'); + }); + + it('fails when too slow, off-axis, or the wrong way', () => { + const slow = createHarness(); + const a = new SwipeGestureRecognizer({name: 'a'}); + slow + .finger(1) + .down(0, 0, 0, {recognizers: [a]}) + .move(30, 0, 400); + slow.advance(500); + expect(a.state).toBe('failed'); + + const diagonal = createHarness(); + const b = new SwipeGestureRecognizer({name: 'b'}); + diagonal + .finger(1) + .down(0, 0, 0, {recognizers: [b]}) + .move(45, 55, 60); + // 55 px across is past the 50 px secondary allowance. + expect(b.state).toBe('failed'); + + const wrong = createHarness(); + const c = new SwipeGestureRecognizer({name: 'c', direction: 'left'}); + wrong + .finger(1) + .down(100, 0, 0, {recognizers: [c]}) + .move(160, 0, 60); + expect(c.state).toBe('failed'); + }); + + it('takes several directions and fingers', () => { + const h = createHarness(); + const swipe = new SwipeGestureRecognizer({ + name: 'swipe', + direction: ['up', 'down'], + numberOfTouchesRequired: 2, + }); + h.finger(1).down(0, 200, 0, {recognizers: [swipe]}); + h.finger(2).down(40, 200, 5, {recognizers: [swipe]}); + h.finger(1).move(0, 140, 50); + h.finger(2).move(40, 140, 50); + expect(swipe.recognizedDirection).toBe('up'); + }); + + it('gives the same verdict for the same path however its events are spaced', () => { + // A straight drag to (55, 40): along 55 past the 50 px distance, 40 px + // across within the 50 px allowance. + const sparse = createHarness(); + const a = new SwipeGestureRecognizer({name: 'a'}); + sparse + .finger(1) + .down(0, 0, 0, {recognizers: [a]}) + .move(55, 40, 40); + expect(a.state).toBe('ended'); + + const dense = createHarness(); + const b = new SwipeGestureRecognizer({name: 'b'}); + const finger = dense.finger(1).down(0, 0, 0, {recognizers: [b]}); + for (let step = 1; step <= 10; step += 1) { + finger.move(5.5 * step, 4 * step, 4 * step); + } + expect(b.state).toBe('ended'); + expect(b.recognizedDirection).toBe('right'); + }); + + it('recognizes a 40\u00b0 swipe at 16 px per event as at small steps', () => { + // At 40\u00b0 the travel along reaches 50 px with 42 px across, inside + // the 50 px allowance; at 16 px per event the next event is already 51 px + // across, so the verdict must come from the path between the events. + const angle = (40 * Math.PI) / 180; + for (const step of [2, 16]) { + const h = createHarness(); + const swipe = new SwipeGestureRecognizer({name: 'swipe'}); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [swipe]}); + for (let d = step, t = 4; d <= 96; d += step, t += 4) { + finger.move(d * Math.cos(angle), d * Math.sin(angle), t); + if (swipe.state !== 'possible') { + break; + } + } + expect(swipe.state).toBe('ended'); + expect(swipe.recognizedDirection).toBe('right'); + } + }); +}); diff --git a/packages/core/src/gestures/__tests__/arena.test.ts b/packages/core/src/gestures/__tests__/arena.test.ts index 8cc06fc5fe270..c9ee41bee59bb 100644 --- a/packages/core/src/gestures/__tests__/arena.test.ts +++ b/packages/core/src/gestures/__tests__/arena.test.ts @@ -75,6 +75,11 @@ class Scripted extends GestureRecognizer { this.setState('failed'); } + /** Cancel itself, as a recognizer that gives up mid-gesture does. */ + cancelSelf(): void { + this.setState('cancelled'); + } + /** Recognize at `deadline` on the event clock, unless the touch lifts. */ recognizeAt(deadline: number): void { this.setTimer(deadline, () => this.recognize()); @@ -517,6 +522,36 @@ describe('arena: claims', () => { first.up(80); second.up(80); }); + + it('keeps a moving finger claimed until it lifts when its gesture cancels itself on that move, so the drag never turns into the page scroll midway (FR14)', () => { + const h = createHarness(); + // A long press's shape: it begins while the finger rests, then cancels + // itself on the first move past its tolerance. + const press = new Scripted('press', { + began: (self, event) => self.recognizeAt(event.time + 500), + moved: (self, event) => { + const touch = event.allTouches[0]; + if ( + touch != null && + Math.abs(touch.y - touch.startY) > 10 && + isActive(self.state) + ) { + self.cancelSelf(); + } + }, + }); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [press]}); + h.advance(600); + expect(press.state).toBe('began'); + expect(h.arena.isClaimed(1)).toBe(true); + finger.move(0, -11, 616); + expect(press.state).toBe('cancelled'); + expect(h.arena.isClaimed(1)).toBe(true); + finger.move(0, -60, 632); + expect(h.arena.isClaimed(1)).toBe(true); + finger.up(700); + expect(h.arena.isClaimed(1)).toBe(false); + }); }); /** Records the touches that began off its hit path. */ diff --git a/packages/core/src/gestures/__tests__/arenaPinchSwipeEdge.test.ts b/packages/core/src/gestures/__tests__/arenaPinchSwipeEdge.test.ts new file mode 100644 index 0000000000000..54829fb2aeac3 --- /dev/null +++ b/packages/core/src/gestures/__tests__/arenaPinchSwipeEdge.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file arenaPinchSwipeEdge.test.ts + * @input GestureArena with pinches, swipes, edge pans and pans, driven by + * synthetic touches with explicit timestamps + * @output Simultaneous recognition (pinch + pan), the first to recognize + * winning without permission, edge pan vs inner pan, swipe vs pan, and a + * delayed touch delivery + * @position Unit tests of packages/core/src/gestures/GestureArena.ts + */ + +import {describe, expect, it} from 'vitest'; +import {UIKIT_PINCH, UIKIT_SWIPE} from '../../motion/uikit/gestures'; +import {GestureRecognizer} from '../GestureRecognizer'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../PinchGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {SwipeGestureRecognizer} from '../SwipeGestureRecognizer'; +import {createHarness, RecordingConsumer} from './harness'; + +/** + * A recognizer that stays possible, even past its finger's lift, until the + * test fails it: something another recognizer can wait on. + */ +class Held extends GestureRecognizer { + private isWaiting = true; + + override isInvolved(): boolean { + return super.isInvolved() || this.isWaiting; + } + + fail(): void { + this.isWaiting = false; + this.setState('failed'); + } +} + +describe('conflict matrix', () => { + it('pinch + pan recognize simultaneously when allowed', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + const pan = new PanGestureRecognizer({name: 'pan'}); + pinch.recognizeSimultaneouslyWith(pan); + h.finger(1).down(100, 100, 0, {recognizers: [pinch, pan]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch, pan]}); + h.finger(1).move(80, 120, 16); + h.finger(2).move(240, 120, 16); + expect(h.log.slice(0, 2)).toEqual(['pinch:began@16', 'pan:began@16']); + // Span 100 -> 160, measured against 100 + 8. + expect(pinch.scale).toBeCloseTo(160 / 108, 5); + // The pan began at the first finger's move, centroid (-10, 10), which + // the hysteresis swallows; the second finger's move brought it to (10, 20). + expect(pan.translation()).toEqual({x: 20, y: 10}); + }); + + it('pinch vs pan without permission: the first to recognize wins', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1).down(100, 100, 0, {recognizers: [pinch, pan]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch, pan]}); + h.finger(1).move(80, 120, 16); + h.finger(2).move(240, 120, 16); + expect(h.log.slice(0, 2)).toEqual(['pinch:began@16', 'pan:failed@16']); + }); + + it('edge pan vs inner pan: in the band the edge pan wins, elsewhere the inner pan runs undelayed', () => { + const band = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + const inner = new PanGestureRecognizer({ + name: 'inner', + direction: 'horizontal', + }); + inner.requireToFail(edge); + // A thin edge view: the edge zone, where the pan takes its axis. + band.environment.view = {width: edge.edgeRegion, height: 844}; + band.finger(1).down(8, 300, 0, {recognizers: [inner, edge]}); + band.finger(1).drag(60, 2, 60, 6); + // The edge pan's own hysteresis is 15 px: the second 10 px step. + expect(band.log.slice(0, 2)).toEqual(['edge:began@20', 'inner:failed@20']); + + const middle = createHarness(); + const edge2 = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + const inner2 = new PanGestureRecognizer({ + name: 'inner', + direction: 'horizontal', + }); + inner2.requireToFail(edge2); + middle.finger(1).down(150, 300, 0, {recognizers: [inner2, edge2]}); + // Failed at touch-down, before any movement: nothing waits on it. + expect(middle.log).toEqual(['edge:failed@0']); + middle.finger(1).drag(60, 2, 60, 6); + expect(middle.log[1]).toBe('inner:began@10'); + }); + + it('swipe vs pan: the pan wins by default, the swipe when the pan requires it to fail', () => { + const plain = createHarness(); + const swipe = new SwipeGestureRecognizer({name: 'swipe'}); + const pan = new PanGestureRecognizer({name: 'pan'}); + plain + .finger(1) + .down(0, 0, 0, {recognizers: [swipe, pan]}) + .drag(80, 0, 60, 6); + expect(plain.log.slice(0, 2)).toEqual(['pan:began@10', 'swipe:failed@10']); + + const ordered = createHarness(); + const swipe2 = new SwipeGestureRecognizer({name: 'swipe'}); + const pan2 = new PanGestureRecognizer({name: 'pan'}); + pan2.requireToFail(swipe2); + ordered + .finger(1) + .down(0, 0, 0, {recognizers: [swipe2, pan2]}) + .drag(80, 0, 60, 8); + expect(ordered.log.slice(0, 2)).toEqual([ + 'swipe:ended@37.5', + 'pan:failed@37.5', + ]); + + const slow = createHarness(); + const swipe3 = new SwipeGestureRecognizer({name: 'swipe'}); + const pan3 = new PanGestureRecognizer({name: 'pan'}); + pan3.requireToFail(swipe3); + slow + .finger(1) + .down(0, 0, 0, {recognizers: [swipe3, pan3]}) + .drag(30, 0, 300, 3); + expect(pan3.state).toBe('possible'); + slow.advance(500); + // The swipe ran out of time; the waiting pan begins with what it has. + expect(slow.log.slice(0, 2)).toEqual(['swipe:failed@500', 'pan:began@500']); + }); +}); + +describe('a recognizer waiting on another', () => { + it('a swipe holds its recognition past its lift and its deadline (spec:AST-094 FR15)', () => { + const h = createHarness(); + const held = new Held({name: 'held'}); + const swipe = new SwipeGestureRecognizer({name: 'swipe'}); + swipe.requireToFail(held); + h.finger(1) + .down(0, 0, 0, {recognizers: [swipe, held]}) + .drag(80, 0, 60, 8) + .up(70); + h.advance(UIKIT_SWIPE.maximumDuration.value + 100); + expect(swipe.state).toBe('possible'); + expect(h.log.some(entry => entry.startsWith('swipe:failed'))).toBe(false); + held.fail(); + expect(h.log).toContain( + `swipe:ended@${UIKIT_SWIPE.maximumDuration.value + 100}`, + ); + expect(swipe.recognizedDirection).toBe('right'); + }); + + it('a pinch keeps its start once it passes the hysteresis, so it begins at the scale its fingers show (spec:AST-094 FR15)', () => { + const h = createHarness(); + const held = new Held({name: 'held'}); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + pinch.requireToFail(held); + h.finger(1).down(100, 100, 0, {recognizers: [pinch, held]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch, held]}); + // Span 100 -> 120 -> 140 -> 160 while the pinch waits. + h.finger(2).move(220, 100, 16); + h.finger(2).move(240, 100, 32); + h.finger(2).move(260, 100, 48); + expect(pinch.state).toBe('possible'); + held.fail(); + expect(h.log).toContain('pinch:began@48'); + // Measured against 100 + 8 once, not re-based on every move. + const hysteresis = UIKIT_PINCH.hysteresis.value; + expect(pinch.scale).toBeCloseTo(160 / (100 + hysteresis), 5); + }); +}); + +describe('delivery to the view', () => { + it('holds the began with delaysTouchesBegan until the recognizer fails', () => { + const h = createHarness(); + const consumer = new RecordingConsumer(); + const swipe = new SwipeGestureRecognizer({ + name: 'swipe', + delaysTouchesBegan: true, + }); + h.finger(1).down(0, 0, 0, {recognizers: [swipe], consumer}); + expect(consumer.events).toEqual([]); + h.advance(UIKIT_SWIPE.maximumDuration.value); + expect(consumer.events).toEqual(['began:1']); + }); +}); diff --git a/packages/core/src/gestures/__tests__/browser/build-fixture.mjs b/packages/core/src/gestures/__tests__/browser/build-fixture.mjs new file mode 100644 index 0000000000000..0d1be1180c45d --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/build-fixture.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file build-fixture.mjs + * @input fixture.ts beside this file + * @output A standalone HTML page (the fixture bundled inline) for running the + * gesture proofs in any browser, including Mobile Safari on the iOS + * simulator + * @position Test-only helper of packages/core/src/gestures + * + * node packages/core/src/gestures/__tests__/browser/build-fixture.mjs out.html + * + * The page records every recognizer transition and every click on + * `window.__gestures`; read it with the Web Inspector or by evaluating + * `JSON.stringify(window.__gestures)`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const out = process.argv[2]; +if (out == null) { + console.error('usage: build-fixture.mjs '); + process.exit(1); +} +const result = await build({ + entryPoints: [path.join(here, 'fixture.ts')], + bundle: true, + write: false, + format: 'iife', + platform: 'browser', + target: 'es2022', +}); +const html = ` + + +Gesture fixture + + +`; +fs.writeFileSync(out, html); +console.log(`wrote ${out} (${html.length} bytes)`); diff --git a/packages/core/src/gestures/__tests__/browser/fixture.ts b/packages/core/src/gestures/__tests__/browser/fixture.ts new file mode 100644 index 0000000000000..fd8f1447d17cd --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/fixture.ts @@ -0,0 +1,173 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file fixture.ts + * @input Nothing: bundled by esbuild and injected into a blank page + * @output A page of gesture hosts (a horizontal pan inside a scroller, a tap + * target, a long press, a pinch, a sheet body that hands its scroll to a + * pan, a passive control: a press consumer under `pan-y` with no + * recognizer) that records every recognizer transition, every consumer + * event and every click on `window.__gestures` + * @position Browser fixture of gestures.chromium.spec.ts; test-only + */ + +import {getGestureHost} from '../../GestureHost'; +import type {GestureRecognizer} from '../../GestureRecognizer'; +import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../../PinchGestureRecognizer'; +import {TapGestureRecognizer} from '../../TapGestureRecognizer'; + +interface Entry { + name: string; + state: string; + time: number; + x?: number; + y?: number; + scale?: number; + beganAt?: number | null; + startedAt?: number; +} + +interface ListenerEntry { + id: string; + type: string; + passive: boolean | null; +} + +declare global { + interface Window { + __gestures: Entry[]; + __listeners: ListenerEntry[]; + } +} + +window.__gestures = []; + +// Every touch listener added to an element with an id, as the engine sees +// it: which elements make a scroll wait for the page. +window.__listeners = []; +const addListener = EventTarget.prototype.addEventListener; +EventTarget.prototype.addEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, +) { + if (this instanceof Element && this.id !== '' && type.startsWith('touch')) { + window.__listeners.push({ + id: this.id, + type, + passive: typeof options === 'object' ? (options.passive ?? null) : null, + }); + } + addListener.call(this, type, listener, options); +}; + +document.body.style.margin = '0'; +document.body.innerHTML = ` +
+
+
scroller
+
+ +
+
content
+
+
tap
+
press
+
pinch
+
+
sheet content
+
+
passive control
+
card (swipe up)
+
page
+
`; + +function element(id: string): HTMLElement { + const found = document.getElementById(id); + if (found == null) { + throw new Error(`missing #${id}`); + } + return found; +} + +const host = getGestureHost(document); + +function record(recognizer: GestureRecognizer, extra: () => Partial) { + recognizer.addTransitionListener((current, _from, to) => { + window.__gestures.push({ + name: current.name, + state: to, + time: current.arena?.now() ?? performance.now(), + ...(to === 'possible' || to === 'failed' ? {} : extra()), + }); + }); +} + +const hpan = new PanGestureRecognizer({name: 'hpan', direction: 'horizontal'}); +record(hpan, () => ({x: hpan.translation().x, y: hpan.translation().y})); +host.attach(element('hpan'), hpan); + +const tap = new TapGestureRecognizer({name: 'tap'}); +record(tap, () => ({})); +host.attach(element('tapper'), tap); + +const press = new LongPressGestureRecognizer({name: 'press'}); +record(press, () => ({ + beganAt: press.beganAt, + startedAt: press.trackedTouches[0]?.startTime, +})); +host.attach(element('presser'), press); + +const pinch = new PinchGestureRecognizer({name: 'pinch'}); +const pinchPan = new PanGestureRecognizer({ + name: 'pinchPan', + maximumNumberOfTouches: 2, +}); +pinch.recognizeSimultaneouslyWith(pinchPan); +record(pinch, () => ({scale: pinch.scale})); +record(pinchPan, () => ({x: pinchPan.translation().x})); +host.attach(element('pinchable'), pinch); +host.attach(element('pinchable'), pinchPan); + +const sheet = new PanGestureRecognizer({ + name: 'sheet', + direction: 'down', + scrollEdge: true, +}); +record(sheet, () => ({y: sheet.translation().y})); +host.attach(element('sheetBody'), sheet); + +// A one-way pan over the scrollable page, as a toast's upward swipe is: it +// claims its touch as it begins, at UIKit's 10 pt (spec:AST-094 FR19). +const card = new PanGestureRecognizer({name: 'card', direction: 'up'}); +record(card, () => ({x: card.translation().x, y: card.translation().y})); +host.attach(element('card'), card); + +// A control with no recognizer of its own (no blocking listener): only a +// press consumer, under pan-y, as a passive switch is. +const passiveEvent = (state: string) => + window.__gestures.push({name: 'passive', state, time: performance.now()}); +host.attachConsumer(element('passive'), { + touchBegan: () => passiveEvent('began'), + touchMoved: () => passiveEvent('moved'), + touchEnded: () => passiveEvent('ended'), + touchCancelled: () => passiveEvent('cancelled'), +}); + +for (const type of ['click', 'mousedown']) { + document.addEventListener( + type, + event => { + const target = event.target as HTMLElement | null; + window.__gestures.push({ + name: `${type}:${target?.id ?? ''}`, + state: 'dom', + time: event.timeStamp, + }); + }, + true, + ); +} diff --git a/packages/core/src/gestures/__tests__/browser/gestures.browser.spec.ts b/packages/core/src/gestures/__tests__/browser/gestures.browser.spec.ts new file mode 100644 index 0000000000000..9ba44c17f7d7d --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/gestures.browser.spec.ts @@ -0,0 +1,597 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file gestures.browser.spec.ts + * @input The gesture fixture bundled with esbuild, a phone-sized touch + * screen, and real engine touch input: Chromium through CDP with explicit + * timestamps, WebKit through its own input command (real-time pacing) + * @output Browser proof of the negotiation the unit tests cannot see: + * touch-action per element, touchmove cancelled exactly while a recognizer + * owns the finger, native scrolling untouched otherwise, click suppression + * with no ghost click, a drag that began as a scroll staying the browser's, pinch-zoom + * left to the browser, a second finger that never breaks the first, and + * timing taken from event timestamps under a blocked main thread + * @position Browser tests of packages/core/src/gestures (see + * playwright.config.ts beside this file). WebKit's Linux build cannot + * scroll, fling or pinch-zoom from injected touches, and synthesises a + * click only from its tap gesture; the checks that need those run in + * Chromium here and in Mobile Safari on the iOS simulator. + */ + +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; +import {expect, test, type Page} from '@playwright/test'; + +interface Entry { + name: string; + state: string; + time: number; + x?: number; + y?: number; + scale?: number; + beganAt?: number | null; + startedAt?: number; +} + +let bundle = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [ + path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixture.ts'), + ], + bundle: true, + write: false, + format: 'iife', + platform: 'browser', + target: 'es2022', + }); + bundle = result.outputFiles[0].text; +}); + +type Point = {x: number; y: number}; +type Finger = Point & {id: number}; + +/** The fingers down after a step, and when (ms after the gesture's start). */ +interface Step { + t: number; + down: Finger[]; +} + +interface Driver { + readonly engine: 'chromium' | 'webkit'; + /** Plays the steps as real touch input; returns the page time of t = 0. */ + play(steps: Step[]): Promise; + /** A tap through the engine's tap path (WebKit synthesises click only from it). */ + tap(at: Point): Promise; +} + +const sleep = async (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)); + +/** Each step's change against the previous one: who landed, lifted, moved. */ +function diff(previous: Finger[], next: Finger[]) { + const before = new Set(previous.map(finger => finger.id)); + const after = new Set(next.map(finger => finger.id)); + return { + landed: next.filter(finger => !before.has(finger.id)), + lifted: previous.filter(finger => !after.has(finger.id)), + }; +} + +async function chromiumDriver(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + const origin = await page.evaluate(() => performance.timeOrigin); + // The fingers down, across calls: a gesture may span several. + let down: Finger[] = []; + const send = async (type: string, points: Finger[], at: number) => + cdp.send('Input.dispatchTouchEvent', { + type: type as 'touchStart', + touchPoints: points.map(point => ({ + x: point.x, + y: point.y, + id: point.id, + })), + timestamp: (origin + at) / 1000, + }); + return { + engine: 'chromium', + async play(steps) { + // CDP stamps every event with the scripted time, so the page sees the + // script's timing exactly, whatever the dispatch latency. + const base = Math.ceil(await page.evaluate(() => performance.now())) + 50; + for (const step of steps) { + const {landed} = diff(down, step.down); + // CDP diffs active points against the previous event: a new point + // is pressed, a missing one released; touchEnd carries none. + const type = + landed.length > 0 + ? 'touchStart' + : step.down.length === 0 + ? 'touchEnd' + : 'touchMove'; + await send(type, step.down, base + step.t); + down = step.down; + } + return base; + }, + async tap(at) { + const base = Math.ceil(await page.evaluate(() => performance.now())) + 50; + await send('touchStart', [{...at, id: 1}], base); + await send('touchEnd', [], base + 60); + }, + }; +} + +interface WebKitSession { + send(method: string, params?: Record): Promise; +} + +/** Playwright's server-side WebKit page session (a locally launched browser). */ +function webkitSession(page: Page): WebKitSession { + const connection = ( + page as unknown as {_connection?: {toImpl?: (o: unknown) => unknown}} + )._connection; + const impl = connection?.toImpl?.(page) as + {delegate?: {_pageProxySession?: WebKitSession}} | undefined; + const session = impl?.delegate?._pageProxySession; + if (session == null) { + throw new Error('WebKit touch input needs a locally launched browser'); + } + return session; +} + +function webkitDriver(page: Page): Driver { + const session = webkitSession(page); + // WebKit's command applies the event's phase to every point it lists: a + // start lists only the fingers that land, an end only those that lift, a + // move every finger down. Integer CSS px; the page sees real time. + const points = (fingers: Finger[]) => + fingers.map(f => ({x: Math.round(f.x), y: Math.round(f.y), id: f.id})); + // The fingers down, across calls: a gesture may span several. + let down: Finger[] = []; + return { + engine: 'webkit', + async play(steps) { + const start = Date.now(); + const base = await page.evaluate(() => performance.now()); + for (const step of steps) { + const wait = start + step.t - Date.now(); + if (wait > 0) { + await sleep(wait); + } + const {landed, lifted} = diff(down, step.down); + if (landed.length > 0) { + await session.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: points(landed), + }); + } else if (lifted.length > 0) { + await session.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: points(lifted), + }); + } else { + await session.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: points(step.down), + }); + } + down = step.down; + } + return base; + }, + async tap(at) { + await page.touchscreen.tap(Math.round(at.x), Math.round(at.y)); + }, + }; +} + +/** One finger from `from` to `to` in `count` frames of 16 ms, then a lift. */ +function drag(from: Point, to: Point, count = 12, lift = true): Step[] { + const steps: Step[] = [{t: 0, down: [{...from, id: 1}]}]; + for (let i = 1; i <= count; i++) { + const k = i / count; + steps.push({ + t: i * 16, + down: [ + { + x: from.x + (to.x - from.x) * k, + y: from.y + (to.y - from.y) * k, + id: 1, + }, + ], + }); + } + if (lift) { + steps.push({t: (count + 1) * 16, down: []}); + } + return steps; +} + +async function open(page: Page, engine: string): Promise { + // A real navigation first: touch emulation exposes the Touch Events API + // to documents it creates. + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bundle}); + return engine === 'webkit' ? webkitDriver(page) : chromiumDriver(page); +} + +async function center(page: Page, id: string): Promise { + const box = await page.locator(`#${id}`).boundingBox(); + if (box == null) { + throw new Error(`#${id} has no box`); + } + return { + x: Math.round(box.x + box.width / 2), + y: Math.round(box.y + box.height / 2), + }; +} + +async function log(page: Page): Promise { + await page.waitForTimeout(50); + return page.evaluate(() => window.__gestures); +} + +async function clearLog(page: Page): Promise { + await page.evaluate(() => { + window.__gestures.length = 0; + }); +} + +async function scrollTop(page: Page, id: string): Promise { + return page.evaluate( + target => document.getElementById(target)?.scrollTop ?? -1, + id, + ); +} + +/** Record whether each touchmove reached the page cancelled. */ +async function logTouchMoves(page: Page): Promise { + await page.evaluate(() => { + const moves: boolean[] = []; + (window as unknown as {__moves: boolean[]}).__moves = moves; + window.addEventListener( + 'touchmove', + event => moves.push(event.defaultPrevented), + {passive: true}, + ); + }); +} + +async function readTouchMoves(page: Page): Promise { + return page.evaluate( + () => (window as unknown as {__moves: boolean[]}).__moves, + ); +} + +const states = (entries: Entry[], name: string) => + entries.filter(entry => entry.name === name).map(entry => entry.state); + +test('derives touch-action per element from its recognizers', async ({ + page, + browserName, +}) => { + await open(page, browserName); + const actions = await page.evaluate(() => + Object.fromEntries( + ['hpan', 'tapper', 'presser', 'pinchable', 'sheetBody'].map(id => [ + id, + getComputedStyle(document.getElementById(id) as HTMLElement) + .touchAction, + ]), + ), + ); + expect(actions).toEqual({ + hpan: 'pan-y pinch-zoom', + tapper: 'manipulation', + presser: 'manipulation', + pinchable: 'none', + sheetBody: 'auto', + }); +}); + +test('only elements whose recognizers claim a moving touch carry a blocking touchmove listener', async ({ + page, + browserName, +}) => { + await open(page, browserName); + const blocking = await page.evaluate(() => + window.__listeners + .filter(entry => entry.type === 'touchmove' && entry.passive === false) + .map(entry => entry.id), + ); + // Pans (the cards' too), a long press, a pinch and the sheet's pan can take + // a moving touch; a tap-only element and a press consumer never wait. + expect(new Set(blocking)).toEqual( + new Set(['hpan', 'presser', 'pinchable', 'sheetBody', 'card']), + ); +}); + +test('a horizontal drag on a pan-y element is the recognizer’s: cancelled moves, no scroll, no click', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await logTouchMoves(page); + const start = await center(page, 'hbutton'); + // A 4:1 drag, the shape WebKit would otherwise pan natively. + await driver.play(drag(start, {x: start.x + 120, y: start.y + 30})); + const entries = await log(page); + const hpan = states(entries, 'hpan'); + expect(hpan[0]).toBe('began'); + expect(hpan.slice(-2)).toEqual(['ended', 'possible']); + const last = entries + .filter(entry => entry.name === 'hpan' && entry.state === 'changed') + .at(-1); + // As in UIKit, the 10 px hysteresis is not part of the translation. + expect(last?.x).toBeCloseTo(120 - 10, 0); + // Every touchmove after the claim was cancelled. + const moves = await readTouchMoves(page); + expect(moves.length).toBeGreaterThan(0); + expect(moves.slice(-3).every(Boolean)).toBe(true); + expect(await scrollTop(page, 'scroller')).toBe(0); + expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false); + expect(entries.some(entry => entry.name.startsWith('mousedown:'))).toBe( + false, + ); +}); + +test('a vertical drag on the same element fails the pan and is never cancelled', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await logTouchMoves(page); + const start = await center(page, 'hpan'); + await driver.play(drag(start, {x: start.x + 4, y: start.y - 120})); + const entries = await log(page); + expect(states(entries, 'hpan')).toEqual(['failed', 'possible']); + expect((await readTouchMoves(page)).some(Boolean)).toBe(false); + if (driver.engine === 'chromium') { + // The browser scrolled it natively. + expect(await scrollTop(page, 'scroller')).toBeGreaterThan(60); + } + expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false); +}); + +test('a horizontal drag on a passive pan-y control keeps its press: uncancelable moves the browser cannot pan are not a claim', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await page.evaluate(() => { + document.getElementById('passive')?.scrollIntoView({block: 'center'}); + }); + const start = await center(page, 'passive'); + await driver.play( + drag({x: start.x - 120, y: start.y}, {x: start.x + 120, y: start.y + 6}), + ); + const consumed = (await log(page)) + .filter(entry => entry.name === 'passive') + .map(entry => entry.state); + expect(consumed[0]).toBe('began'); + expect(consumed).not.toContain('cancelled'); + expect(consumed.at(-1)).toBe('ended'); +}); + +test('a one-way overlay pan keeps its direction from the page, and leaves the other way to it', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await page.evaluate(() => { + document.getElementById('card')?.scrollIntoView({block: 'center'}); + }); + const before = await page.evaluate(() => window.scrollY); + const start = await center(page, 'card'); + // Up, slowly: the card's pan begins at 10 px and the page never moves. + await driver.play(drag(start, {x: start.x, y: start.y - 40}, 40)); + expect(await page.evaluate(() => window.scrollY)).toBe(before); + const up = (await log(page)).filter(entry => entry.name === 'card'); + expect(up[0]?.state).toBe('began'); + expect(up.filter(entry => entry.state === 'changed').at(-1)?.y).toBe(-30); + // Down: the page's. + await clearLog(page); + await driver.play(drag(start, {x: start.x, y: start.y + 60}, 30)); + expect(await page.evaluate(() => window.scrollY)).toBeLessThan(before - 20); + expect(states(await log(page), 'card')).toEqual(['failed', 'possible']); +}); + +test('a tap recognizer replaces the click: no ghost click, no compatibility mouse events', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await driver.tap(await center(page, 'tapper')); + let entries = await log(page); + expect(states(entries, 'tap')).toEqual(['ended', 'possible']); + expect(entries.filter(entry => entry.state === 'dom')).toEqual([]); + + // A tap the pan does not take keeps its click (exactly one). + await clearLog(page); + await driver.tap(await center(page, 'hbutton')); + entries = await log(page); + expect(entries.filter(entry => entry.name === 'click:hbutton')).toHaveLength( + 1, + ); +}); + +test('scroll edge: at the top the pull is the sheet’s at once', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const start = await center(page, 'sheetBody'); + await driver.play(drag(start, {x: start.x, y: start.y + 96})); + const entries = await log(page); + const sheet = entries.filter(entry => entry.name === 'sheet'); + expect(sheet[0]?.state).toBe('began'); + expect( + sheet.filter(entry => entry.state === 'changed').at(-1)?.y, + ).toBeCloseTo(96 - 10, 0); + expect(await scrollTop(page, 'sheetBody')).toBe(0); +}); + +test('a drag that began as a scroll stays the browser’s (AST-094 FR14): the content scrolls to its top and the sheet never begins', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await page.evaluate(() => { + (document.getElementById('sheetBody') as HTMLElement).scrollTop = 60; + }); + const start = await center(page, 'sheetBody'); + start.y -= 60; + await driver.play(drag(start, {x: start.x, y: start.y + 200}, 25)); + const entries = await log(page); + const sheet = entries.filter(entry => entry.name === 'sheet'); + // The content takes the whole drag: it scrolls to its top, and the travel + // past that point never becomes the sheet's. + expect(await scrollTop(page, 'sheetBody')).toBe(0); + expect(sheet.some(entry => entry.state === 'began')).toBe(false); +}); + +/** Two fingers that land one after the other and spread apart. */ +function spread(at: Point, from: number, by: number, drift = 0): Step[] { + const steps: Step[] = [ + {t: 0, down: [{x: at.x - from, y: at.y, id: 1}]}, + { + t: 30, + down: [ + {x: at.x - from, y: at.y, id: 1}, + {x: at.x + from, y: at.y, id: 2}, + ], + }, + ]; + for (let i = 1; i <= 12; i++) { + const half = from + (by * i) / 12; + steps.push({ + t: 30 + i * 16, + down: [ + {x: at.x - half + drift * i, y: at.y, id: 1}, + {x: at.x + half + drift * i, y: at.y, id: 2}, + ], + }); + } + steps.push({t: 30 + 13 * 16, down: []}); + return steps; +} + +test('pinch-zoom stays the browser’s on an element without a pinch recognizer', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native pinch-zoom from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await driver.play(spread(await center(page, 'tapper'), 20, 96)); + await page.waitForTimeout(400); + expect( + await page.evaluate(() => window.visualViewport?.scale ?? 1), + ).toBeGreaterThan(1.2); + expect(states(await log(page), 'tap')).not.toContain('ended'); +}); + +test('a pinch recognizer takes the two-finger gesture on its element only, alongside its pan', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await driver.play(spread(await center(page, 'pinchable'), 20, 40, 1)); + const entries = await log(page); + expect(states(entries, 'pinch')[0]).toBe('began'); + const scale = entries + .filter(entry => entry.name === 'pinch' && entry.state === 'changed') + .at(-1)?.scale; + // The span grew from 40 to 120; as in UIKit, the scale is measured from + // the starting span moved by the 8 px hysteresis. + expect(scale).toBeCloseTo(120 / (40 + 8), 1); + expect(states(entries, 'pinchPan')).toContain('began'); + expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(1); +}); + +test('a second finger never breaks a claimed one-finger pan', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const start = await center(page, 'hbutton'); + const other = await center(page, 'tapper'); + const steps: Step[] = [{t: 0, down: [{...start, id: 1}]}]; + for (let i = 1; i <= 4; i++) { + steps.push({t: i * 16, down: [{x: start.x + i * 10, y: start.y, id: 1}]}); + } + const first = {x: start.x + 40, y: start.y, id: 1}; + steps.push({t: 80, down: [first, {...other, id: 2}]}); + for (let i = 1; i <= 6; i++) { + steps.push({ + t: 80 + i * 16, + down: [ + {x: first.x + i * 5, y: first.y, id: 1}, + {x: other.x + i * 15, y: other.y + i * 15, id: 2}, + ], + }); + } + steps.push({ + t: 200, + down: [{x: other.x + 90, y: other.y + 90, id: 2}], + }); + steps.push({t: 216, down: []}); + await driver.play(steps); + const entries = await log(page); + const hpan = entries.filter(entry => entry.name === 'hpan'); + expect(hpan.map(entry => entry.state)).not.toContain('cancelled'); + expect(hpan.map(entry => entry.state)).toContain('ended'); + expect(hpan.filter(entry => entry.state === 'changed').at(-1)?.x).toBeCloseTo( + 70 - 10, + 0, + ); + expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(1); +}); + +test('a long press begins at touch-down + 500 ms on the event clock, even when the main thread was blocked', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const at = await center(page, 'presser'); + const base = await driver.play([{t: 0, down: [{...at, id: 1}]}]); + // Block the main thread well past the press duration. + await page.evaluate(() => { + const end = performance.now() + 800; + while (performance.now() < end) { + // busy + } + }); + await page.waitForTimeout(100); + await driver.play([{t: 0, down: []}]); + const entries = await log(page); + const began = entries.find( + entry => entry.name === 'press' && entry.state === 'began', + ); + if (driver.engine === 'chromium') { + // The touch carries the timestamp CDP gave it (to the browser's 0.1 ms + // timestamp precision). + expect(Math.abs((began?.startedAt ?? 0) - base)).toBeLessThan(0.5); + } + // The press began exactly 500 ms after the touch, though the timer that + // noticed it ran 300 ms late. + expect((began?.beganAt ?? 0) - (began?.startedAt ?? 0)).toBeCloseTo(500, 6); + expect(states(entries, 'press')).toEqual(['began', 'ended', 'possible']); + expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false); +}); diff --git a/packages/core/src/gestures/__tests__/browser/playwright.config.ts b/packages/core/src/gestures/__tests__/browser/playwright.config.ts new file mode 100644 index 0000000000000..9b2d5504d3f8c --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/playwright.config.ts @@ -0,0 +1,48 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file playwright.config.ts + * @input Uses @playwright/test + * @output The gesture system's browser proof on a phone-sized touch screen, + * in Chromium (touch input through CDP) and WebKit (its own input command) + * @position Self-contained config for packages/core/src/gestures; run with + * `pnpm exec playwright test --config packages/core/src/gestures/__tests__/browser/playwright.config.ts`. + * Where WebKit cannot launch on the host, run its project inside the + * official Playwright image (the repository mounted at the same path): + * `podman run --rm --network=host --ipc=host -v "$PWD:$PWD" -w "$PWD" + * mcr.microsoft.com/playwright:v1.61.1-noble node + * node_modules/@playwright/test/cli.js test --config --project webkit` + */ + +import os from 'node:os'; +import path from 'node:path'; +import {defineConfig, devices} from '@playwright/test'; + +const phone = { + viewport: {width: 390, height: 844}, + deviceScaleFactor: 3, + hasTouch: true, + isMobile: true, +} as const; + +export default defineConfig({ + testDir: '.', + // Traces and screenshots of failures stay out of the source tree. + outputDir: path.join(os.tmpdir(), 'astryx-gesture-proof'), + testMatch: ['*.browser.spec.ts'], + fullyParallel: false, + workers: 1, + timeout: 60_000, + reporter: [['list']], + projects: [ + { + name: 'chromium', + use: { + ...devices['Pixel 7'], + browserName: 'chromium', + ...phone, + }, + }, + {name: 'webkit', use: {browserName: 'webkit', ...phone}}, + ], +}); diff --git a/packages/core/src/gestures/__tests__/browserNegotiation.test.ts b/packages/core/src/gestures/__tests__/browserNegotiation.test.ts new file mode 100644 index 0000000000000..a3ad073383e5c --- /dev/null +++ b/packages/core/src/gestures/__tests__/browserNegotiation.test.ts @@ -0,0 +1,299 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file browserNegotiation.test.ts + * @input Recognizer sets, touch-action strings, and jsdom elements with + * scripted scroll metrics + * @output What the browser is told (touch-action and long-press styles) and + * what the native scroll probe answers (edges, touch-action, chaining, + * RTL) + * @position Unit tests of touchAction.ts and nativeScroll.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {createNativeScrollProbe, parseTouchAction} from '../nativeScroll'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../PinchGestureRecognizer'; +import {RotationGestureRecognizer} from '../RotationGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {SwipeGestureRecognizer} from '../SwipeGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import { + deriveTouchAction, + gestureHostStyle, + holdSelectionStyle, +} from '../touchAction'; +import {HoverGestureRecognizer} from '../HoverGestureRecognizer'; + +describe('deriveTouchAction', () => { + it('needs nothing without touch recognizers', () => { + expect(deriveTouchAction([])).toBeNull(); + expect(deriveTouchAction([new HoverGestureRecognizer()])).toBeNull(); + expect( + deriveTouchAction([ + new PanGestureRecognizer({allowedPointerTypes: ['mouse']}), + ]), + ).toBeNull(); + }); + + it('removes only double-tap zoom for taps and long presses', () => { + expect(deriveTouchAction([new TapGestureRecognizer()])).toBe( + 'manipulation', + ); + expect(deriveTouchAction([new LongPressGestureRecognizer()])).toBe( + 'manipulation', + ); + }); + + it('gives the browser the other axis of a one-axis pan or swipe, and keeps pinch-zoom', () => { + expect( + deriveTouchAction([new PanGestureRecognizer({direction: 'horizontal'})]), + ).toBe('pan-y pinch-zoom'); + expect( + deriveTouchAction([new PanGestureRecognizer({direction: 'vertical'})]), + ).toBe('pan-x pinch-zoom'); + expect(deriveTouchAction([new PanGestureRecognizer()])).toBe('pinch-zoom'); + expect( + deriveTouchAction([new SwipeGestureRecognizer({direction: 'left'})]), + ).toBe('pan-y pinch-zoom'); + }); + + it('leaves the browser everything for a one-way pan, or one that shares native scrolling', () => { + // The other direction keeps scrolling natively; the pan claims its own + // by cancelling touchmove when it begins. + expect( + deriveTouchAction([new PanGestureRecognizer({direction: 'down'})]), + ).toBeNull(); + // A screen-edge pan from one edge goes one way, inward (spec:AST-094 FR18). + expect( + deriveTouchAction([ + new ScreenEdgePanGestureRecognizer({edges: ['leading']}), + ]), + ).toBeNull(); + expect( + deriveTouchAction([ + new PanGestureRecognizer({direction: 'down', scrollEdge: true}), + ]), + ).toBeNull(); + expect( + deriveTouchAction([ + new PanGestureRecognizer({direction: 'down', scrollEdge: true}), + new TapGestureRecognizer(), + ]), + ).toBe('manipulation'); + }); + + it('takes pinch-zoom only for a pinch or rotation recognizer', () => { + expect(deriveTouchAction([new PinchGestureRecognizer()])).toBe( + 'pan-x pan-y', + ); + expect(deriveTouchAction([new RotationGestureRecognizer()])).toBe( + 'pan-x pan-y', + ); + expect( + deriveTouchAction([ + new PinchGestureRecognizer(), + new PanGestureRecognizer(), + ]), + ).toBe('none'); + }); + + it('intersects every recognizer on the element', () => { + expect( + deriveTouchAction([ + new TapGestureRecognizer(), + new PanGestureRecognizer({direction: 'horizontal'}), + ]), + ).toBe('pan-y pinch-zoom'); + }); + + it('locks selection only for a held press, never on the element (spec:AST-094 FR41)', () => { + // The attached style is the touch-action alone: a mouse and a keyboard + // keep selecting (spec:AST-017 FR59). + expect(gestureHostStyle([new LongPressGestureRecognizer()])).toEqual({ + touchAction: 'manipulation', + }); + // The host applies this for one press, from touch-down to the lift. + expect(holdSelectionStyle([new LongPressGestureRecognizer()])).toEqual({ + WebkitTouchCallout: 'none', + WebkitUserSelect: 'none', + userSelect: 'none', + }); + // A hold that does nothing selects natively. + expect(holdSelectionStyle([new TapGestureRecognizer()])).toBeNull(); + }); +}); + +describe('parseTouchAction', () => { + it('reads the pans each value allows', () => { + expect(parseTouchAction('auto')).toEqual({x: true, y: true}); + expect(parseTouchAction('manipulation')).toEqual({x: true, y: true}); + expect(parseTouchAction('none')).toEqual({x: false, y: false}); + expect(parseTouchAction('pan-y pinch-zoom')).toEqual({x: false, y: true}); + expect(parseTouchAction('pan-left')).toEqual({x: true, y: false}); + expect(parseTouchAction('pinch-zoom')).toEqual({x: false, y: false}); + }); +}); + +/** Give an element the scroll metrics jsdom cannot lay out. */ +function scroller( + element: HTMLElement, + metrics: { + scrollTop?: number; + scrollHeight?: number; + clientHeight?: number; + scrollLeft?: number; + scrollWidth?: number; + clientWidth?: number; + }, +): void { + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(element, key, {configurable: true, value}); + } +} + +/** + * jsdom drops `touch-action` from styles, so the elements under test carry + * it in `data-touch-action` and computed style reports it from there. + */ +function withTouchActionFromData(): () => void { + const original = window.getComputedStyle.bind(window); + const spy = vi + .spyOn(window, 'getComputedStyle') + .mockImplementation((element: Element, pseudo?: string | null) => { + const style = original(element, pseudo); + const touchAction = element.getAttribute('data-touch-action'); + if (touchAction == null) { + return style; + } + return new Proxy(style, { + get(target, key): string | number | object | null | undefined { + if (key === 'touchAction') { + return touchAction; + } + const value = Reflect.get(target, key) as + string | number | object | null | undefined; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }); + return () => spy.mockRestore(); +} + +describe('createNativeScrollProbe', () => { + let restore: () => void = () => {}; + beforeEach(() => { + restore = withTouchActionFromData(); + }); + afterEach(() => { + restore(); + document.body.innerHTML = ''; + document.documentElement.style.overflow = ''; + }); + + function setup(listStyle: string) { + // The page itself does not scroll in these cases. + document.documentElement.style.overflow = 'hidden'; + document.body.innerHTML = `
`; + const list = document.getElementById('list') as HTMLElement; + const row = document.getElementById('row') as HTMLElement; + return {list, row}; + } + + it('answers by edge: at the top a pull down scrolls nothing, a pull up does', () => { + const {list, row} = setup('overflow-y: auto'); + scroller(list, {scrollTop: 0, scrollHeight: 1000, clientHeight: 400}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(0, 20)).toBe(false); + expect(probe.canScroll(0, -20)).toBe(true); + expect(probe.isInsideScroller).toBe(true); + scroller(list, {scrollTop: 600}); + expect(probe.canScroll(0, -20)).toBe(false); + expect(probe.canScroll(0, 20)).toBe(true); + }); + + it('finds a scroller in a shadow tree around the slot a light-DOM row is slotted into', () => { + document.documentElement.style.overflow = 'hidden'; + document.body.innerHTML = + '
'; + const host = document.getElementById('host') as HTMLElement; + const row = document.getElementById('row') as HTMLElement; + const shadow = host.attachShadow({mode: 'open'}); + shadow.innerHTML = + '
'; + const list = shadow.getElementById('list') as HTMLElement; + scroller(list, {scrollTop: 0, scrollHeight: 1000, clientHeight: 400}); + const probe = createNativeScrollProbe(row); + expect(probe.isInsideScroller).toBe(true); + expect(probe.canScroll(0, -20)).toBe(true); + }); + + it('puts a control in a fixed bar outside the page it floats over (spec:AST-094 FR11)', () => { + document.body.innerHTML = + '
'; + const root = document.scrollingElement ?? document.documentElement; + scroller(root as HTMLElement, { + scrollTop: 0, + scrollHeight: 3000, + clientHeight: 800, + }); + const probe = createNativeScrollProbe(document.getElementById('row')); + // The page still scrolls under a drag that starts on the bar, but the bar + // is not in a scroll view, so its control gets no content-touch delay. + expect(probe.isInsideScroller).toBe(false); + expect(probe.canScroll(0, -20)).toBe(true); + for (const key of ['scrollTop', 'scrollHeight', 'clientHeight']) { + Reflect.deleteProperty(root, key); + } + }); + + it('honors touch-action between the touch and the scroller', () => { + const {list, row} = setup('overflow-y: auto'); + row.setAttribute('data-touch-action', 'pan-x'); + scroller(list, {scrollTop: 100, scrollHeight: 1000, clientHeight: 400}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(0, 20)).toBe(false); + expect(probe.allowsNativePan).toBe(true); + row.setAttribute('data-touch-action', 'none'); + expect(createNativeScrollProbe(row).allowsNativePan).toBe(false); + }); + + it('ignores the wobble of a mostly straight drag', () => { + const {list, row} = setup('overflow-x: auto; overflow-y: hidden'); + scroller(list, {scrollLeft: 50, scrollWidth: 1000, clientWidth: 300}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(2, 30)).toBe(false); + expect(probe.canScroll(30, 2)).toBe(true); + }); + + it('follows the RTL scrollLeft range', () => { + const {list, row} = setup( + 'overflow-x: auto; overflow-y: hidden; direction: rtl', + ); + // RTL starts at 0 and runs negative toward the inline end (the left). + scroller(list, {scrollLeft: 0, scrollWidth: 1000, clientWidth: 300}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(30, 0)).toBe(true); // reveals content on the left + expect(probe.canScroll(-30, 0)).toBe(false); + scroller(list, {scrollLeft: -700}); + expect(probe.canScroll(30, 0)).toBe(false); + }); + + it('stops the chain at overscroll-behavior: contain', () => { + document.documentElement.style.overflow = ''; + document.body.innerHTML = `
x
`; + const outer = document.getElementById('outer') as HTMLElement; + const inner = document.getElementById('inner') as HTMLElement; + scroller(outer, {scrollTop: 300, scrollHeight: 2000, clientHeight: 500}); + scroller(inner, {scrollTop: 0, scrollHeight: 800, clientHeight: 400}); + const probe = createNativeScrollProbe(document.getElementById('t')); + // The inner list is at its top and contains its overscroll: a pull + // down reaches nothing, though the outer one could scroll. + expect(probe.canScroll(0, 20)).toBe(false); + inner.style.overscrollBehavior = 'auto'; + expect( + createNativeScrollProbe(document.getElementById('t')).canScroll(0, 20), + ).toBe(true); + }); +}); diff --git a/packages/core/src/gestures/__tests__/haptics.test.ts b/packages/core/src/gestures/__tests__/haptics.test.ts new file mode 100644 index 0000000000000..fe2bba423c5af --- /dev/null +++ b/packages/core/src/gestures/__tests__/haptics.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file haptics.test.ts + * @input playHaptic, attachTapHaptic and the support probes, with + * navigator.vibrate stubbed or absent, in jsdom + * @output Vibration patterns per feedback kind where the platform vibrates, + * nothing where it does not; the hidden switch iOS Safari plays its tap + * haptic on: hidden from layout, touch and assistive technology, its click + * and change kept inside, removed on release + * @position Unit tests of packages/core/src/gestures/haptics.ts + */ + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import { + HAPTIC_FEEDBACK_KINDS, + HAPTIC_VIBRATION_PATTERNS, +} from '../../motion/uikit/gestures'; +import { + attachTapHaptic, + canVibrate, + HAPTIC_SWITCH_ATTRIBUTE, + playHaptic, + supportsSwitchControl, +} from '../haptics'; + +const original = Object.getOwnPropertyDescriptor(navigator, 'vibrate'); + +function stubVibrate(implementation: (pattern: number[]) => boolean) { + const vibrate = vi.fn(implementation); + Object.defineProperty(navigator, 'vibrate', { + configurable: true, + value: vibrate, + }); + return vibrate; +} + +afterEach(() => { + if (original == null) { + delete (navigator as {vibrate?: unknown}).vibrate; + } else { + Object.defineProperty(navigator, 'vibrate', original); + } + document.body.innerHTML = ''; +}); + +describe('playHaptic', () => { + it('vibrates each feedback kind with its own pattern', () => { + const vibrate = stubVibrate(() => true); + expect(canVibrate()).toBe(true); + for (const kind of HAPTIC_FEEDBACK_KINDS) { + expect(playHaptic(kind)).toBe(true); + expect(vibrate).toHaveBeenLastCalledWith([ + ...HAPTIC_VIBRATION_PATTERNS[kind].value, + ]); + } + }); + + it('plays nothing where the platform cannot vibrate, or refuses', () => { + delete (navigator as {vibrate?: unknown}).vibrate; + expect(canVibrate()).toBe(false); + expect(playHaptic('impactLight')).toBe(false); + stubVibrate(() => { + throw new Error('blocked'); + }); + expect(playHaptic('selection')).toBe(false); + }); +}); + +describe('attachTapHaptic', () => { + it('gives the label a hidden switch that keeps its own events inside', () => { + document.body.innerHTML = ''; + const label = document.getElementById('tap') as HTMLLabelElement; + const onClick = vi.fn(); + const onChange = vi.fn(); + label.addEventListener('click', onClick); + label.addEventListener('change', onChange); + + const release = attachTapHaptic(label); + const input = label.querySelector(`[${HAPTIC_SWITCH_ATTRIBUTE}]`); + expect(input).toBeInstanceOf(HTMLInputElement); + const sw = input as HTMLInputElement; + expect(sw.type).toBe('checkbox'); + expect(sw.hasAttribute('switch')).toBe(true); + expect(sw.getAttribute('aria-hidden')).toBe('true'); + expect(sw.tabIndex).toBe(-1); + // It never takes the touch itself, so a pan starting on it still + // scrolls, and it takes no room. + expect(sw.style.pointerEvents).toBe('none'); + expect(sw.style.position).toBe('absolute'); + + // The switch toggling (the label's activation) does not reach the + // control as a second click or a change. + sw.click(); + expect(onClick).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + + release(); + expect(label.querySelector(`[${HAPTIC_SWITCH_ATTRIBUTE}]`)).toBeNull(); + }); + + it('never leaves the focus on its hidden switch', () => { + document.body.innerHTML = + ''; + const before = document.getElementById('before') as HTMLButtonElement; + const label = document.getElementById('tap') as HTMLLabelElement; + const release = attachTapHaptic(label); + const sw = label.querySelector( + `[${HAPTIC_SWITCH_ATTRIBUTE}]`, + )!; + before.focus(); + // Chromium focuses a label's control on its activation. + sw.focus(); + expect(document.activeElement).not.toBe(sw); + expect(document.activeElement).toBe(before); + release(); + }); + + it('keeps a label\u2019s own control: the tap plays and the control still toggles', () => { + document.body.innerHTML = + ''; + const label = document.getElementById('tap') as HTMLLabelElement; + const own = document.getElementById('own') as HTMLInputElement; + const onOwnClick = vi.fn(); + own.addEventListener('click', onOwnClick); + const release = attachTapHaptic(label); + const sw = label.querySelector( + `[${HAPTIC_SWITCH_ATTRIBUTE}]`, + )!; + // The switch takes the label's activation, so a tap plays. + expect(label.control).toBe(sw); + // The checkbox keeps its name: the label's text, through aria-labelledby. + expect(own).toHaveAccessibleName('Remember me'); + sw.click(); + expect(sw.checked).toBe(true); + expect(own.checked).toBe(true); + expect(onOwnClick).toHaveBeenCalledTimes(1); + release(); + expect(label.control).toBe(own); + expect(own).not.toHaveAttribute('aria-labelledby'); + }); + + it('gives a disabled control no feedback and leaves no focus on the switch (FR23)', () => { + document.body.innerHTML = + '' + + ''; + const before = document.getElementById('before') as HTMLButtonElement; + const label = document.getElementById('tap') as HTMLLabelElement; + const own = document.getElementById('own') as HTMLInputElement; + const release = attachTapHaptic(label); + const sw = label.querySelector( + `[${HAPTIC_SWITCH_ATTRIBUTE}]`, + )!; + before.focus(); + sw.focus(); + sw.click(); + // The switch did not toggle (nothing plays), the control did not change, + // and focus is not stranded on the hidden switch. + expect(sw.checked).toBe(false); + expect(own.checked).toBe(false); + expect(document.activeElement).toBe(before); + release(); + }); + + it('leaves a label whose control is already a switch, or tied by for, as it is', () => { + document.body.innerHTML = + '' + + ''; + for (const id of ['a', 'b']) { + const label = document.getElementById(id) as HTMLLabelElement; + const release = attachTapHaptic(label); + expect(label.querySelector(`[${HAPTIC_SWITCH_ATTRIBUTE}]`)).toBeNull(); + release(); + } + }); + + it('reports whether the browser knows the switch control', () => { + // jsdom has no `switch` property on checkbox inputs, like browsers + // before Safari 17.4. + expect(supportsSwitchControl(document)).toBe(false); + }); +}); diff --git a/packages/core/src/gestures/__tests__/harness.ts b/packages/core/src/gestures/__tests__/harness.ts index a54549412f7e0..b3416fc5be785 100644 --- a/packages/core/src/gestures/__tests__/harness.ts +++ b/packages/core/src/gestures/__tests__/harness.ts @@ -4,8 +4,9 @@ * @file harness.ts * @input Nothing * @output Exports the synthetic-input harness the gesture tests share: a - * manual clock, an arena wired to a transition log, and fingers that land, - * move and lift at explicit timestamps + * manual clock, an arena wired to a transition log, fingers that land, + * move and lift at explicit timestamps, and a coarse primary pointer for + * tests that go through the document's host * @position Test-only helper of packages/core/src/gestures (excluded from the * build with the rest of __tests__) */ @@ -40,7 +41,13 @@ export interface Harness { claims: number[]; /** Deferred clicks as they resolved. */ deferredClicks: {id: number; allowed: boolean; time: number}[]; - environment: {width: number; height: number; rtl: boolean}; + environment: { + width: number; + height: number; + rtl: boolean; + /** The size every recognizer's view measures, when a test sets one. */ + view?: {width: number; height: number}; + }; finger(id: number): Finger; /** Advance the clock and run every timer due by `time`. */ advance(time: number): void; @@ -51,7 +58,11 @@ export function createHarness(): Harness { const log: string[] = []; const claims: number[] = []; const deferredClicks: Harness['deferredClicks'] = []; - const environment = {width: 390, height: 844, rtl: false}; + const environment: Harness['environment'] = { + width: 390, + height: 844, + rtl: false, + }; const fingers = new Map(); const env: GestureEnvironment = { viewportSize: () => ({ @@ -59,6 +70,7 @@ export function createHarness(): Harness { height: environment.height, }), isRtl: () => environment.rtl, + viewSize: () => environment.view ?? null, }; const arena: GestureArena = new GestureArena({ clock, @@ -202,3 +214,31 @@ export function scrollProbe( ): NativeScrollProbe { return {canScroll, isInsideScroller, allowsNativePan: true}; } + +/** + * Declare a phone's primary pointer for a test that goes through the + * document's host (`getGestureHost`): the host listens only where + * `(pointer: coarse)` matches (`spec:AST-094` FR19, FR24), and jsdom has no + * `matchMedia`. Returns the undo. + */ +export function coarsePrimaryPointer(): () => void { + const hadOwn = Object.prototype.hasOwnProperty.call(window, 'matchMedia'); + const original = window.matchMedia; + window.matchMedia = (query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }); + return () => { + if (hadOwn) { + window.matchMedia = original; + } else { + delete (window as {matchMedia?: unknown}).matchMedia; + } + }; +} diff --git a/packages/core/src/gestures/__tests__/useGestureRecognizer.test.tsx b/packages/core/src/gestures/__tests__/useGestureRecognizer.test.tsx new file mode 100644 index 0000000000000..67329f3ca5af7 --- /dev/null +++ b/packages/core/src/gestures/__tests__/useGestureRecognizer.test.tsx @@ -0,0 +1,414 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useGestureRecognizer.test.tsx + * @input React components using useGestureRecognizer / usePressTracker, and + * touch events dispatched on their elements + * @output The hooks keep one recognizer per component, attach before the + * first touch, run the latest listener without rendering per move, and + * detach on unmount + * @position jsdom tests of packages/core/src/gestures/useGestureRecognizer.ts + */ + +import {act, render} from '@testing-library/react'; +import {useRef} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {getGestureHost} from '../GestureHost'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import type {ControlEvent} from '../PressTracker'; +import {useGestureRecognizer, usePressTracker} from '../useGestureRecognizer'; +import {coarsePrimaryPointer} from './harness'; + +function touch( + type: string, + target: EventTarget, + contact: {id: number; x: number; y: number}, + time: number, + isLift = false, +): void { + 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); +} + +let renders = 0; + +function Draggable({ + onPan, + onRecognizer, + isEnabled = true, +}: { + onPan: (recognizer: PanGestureRecognizer) => void; + onRecognizer?: (recognizer: PanGestureRecognizer) => void; + isEnabled?: boolean; +}) { + renders += 1; + const ref = useRef(null); + const pan = useGestureRecognizer( + ref, + () => new PanGestureRecognizer({direction: 'horizontal'}), + onPan, + {isEnabled}, + ); + onRecognizer?.(pan); + return
; +} + +// A phone: the document's host listens only where the pointer is coarse. +let restorePointer: () => void = () => {}; +beforeEach(() => { + restorePointer = coarsePrimaryPointer(); +}); +afterEach(() => { + restorePointer(); +}); + +describe('useGestureRecognizer', () => { + afterEach(() => { + renders = 0; + }); + + it('drives the latest listener with no render per move', () => { + const seen: string[] = []; + const {getByTestId} = render( + { + seen.push(`${recognizer.state}:${recognizer.translation().x}`); + }} + />, + ); + const surface = getByTestId('surface'); + expect(renders).toBe(1); + touch('touchstart', surface, {id: 1, x: 0, y: 0}, 0); + for (let x = 6; x <= 36; x += 6) { + touch('touchmove', surface, {id: 1, x, y: 0}, x * 2); + } + touch('touchend', surface, {id: 1, x: 36, y: 0}, 80, true); + // The 10 px hysteresis is left out of the translation, as in UIKit. + expect(seen).toEqual([ + 'began:2', + 'changed:8', + 'changed:14', + 'changed:20', + 'changed:26', + 'ended:26', + ]); + expect(renders).toBe(1); + }); + + it('keeps one recognizer across renders and reads the newest listener', () => { + const identities = new Set(); + const first = vi.fn(); + const second = vi.fn(); + const {getByTestId, rerender} = render( + identities.add(r)} />, + ); + rerender( + identities.add(r)} />, + ); + expect(identities.size).toBe(1); + const surface = getByTestId('surface'); + touch('touchstart', surface, {id: 2, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 2, x: 30, y: 0}, 16); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + touch('touchend', surface, {id: 2, x: 30, y: 0}, 32, true); + }); + + it('cancels a gesture in flight when it is disabled, without calling the listener from the effect', () => { + const states: string[] = []; + // A listener of the recognizer's own, outside the hook, hears the cancel. + const heard: string[] = []; + let pan: PanGestureRecognizer | null = null; + const {getByTestId, rerender} = render( + states.push(r.state)} + onRecognizer={r => { + if (pan == null) { + pan = r; + r.addListener(current => heard.push(current.state)); + } + }} + />, + ); + const surface = getByTestId('surface'); + touch('touchstart', surface, {id: 3, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 3, x: 30, y: 0}, 16); + act(() => { + rerender( + states.push(r.state)} isEnabled={false} />, + ); + }); + // Disabling cancels the gesture; its attachment then goes (FR24), which + // leaves the recognizer at rest. The hook reports nothing from the effect. + expect(heard).toContain('cancelled'); + expect(pan!.state).not.toBe('changed'); + expect(states).toEqual(['began']); + touch('touchend', surface, {id: 3, x: 30, y: 0}, 32, true); + expect(states).toEqual(['began']); + }); + + it('follows the element when the node behind the ref is replaced', () => { + const onPan = vi.fn(); + function Swappable({tag}: {tag: 'div' | 'section'}) { + const ref = useRef(null); + useGestureRecognizer( + ref, + () => new PanGestureRecognizer({direction: 'horizontal'}), + onPan, + ); + const Tag = tag; + return ; + } + const {getByTestId, rerender} = render(); + const first = getByTestId('surface'); + rerender(); + const second = getByTestId('surface'); + expect(second).not.toBe(first); + touch('touchstart', first, {id: 6, x: 0, y: 0}, 0); + touch('touchmove', first, {id: 6, x: 30, y: 0}, 16); + touch('touchend', first, {id: 6, x: 30, y: 0}, 32, true); + expect(onPan).not.toHaveBeenCalled(); + touch('touchstart', second, {id: 7, x: 0, y: 0}, 100); + touch('touchmove', second, {id: 7, x: 30, y: 0}, 116); + expect(onPan).toHaveBeenCalled(); + touch('touchend', second, {id: 7, x: 30, y: 0}, 132, true); + }); + + it('reports cancelled to a handler that disables the recognizer itself, from that handler', () => { + const states: string[] = []; + let pan: PanGestureRecognizer | null = null; + const {getByTestId} = render( + states.push(r.state)} + onRecognizer={r => { + pan = r; + }} + />, + ); + const surface = getByTestId('surface'); + touch('touchstart', surface, {id: 6, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 6, x: 30, y: 0}, 16); + // A handler (here, the test) turns it off: UIKit's .cancelled, at once. + (pan as PanGestureRecognizer | null)?.setEnabled(false); + expect(states).toEqual(['began', 'cancelled']); + touch('touchend', surface, {id: 6, x: 30, y: 0}, 32, true); + }); + + it('attaches nothing while disabled: no touch-action, no gesture (FR24)', () => { + const states: string[] = []; + const {getByTestId, rerender} = render( + states.push(r.state)} />, + ); + const surface = getByTestId('surface'); + const remove = vi.spyOn(surface.style, 'removeProperty'); + act(() => { + rerender( + states.push(r.state)} isEnabled={false} />, + ); + }); + // Disabling detaches: the host gives the element its touch-action back. + expect(remove).toHaveBeenCalledWith('touch-action'); + touch('touchstart', surface, {id: 6, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 6, x: 30, y: 0}, 16); + touch('touchend', surface, {id: 6, x: 30, y: 0}, 32, true); + expect(states).toEqual([]); + act(() => { + rerender( states.push(r.state)} />); + }); + touch('touchstart', surface, {id: 8, x: 0, y: 0}, 100); + touch('touchmove', surface, {id: 8, x: 30, y: 0}, 116); + expect(states).toContain('began'); + touch('touchend', surface, {id: 8, x: 30, y: 0}, 132, true); + }); + + it('follows the element behind the ref when it is replaced (INV6)', () => { + const states: string[] = []; + function Swapped({id}: {id: string}) { + const ref = useRef(null); + useGestureRecognizer( + ref, + () => new PanGestureRecognizer({direction: 'horizontal'}), + r => states.push(r.state), + ); + return
; + } + const {getByTestId, rerender} = render(); + act(() => { + rerender(); + }); + const second = getByTestId('second'); + touch('touchstart', second, {id: 9, x: 0, y: 0}, 0); + touch('touchmove', second, {id: 9, x: 30, y: 0}, 16); + expect(states).toContain('began'); + touch('touchend', second, {id: 9, x: 30, y: 0}, 32, true); + }); + + it('detaches on unmount', () => { + const onPan = vi.fn(); + const {getByTestId, unmount} = render(); + const surface = getByTestId('surface'); + const remove = vi.spyOn(surface.style, 'removeProperty'); + unmount(); + expect(remove).toHaveBeenCalledWith('touch-action'); + touch('touchstart', surface, {id: 4, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 4, x: 30, y: 0}, 16); + expect(onPan).not.toHaveBeenCalled(); + }); +}); + +function Button({ + onEvent, + cancelTravel, + isBraking, + isDisabled = false, +}: { + onEvent: (event: ControlEvent) => void; + cancelTravel?: number; + isBraking?: () => boolean; + isDisabled?: boolean; +}) { + const ref = useRef(null); + usePressTracker(ref, { + onControlEvent: event => onEvent(event), + cancelTravel, + isBraking, + isEnabled: () => !isDisabled, + }); + return ( + + ); +} + +describe('usePressTracker', () => { + it('gives the host no listener while the control is disabled (FR24)', () => { + const add = vi.spyOn(document, 'addEventListener'); + const remove = vi.spyOn(document, 'removeEventListener'); + const {rerender, unmount} = render( + + ); + } + const {getByTestId, unmount} = render(); + touch('touchstart', getByTestId('held'), {id: 9, x: 1, y: 1}, 0); + const seenHighlights = [...highlights]; + const seenEvents = [...events]; + unmount(); + expect(highlights).toEqual(seenHighlights); + expect(events).toEqual(seenEvents); + }); + + it('passes isBraking through to the tracker', () => { + const events: ControlEvent[] = []; + const isBraking = vi.fn(() => true); + function BrakingButton() { + const ref = useRef(null); + usePressTracker(ref, { + onControlEvent: event => events.push(event), + isBraking, + }); + return ( + + ); + } + const {getByTestId} = render(); + const button = getByTestId('braking'); + touch('touchstart', button, {id: 8, x: 1, y: 1}, 0); + touch('touchend', button, {id: 8, x: 1, y: 1}, 50, true); + expect(isBraking).toHaveBeenCalled(); + expect(events).not.toContain('touchDown'); + }); + + it("uses the caller's scroll brake", () => { + const events: ControlEvent[] = []; + const {getByTestId} = render( +