Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/uikit-pager.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: a pager for touch presentations that pages like a UIKit paging scroll view. A horizontal pan moves the pages one to one and rubber-bands at the ends. The release picks the page with UIKit's rule and settles on UIKit's paging curve on the compositor, and a finger can catch it mid-flight. It takes every pointer: a mouse drags it as a finger does, and its pan follows a trackpad's horizontal scroll, at most one page per swipe. A mouse wheel's scroll stays the page's. Its motion values are internal constants. Not exported from the package entry point.

@vjeux
14 changes: 6 additions & 8 deletions docs/specs/AST-094-touch-presentations/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,8 @@ a measured, sourced difference exists.
starts its settle from rest, as UIKit's does; a faster release hands its
velocity to the settle. The floor is a guess inside the measured bracket
(UIKit zeroes steady releases at 175 and 200 pt/s and keeps 249 pt/s, iOS
26.2 simulator, 2026-10-23) until a measurement replaces it (FR2). A
programmatic page change on a pager (a page control's tap, a keyboard step)
starts from rest: this is a known gap against this requirement until the
pager carries the current velocity into the page change (DEC-25).
26.2 simulator, 2026-10-23) until a measurement replaces it (FR2). A page
change during a drag starts from rest (DEC-25).
- **FR7 — Exits animate as the native counterpart's do.** A touch
presentation MUST play the exit its UIKit counterpart plays, on every touch
platform. FR8 governs their reduced-motion form. Each overlay's own record states its
Expand Down Expand Up @@ -750,7 +748,7 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and
| FR4 | Role unit tests over every touch motion | each role at its table value; a theme that writes `motion` | a literal played in place of a role, a value that differs from the table at `tempo` 1 and `'standard'`, or a theme that changes a role other than through its motion knobs fails |
| FR5 | Browser proof: an animation keeps advancing frame by frame through a 1 s main-thread block, with no layout or paint per frame | Chromium phone emulation; WebKit | a script-driven or layout-animating implementation freezes or lays out |
| FR6 | Unit and browser tests catching an animation mid-flight and retargeting it | catch at peak velocity; retarget with a new spring; a retarget before the browser reports the call's start | a layout read, a jump, or a lost velocity fails |
| FR6 pager | Unit and browser tests of a pager's settle start | releases just under and over 225 pt/s; a catch mid-settle; a programmatic page change mid-settle | a release under the floor whose settle starts moving, a release over it whose settle starts from rest, or a catch that loses the on-screen velocity fails; a programmatic page change from rest is the recorded gap |
| FR6 pager | Unit and browser tests of a pager's settle start | releases just under and over 225 pt/s; a catch mid-settle; a programmatic page change mid-settle | a release under the floor whose settle starts moving, a release over it whose settle starts from rest, a catch that loses the on-screen velocity, or a programmatic page change mid-settle that starts from rest fails; a page change during a drag starts from rest |
| FR7, FR8 | Component browser tests and storyboards with and without Reduce Motion | entrance, exit, move; a drag and its lift under Reduce Motion | a missing exit, any travel, scale or spring under Reduce Motion other than a coast after a throw and its settle at an edge (`design:reduced-motion` DR2), or an opacity fade over 200 ms fails |
| FR9, FR10 | Recognizer unit tests on synthetic streams and browser tests with real touch input | drag, second finger, pinch, page zoom, main-thread block | a second finger that breaks a drag, or a page pinch blocked other than by the author's `touch-action`, fails |
| FR11, FR12 | Press and long-press tests on synthetic streams with explicit timestamps | inside and outside a scroll view; a drag the browser takes; a lift inside; a menu row under a finger; a press on a row of a menu whose rows fit and of one that scrolls; a slide-off lift on a Switch and on a Radio | the wrong profile's timing, a press that survives the browser taking the touch, or a control outside an action group activated other than by the browser's click, outside `spec:AST-020` FR9's slide-off exception, a row of a menu whose rows fit that waits 150 ms, or one of a scrolling menu that does not, or a slide-off lift that completes a control outside `spec:AST-020` FR9's exception, fails |
Expand Down Expand Up @@ -1075,9 +1073,9 @@ of a page, not by a projection, and starts a slow release's settle from rest.
The owner tried this paging on the pager and the paging Carousel on
2026-11-01, with a trackpad and on a phone: "trackpad is good", "phone good".
This narrows FR3 and FR6 for paging only; every other release keeps them. A
programmatic page change starts from rest until the pager carries velocity
into it. A paging release decides by FR3's paging rule, and its settle starts
as FR6's paging paragraph says; FR37 applies unchanged.
page change during a drag starts from rest. A paging release decides by FR3's
paging rule, and its settle starts as FR6's paging paragraph says; FR37
applies unchanged.

Rejected: projecting a paging release with a deceleration rate; handing a
slow paging release's velocity to the settle.
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/hooks/__tests__/pagerBundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file pagerBundle.ts
* @input The pager and the paging math
* @output The surface the pager's browser proofs bundle onto their fixture
* page (esbuild, IIFE global `Pager`)
* @position Test-only entry of usePager.interaction.browser.spec.ts
*/

export {createPager, pageForRelease} from '../usePager';
export {
pagingSettleVelocity,
solveAnimatedPageScroll,
solvePaging,
} from '../../motion/paging';
export {rubberBand} from '../../motion/rubberBand';
export {paging} from '../../motion/uikit/paging';
export {VelocityTracker} from '../../gestures/VelocityTracker';
106 changes: 106 additions & 0 deletions packages/core/src/hooks/__tests__/useAttachmentPair.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file useAttachmentPair.test.tsx
* @input useAttachmentPair
* @output Proof that an attachment of two nodes starts once both are there
* and follows either node and its key. A commit that changes neither does
* nothing, and the attachment is released when a node goes and at unmount
* @position Testing for /packages/core/src/hooks/useAttachment.ts
*/

import {render} from '@testing-library/react';
import {createElement, useRef} from 'react';
import {describe, expect, it} from 'vitest';
import {useAttachmentPair} from '../useAttachment';

interface PairProps {
log: string[];
first?: string | null;
second?: string | null;
attachKey?: unknown;
}

/** Two nodes, each shown under an id that names it (a new id is a new node). */
function Pair({log, first = 'a', second = 'b', attachKey = 0}: PairProps) {
const firstRef = useRef<HTMLDivElement>(null);
const secondRef = useRef<HTMLSpanElement>(null);
useAttachmentPair(
firstRef,
secondRef,
(firstNode, secondNode) => {
const pair = `${firstNode.id}+${secondNode.id}`;
log.push(`attach ${pair}`);
return () => log.push(`release ${pair}`);
},
attachKey,
);
return createElement(
'div',
null,
first == null
? null
: createElement('div', {key: first, id: first, ref: firstRef}),
second == null
? null
: createElement('span', {key: second, id: second, ref: secondRef}),
);
}

describe('useAttachmentPair', () => {
it('attaches once both nodes are there, and not before', () => {
const log: string[] = [];
const view = render(createElement(Pair, {log, second: null}));
expect(log).toEqual([]);
view.rerender(createElement(Pair, {log}));
expect(log).toEqual(['attach a+b']);
view.unmount();
});

it('does nothing on a commit that changes neither node nor the key', () => {
const log: string[] = [];
const view = render(createElement(Pair, {log}));
view.rerender(createElement(Pair, {log}));
view.rerender(createElement(Pair, {log}));
expect(log).toEqual(['attach a+b']);
view.unmount();
});

it('releases the pair and attaches the new one when either node is replaced', () => {
const log: string[] = [];
const view = render(createElement(Pair, {log}));
view.rerender(createElement(Pair, {log, first: 'c'}));
view.rerender(createElement(Pair, {log, first: 'c', second: 'd'}));
expect(log).toEqual([
'attach a+b',
'release a+b',
'attach c+b',
'release c+b',
'attach c+d',
]);
view.unmount();
});

it('attaches again when the key changes', () => {
const log: string[] = [];
const view = render(createElement(Pair, {log, attachKey: 'mouse'}));
view.rerender(createElement(Pair, {log, attachKey: 'touch'}));
expect(log).toEqual(['attach a+b', 'release a+b', 'attach a+b']);
view.unmount();
});

it('releases when a node goes, and the last attachment at unmount', () => {
const log: string[] = [];
const view = render(createElement(Pair, {log}));
view.rerender(createElement(Pair, {log, first: null}));
expect(log).toEqual(['attach a+b', 'release a+b']);
view.rerender(createElement(Pair, {log, first: 'e'}));
view.unmount();
expect(log).toEqual([
'attach a+b',
'release a+b',
'attach e+b',
'release e+b',
]);
});
});
Loading
Loading