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: 4 additions & 3 deletions packages/lab/src/NavigationStack/NavigationStack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,10 @@ describe('NavigationStack', () => {
render(<LargeHarness />);
const large = screen.getByRole('heading', {level: 1, name: 'Settings'});
// First in the screen's scrolling content.
expect(screenCard('Settings')?.firstElementChild?.firstElementChild).toBe(
large,
);
expect(
screenCard('Settings')?.firstElementChild?.firstElementChild
?.firstElementChild,
).toBe(large);
// The bar's copy of the title is hidden while the large one shows.
expect(screen.getAllByRole('heading', {name: 'Settings'})).toHaveLength(
1,
Expand Down
30 changes: 17 additions & 13 deletions packages/lab/src/NavigationStack/NavigationStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,9 @@ function CardLayer({
const entry = registry.get(model.key) ?? {};
entry.largeTitleElement = title;
registry.set(model.key, entry);
const scroller = title?.parentElement ?? null;
// The title is first in the scroll content, so a change of its height
// reaches the scroll area's measure. The scroller holds that content.
const scroller = title?.parentElement?.parentElement ?? null;
if (title == null || scroller == null) {
return undefined;
}
Expand Down Expand Up @@ -442,18 +444,20 @@ function CardLayer({
{...getViewportProps<HTMLDivElement>({
xstyle: [focusOutlineStyles.focusVisible, styles.scroller],
})}>
{isLarge ? (
<h1
ref={largeTitleCallback}
tabIndex={-1}
{...stylex.props(
focusOutlineStyles.focusVisible,
styles.largeTitle,
)}>
{props.title}
</h1>
) : null}
<div {...getContentProps<HTMLDivElement>({})}>{props.children}</div>
<div {...getContentProps<HTMLDivElement>({})}>
{isLarge ? (
<h1
ref={largeTitleCallback}
tabIndex={-1}
{...stylex.props(
focusOutlineStyles.focusVisible,
styles.largeTitle,
)}>
{props.title}
</h1>
) : null}
{props.children}
</div>
</div>
<div
ref={dim.ref}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,11 @@
* touch input (Chromium CDP / WebKit's input command) and the interaction
* harness
* @output Proofs that NavigationStack moves like UINavigationController on
* iOS 26.2. A push follows UIKit's push spring (critically damped, 0.3 s,
* starting at 8.773 widths/s) to within half a pixel. The card underneath
* moves at 0.3 of its speed. The back swipe tracks the finger 1:1 from the
* leading edge and from the content. It finishes or springs back by UIKit's
* release rule, and a flick finishes from near the edge. A push keeps
* advancing every frame through a blocked main thread and does no Layout
* or Paint. Reduce Motion cross-fades. The spec also writes storyboards of
* a push and of a released swipe.
* iOS 26.2. They cover the push spring, the parallax, both back swipes and
* their release, and a blocked main thread. They also cover Reduce Motion,
* and write storyboards of a push and of a released swipe.
* @position Interaction proof for lab NavigationStack (NavigationStack.tsx,
* navigationTransition.ts); shape copied from the harness exemplar,
* BottomSheetDetent.interaction.browser.spec.ts
* navigationTransition.ts)
*/

import {
Expand Down Expand Up @@ -1387,6 +1381,58 @@ test.describe('edge cases', () => {
await page.setViewportSize({width: 400, height: 200});
await expect.poll(contain).toBe('contain');
});

test('a large title that grows re-measures its screen', async ({page}) => {
await openStack(page, 'large-title');
const root = card(page, 'root');
const contain = async () =>
root.evaluate(
element =>
getComputedStyle(element.firstElementChild ?? element)
.overscrollBehaviorY,
);
// A viewport just taller than the screen's content: nothing to scroll.
const contentHeight = await root.evaluate(
element => (element.firstElementChild as HTMLElement).scrollHeight,
);
await page.setViewportSize({width: 400, height: contentHeight + 40});
await expect.poll(contain).toBe('auto');
// A font load, the resize and the stack's release of its motion hints
// each start a measurement of their own. Let them end first, so that only
// the title can start the next one.
await page.evaluate(async () => {
await document.fonts.ready;
});
await expect
.poll(() =>
page.evaluate(
() => document.querySelector('[style*="will-change"]') == null,
),
)
.toBe(true);
await page.evaluate(
() =>
new Promise<void>(resolve => {
let frames = 5;
const tick = () => {
frames -= 1;
if (frames === 0) {
resolve();
} else {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
}),
);
expect(await contain()).toBe('auto');
// A taller line makes the title taller, with no font to load. No box but
// the title's own changes size, and the screen now overflows.
await root
.locator('h1')
.evaluate(title => title.style.setProperty('line-height', '400px'));
await expect.poll(contain).toBe('contain');
});
});

test('a push does no Layout or Paint per frame', async ({
Expand Down
41 changes: 18 additions & 23 deletions packages/lab/src/NavigationStack/navigationTransition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,21 @@
* @file navigationTransition.ts
* @input The navigation table (navigationMotion.ts) and Core's rubber band
* (its internal touch entry)
* @output Pure math for NavigationStack's push/pop transition: the layers'
* values at a progress (`layersAt`) and their velocities for a progress
* velocity (`layerVelocities`). It also gives the interactive pop's release
* rule (`shouldFinishPop`), progress from a finger's travel
* (`progressFromTravel`) and the progress a caught screen resumes from
* (`progressFromScreen`).
* @output Pure math for NavigationStack's transition: layer values and
* velocities at a progress (`layersAt`, `layerVelocities`). Also the pop's
* release rule (`shouldFinishPop`) and progress from travel or a caught
* screen (`progressFromTravel`, `progressFromScreen`).
* @position Lab NavigationStack internals; no DOM, no React. The component
* hands these numbers to the compositor animator.
*
* One number describes a transition: progress `p`, the share of the width
* the top card has uncovered. `p = 0`: the top card covers the screen (a
* push has landed). `p = 1`: it has left and the card under it is back (a
* pop has landed). A push runs p 1 → 0 and a pop runs p 0 → 1. An
* interactive pop scrubs p with the finger (UIKit: `percentComplete` =
* translation / width).
*
* SYNC: When modified, update navigationTransition.test.ts.
*/

import {rubberBand, rubberBandInverse} from '@astryxdesign/core/internal/touch';

import {navigation} from './navigationMotion';

/**
* The layers, as UIKit moves them on iOS 26.2 (measured):
* - top card: x = p · W;
* - card under it: x = parallax · W · (1 − p), so from −0.3 W to 0;
Expand All @@ -29,14 +28,7 @@
*
* Every layer is linear in p, so one spring run on all of them with
* velocities scaled the same way keeps them on one trajectory.
*
* SYNC: When modified, update navigationTransition.test.ts.
*/

import {rubberBand, rubberBandInverse} from '@astryxdesign/core/internal/touch';

import {navigation} from './navigationMotion';

export interface TransitionLayers {
/** The top card's translateX, px. */
readonly topX: number;
Expand All @@ -49,9 +41,12 @@ export interface TransitionLayers {
const clamp01 = (value: number) => Math.min(1, Math.max(0, value));

/**
* The layers at progress `p` for a container `width` px wide. `direction`
* is the writing direction: under RTL cards enter from the left, so every x
* flips.
* The layers at progress `p` for a container `width` px wide. `p` is the
* share of the width the top card has uncovered: 0 when a push has landed,
* 1 when a pop has landed. A push runs p 1 → 0 and a pop runs p 0 → 1. An
* interactive pop scrubs p with the finger (UIKit's `percentComplete`).
* `direction` is the writing direction: under RTL cards enter from the left,
* so every x flips.
*/
export function layersAt(
p: number,
Expand Down
Loading