From c4a36b97019550a1f8a31dad08939913d52dac47 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 21:00:59 -0800 Subject: [PATCH 1/6] chore(lab): Lab's animator hook and keyboard listener, over Core's internal touch entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal entries land with their first user, per #8562. Lab's SearchField animates with Core's animator, solves UIKit's timing curves with Core's cubic-bézier solver, listens for taps with Core's gesture hook, reads the writing direction as Core does and claims focus moves as BottomSheet's keyboard hook does, so `@astryxdesign/core/internal/touch` re-exports, by name: - the animator's internal factory as `createMotionAnimator`, a name no public factory has (architecture:react-component-runtime INV13; spec:AST-107 FR6's public factory is `createAnimator`), with its types `MotionAnimator`, `MotionAnimatorOptions`, `AnimateOptions`, `MotionCurve` and `MotionHandle`; - `cubicBezier` and UIKit's `timing` table; - `useGestureRecognizer`, `useTouchPresentation` and `isRtlElement`; - `claimFocusMoves`, new in `utils/`: the capture-phase `blur` claim that BottomSheet's keyboard hook made inline, which that hook now calls. Its BottomSheet tests prove the move unchanged: with the claim's focus removed, four of them fail. Named re-exports of code already on main, plus that one extraction: no behavior changes, no docs, no root re-export, and the `@astryx/no-core-internal-import` lint rule already limits the entry to Core and Lab. The entry's test checks it re-exports the same values Core imports by path, and that it carries no public factory's name. Two Lab modules that other Lab components will share come with them: `internal/useMotionAnimator`, a stable animator bound to an element through a callback ref, and `keyboard/useKeyboardHeight`, Lab's listener for the software keyboard's height: spec:AST-099 FR2's measurement from the visual viewport, in every browser (DEC-4). --- .../core-internal-entries-lab-searchfield.md | 7 + .../core/src/BottomSheet/useMobileKeyboard.ts | 40 ++--- packages/core/src/internal/touch.test.ts | 23 +++ packages/core/src/internal/touch.ts | 19 ++- .../core/src/utils/claimFocusMoves.test.ts | 70 +++++++++ packages/core/src/utils/claimFocusMoves.ts | 35 +++++ .../src/internal/useMotionAnimator.test.tsx | 108 ++++++++++++++ .../lab/src/internal/useMotionAnimator.ts | 54 +++++++ .../src/keyboard/useKeyboardHeight.test.tsx | 138 ++++++++++++++++++ .../lab/src/keyboard/useKeyboardHeight.ts | 71 +++++++++ 10 files changed, 539 insertions(+), 26 deletions(-) create mode 100644 .changeset/core-internal-entries-lab-searchfield.md create mode 100644 packages/core/src/utils/claimFocusMoves.test.ts create mode 100644 packages/core/src/utils/claimFocusMoves.ts create mode 100644 packages/lab/src/internal/useMotionAnimator.test.tsx create mode 100644 packages/lab/src/internal/useMotionAnimator.ts create mode 100644 packages/lab/src/keyboard/useKeyboardHeight.test.tsx create mode 100644 packages/lab/src/keyboard/useKeyboardHeight.ts diff --git a/.changeset/core-internal-entries-lab-searchfield.md b/.changeset/core-internal-entries-lab-searchfield.md new file mode 100644 index 0000000000000..baa70963b8392 --- /dev/null +++ b/.changeset/core-internal-entries-lab-searchfield.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[experimental] Lab's experimental SearchField reaches Core's animator, cubic-bézier solver, UIKit timing table, gesture hook, touch-presentation hook, writing-direction read and focus-move claim through Core's internal `@astryxdesign/core/internal/touch` entry, which only Astryx's own packages may import and which stays outside the public API and its compatibility promise. Its close and clear buttons' names join Core's message catalog. + +@vjeux diff --git a/packages/core/src/BottomSheet/useMobileKeyboard.ts b/packages/core/src/BottomSheet/useMobileKeyboard.ts index b1e74ab665d1c..f1ddfa071f881 100644 --- a/packages/core/src/BottomSheet/useMobileKeyboard.ts +++ b/packages/core/src/BottomSheet/useMobileKeyboard.ts @@ -23,6 +23,8 @@ import {useEffect, useRef, type RefObject} from 'react'; +import {claimFocusMoves} from '../utils/claimFocusMoves'; + const MOBILE_KEYBOARD_INSET_VAR = '--_sheet-keyboard-inset'; const NON_TEXT_INPUT_TYPES = new Set([ 'button', @@ -258,27 +260,19 @@ export function useMobileKeyboard({ body.style.setProperty(MOBILE_KEYBOARD_INSET_VAR, `${inset}px`); }; - // Take the focus transition over before the browser performs it. - // - // `blur` is dispatched in the capture phase ahead of the browser's own - // focus step, and it names the destination in `relatedTarget`. Focusing it - // here with preventScroll settles the transition: the browser's step finds - // the element already active, so it dispatches nothing further and has + // Claim the focus move before the browser performs it (claimFocusMoves). + // The browser's step then finds the element already active, so it has // nothing to reveal. - const claimFocusTransition = (event: FocusEvent) => { + const pickFocusMove = (event: FocusEvent): HTMLElement | null => { if (!isFullyExpandedRef.current) { - return; + return null; } const destination = findTextEntryControl(event.relatedTarget, body); if (destination) { - if (destination !== document.activeElement) { - // Revealing the field is not this handler's job: focusing it raises - // focusin, and the viewport resize that follows the keyboard raises - // another — both already schedule the reveal below, which knows the - // safe area and scrolls only the sheet's own body. - destination.focus({preventScroll: true}); - } - return; + // This handler does not reveal the field. The focus raises focusin, + // and the keyboard's viewport resize raises another. Both schedule + // the reveal below, which scrolls only the sheet's body. + return destination; } // Focus left for nothing — the keyboard's Done button parks it on the @@ -294,6 +288,7 @@ export function useMobileKeyboard({ if (isOpenRef.current && origin && !event.relatedTarget) { sheet?.focus({preventScroll: true}); } + return null; }; const revealFocusedControl = () => { @@ -485,12 +480,9 @@ export function useMobileKeyboard({ }; const viewport = window.visualViewport; - if (ownsFocusTransitions) { - // `blur` does not bubble, so the capture phase is the only place to hear - // every one of them — and it runs before the browser's own focus step, - // which is the only window in which the transition can still be claimed. - document.addEventListener('blur', claimFocusTransition, true); - } + const stopClaimingFocusMoves = ownsFocusTransitions + ? claimFocusMoves(document, pickFocusMove) + : null; body.addEventListener('focusin', handleFocusIn); body.addEventListener('focusout', handleFocusOut); sheet?.addEventListener('transitionend', handleSheetTransitionEnd); @@ -511,9 +503,7 @@ export function useMobileKeyboard({ return () => { cancelAnimationFrame(animationFrame); - if (ownsFocusTransitions) { - document.removeEventListener('blur', claimFocusTransition, true); - } + stopClaimingFocusMoves?.(); body.removeEventListener('focusin', handleFocusIn); body.removeEventListener('focusout', handleFocusOut); sheet?.removeEventListener('transitionend', handleSheetTransitionEnd); diff --git a/packages/core/src/internal/touch.test.ts b/packages/core/src/internal/touch.test.ts index 2f175885dbf4e..83d362230472e 100644 --- a/packages/core/src/internal/touch.test.ts +++ b/packages/core/src/internal/touch.test.ts @@ -28,6 +28,29 @@ describe('internal/touch', () => { for (const name of ['TapGestureRecognizer', 'LongPressGestureRecognizer']) { expect(typeof entry[name], name).toBe('function'); } + const hook = await import('../gestures/useGestureRecognizer'); + const presentation = await import('../utils/touchPresentation'); + const direction = await import('../hooks/isRtlElement'); + const focusMoves = await import('../utils/claimFocusMoves'); + const animator = await import('../motion/animator'); + const bezier = await import('../motion/bezier'); + const uikitTiming = await import('../motion/uikit/timing'); + expect(entry.useGestureRecognizer).toBe(hook.useGestureRecognizer); + expect(entry.useTouchPresentation).toBe(presentation.useTouchPresentation); + expect(entry.isRtlElement).toBe(direction.isRtlElement); + expect(entry.claimFocusMoves).toBe(focusMoves.claimFocusMoves); + expect(entry.createMotionAnimator).toBe(animator.createAnimator); + expect(entry.cubicBezier).toBe(bezier.cubicBezier); + expect(entry.timing).toBe(uikitTiming.timing); + // No internal factory shares a public factory's name + // (architecture:react-component-runtime INV13, spec:AST-107 FR6). + for (const name of [ + 'createAnimator', + 'createPanGesture', + 'createDragToSnapPoints', + ]) { + expect(entry, name).not.toHaveProperty(name); + } // Named re-exports only: no gathering object a component would carry // whole (architecture:component-style-authoring INV6). expect(entry).not.toHaveProperty('touchModule'); diff --git a/packages/core/src/internal/touch.ts b/packages/core/src/internal/touch.ts index b1ff8a168bdb0..14a05de5ae9b8 100644 --- a/packages/core/src/internal/touch.ts +++ b/packages/core/src/internal/touch.ts @@ -2,7 +2,8 @@ /** * @file touch.ts - * @input The gesture core: the arena, the recognizers, and their geometry + * @input The gesture core and the motion, touch, focus and direction modules + * Lab uses * @output `@astryxdesign/core/internal/touch`: the gesture core's primitives * for Lab components, re-exported by name * @position An internal entry reserved for Astryx's own packages: @@ -39,3 +40,19 @@ export { export {LongPressGestureRecognizer} from '../gestures/LongPressGestureRecognizer'; export {TapGestureRecognizer} from '../gestures/TapGestureRecognizer'; export {DEFAULT_ENVIRONMENT} from '../gestures/types'; +export {useGestureRecognizer} from '../gestures/useGestureRecognizer'; +export {isRtlElement} from '../hooks/isRtlElement'; +// The animator's internal factory, under a name no public factory has: the +// public animator factory is `createAnimator`. +export {createAnimator as createMotionAnimator} from '../motion/animator'; +export type { + AnimateOptions, + Animator as MotionAnimator, + AnimatorOptions as MotionAnimatorOptions, + MotionCurve, + MotionHandle, +} from '../motion/animator'; +export {cubicBezier} from '../motion/bezier'; +export {timing} from '../motion/uikit/timing'; +export {claimFocusMoves} from '../utils/claimFocusMoves'; +export {useTouchPresentation} from '../utils/touchPresentation'; diff --git a/packages/core/src/utils/claimFocusMoves.test.ts b/packages/core/src/utils/claimFocusMoves.test.ts new file mode 100644 index 0000000000000..3f1b760beda60 --- /dev/null +++ b/packages/core/src/utils/claimFocusMoves.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file claimFocusMoves.test.ts + * @input Uses vitest and jsdom + * @output A move whose destination the pick returns is focused with + * preventScroll in its blur's capture phase, ahead of any page listener; + * a declined move, an already active destination and a move after the + * unsubscribe are left alone + * @position Unit tests of packages/core/src/utils/claimFocusMoves.ts + */ + +import {afterEach, expect, it, vi} from 'vitest'; + +import {claimFocusMoves} from './claimFocusMoves'; + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; +}); + +function fields(): [HTMLInputElement, HTMLInputElement] { + document.body.innerHTML = ''; + return [ + document.getElementById('from') as HTMLInputElement, + document.getElementById('to') as HTMLInputElement, + ]; +} + +/** The blur of a focus move from `from` to `to`. */ +function move(from: HTMLElement, to: HTMLElement | null): void { + from.dispatchEvent(new FocusEvent('blur', {relatedTarget: to})); +} + +it('focuses the destination the pick returns with preventScroll, in the capture phase ahead of page listeners', () => { + const [from, to] = fields(); + from.focus(); + const order: string[] = []; + const focus = vi + .spyOn(to, 'focus') + .mockImplementation(() => order.push('claim')); + from.addEventListener('blur', () => order.push('page')); + const stop = claimFocusMoves(document, event => + event.relatedTarget === to ? to : null, + ); + move(from, to); + expect(focus).toHaveBeenCalledWith({preventScroll: true}); + expect(order).toEqual(['claim', 'page']); + stop(); +}); + +it('leaves a move the pick declines, an already active destination and every move after the unsubscribe to the browser', () => { + const [from, to] = fields(); + to.focus(); + const pick = vi.fn((event: FocusEvent) => + event.relatedTarget === to ? to : null, + ); + const stop = claimFocusMoves(document, pick); + const focus = vi.spyOn(to, 'focus'); + // Declined: the move goes to `from`. + move(to, from); + // Already active: nothing to claim. + move(from, to); + expect(pick).toHaveBeenCalledTimes(2); + expect(focus).not.toHaveBeenCalled(); + stop(); + move(from, to); + expect(pick).toHaveBeenCalledTimes(2); + expect(focus).not.toHaveBeenCalled(); +}); diff --git a/packages/core/src/utils/claimFocusMoves.ts b/packages/core/src/utils/claimFocusMoves.ts new file mode 100644 index 0000000000000..05c5fc637e183 --- /dev/null +++ b/packages/core/src/utils/claimFocusMoves.ts @@ -0,0 +1,35 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file claimFocusMoves.ts + * @input A document, and a caller's pick of the focus moves it claims + * @output claimFocusMoves + * @position Shared by BottomSheet/useMobileKeyboard.ts and Lab's SearchField + * (through internal/touch.ts), which keep the browser from revealing a + * field by panning the page + * + * SYNC: When modified, update claimFocusMoves.test.ts. + */ + +/** + * Claims each focus move whose destination `pick` returns, before the + * browser's own focus step. The move's `blur` comes in the capture phase + * ahead of that step and names the destination in `relatedTarget`. Focusing + * the destination there with preventScroll leaves the step nothing to + * reveal. `pick` returns null to leave a move to the browser. Returns the + * unsubscribe. + */ +export function claimFocusMoves( + doc: Document, + pick: (event: FocusEvent) => HTMLElement | null, +): () => void { + const onBlur = (event: FocusEvent) => { + const destination = pick(event); + if (destination != null && destination !== doc.activeElement) { + destination.focus({preventScroll: true}); + } + }; + // `blur` does not bubble: only the capture phase hears every move. + doc.addEventListener('blur', onBlur, true); + return () => doc.removeEventListener('blur', onBlur, true); +} diff --git a/packages/lab/src/internal/useMotionAnimator.test.tsx b/packages/lab/src/internal/useMotionAnimator.test.tsx new file mode 100644 index 0000000000000..0dfe6a8f13c02 --- /dev/null +++ b/packages/lab/src/internal/useMotionAnimator.test.tsx @@ -0,0 +1,108 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useMotionAnimator.test.tsx + * @input vitest (jsdom), React Testing Library, the hook + * @output Checks that the animator is stable, attaches through the ref, and + * never re-renders its component while animating, retargeting or tracking + * @position Testing; validates useMotionAnimator.ts + */ + +import {act, render} from '@testing-library/react'; +import {useEffect} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type {MotionAnimator} from '@astryxdesign/core/internal/touch'; +import {useMotionAnimator} from './useMotionAnimator'; + +/** A critically damped half-second spring (SwiftUI's default). */ +const SETTLE = {duration: 0.5, bounce: 0} as const; +/** A stiff, heavy spring (UIKit's sheet presentation). */ +const SHEET = {mass: 3, stiffness: 1000, damping: 500} as const; + +beforeEach(() => { + HTMLElement.prototype.animate = function () { + return {startTime: null, cancel: vi.fn()} as unknown as Animation; + }; +}); + +afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); +}); + +/** A component that hands its animator out, for the unmount test. */ +function UnmountProbe({box}: {box: {current: MotionAnimator | null}}) { + const {ref, animator} = useMotionAnimator(); + box.current = animator; + return
; +} + +describe('useMotionAnimator', () => { + it('keeps one animator and ref, and never re-renders while animating', () => { + let renders = 0; + const seen = new Set(); + const refs = new Set(); + let api: MotionAnimator | null = null; + function Probe() { + renders++; + const {ref, animator} = useMotionAnimator(); + seen.add(animator); + refs.add(ref); + useEffect(() => { + api = animator; + }); + return
; + } + const {rerender, getByTestId} = render(); + const before = renders; + act(() => { + api?.animate({x: 100}, {spring: SETTLE}); + for (let x = 0; x < 50; x++) { + api?.set({y: x}); + } + api?.animate({x: 10}, {spring: SETTLE}); + api?.stop(); + }); + expect(renders).toBe(before); + expect(getByTestId('probe').style.translate).not.toBe(''); + rerender(); + expect(seen.size).toBe(1); + expect(refs.size).toBe(1); + }); + + it('attaches on mount and detaches on unmount', () => { + const box: {current: MotionAnimator | null} = {current: null}; + function Probe() { + const {ref, animator} = useMotionAnimator({initial: {opacity: 0.5}}); + box.current = animator; + return
; + } + const {getByTestId, unmount} = render(); + const element = getByTestId('probe'); + expect(box.current?.element).toBe(element); + expect(element.style.opacity).toBe('0.5'); + unmount(); + expect(box.current?.element).toBeNull(); + }); + + it('ends its callers waits on unmount and keeps no timer for them', async () => { + vi.useFakeTimers(); + try { + const box: {current: MotionAnimator | null} = {current: null}; + const {unmount} = render(); + const animator = box.current!; + const handle = animator.animate({x: 100}, {spring: SHEET}); + let rested = false; + void animator.atRest().then(() => { + rested = true; + }); + expect(vi.getTimerCount()).toBeGreaterThan(0); + unmount(); + // Without advancing time: the curve has not ended. + await expect(handle.finished).resolves.toBe(false); + expect(rested).toBe(true); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/lab/src/internal/useMotionAnimator.ts b/packages/lab/src/internal/useMotionAnimator.ts new file mode 100644 index 0000000000000..ef1586073fd32 --- /dev/null +++ b/packages/lab/src/internal/useMotionAnimator.ts @@ -0,0 +1,54 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useMotionAnimator.ts + * @input React and Core's internal animator (`createMotionAnimator`) + * @output useMotionAnimator: a stable animator and the callback ref that + * attaches it to an element + * @position Lab's React binding of Core's animator, for Lab components. Lab's + * entry point does not export it. + * + * SYNC: When modified, update useMotionAnimator.test.tsx + */ + +import {useState} from 'react'; +import { + createMotionAnimator, + type MotionAnimator, + type MotionAnimatorOptions, +} from '@astryxdesign/core/internal/touch'; + +export interface UseMotionAnimatorResult { + /** Callback ref for the animated element; stable. */ + readonly ref: (element: HTMLElement | null) => void; + /** The animator; stable for the component's lifetime. */ + readonly animator: MotionAnimator; +} + +/** + * A compositor animator bound to an element through a callback ref. The + * animator never sets state, so a move never re-renders the component. When + * the element unmounts, the ref detaches the animator: its moves stop and its + * values stay, so a remount resumes from them. `options` are read on the + * first render only. + * + * @example + * ``` + * const {ref, animator} = useMotionAnimator(); + * // `spring` is the motion role for this move, or the component's own + * // measured spring. + * const open = () => animator.animate({y: 0}, {spring}); + * return
; + * ``` + */ +export function useMotionAnimator( + options?: MotionAnimatorOptions, +): UseMotionAnimatorResult { + const [result] = useState(() => { + const animator = createMotionAnimator(null, options); + return {animator, ref: element => animator.attach(element)}; + }); + return result; +} diff --git a/packages/lab/src/keyboard/useKeyboardHeight.test.tsx b/packages/lab/src/keyboard/useKeyboardHeight.test.tsx new file mode 100644 index 0000000000000..8cae421bed055 --- /dev/null +++ b/packages/lab/src/keyboard/useKeyboardHeight.test.tsx @@ -0,0 +1,138 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useKeyboardHeight.test.tsx + * @input vitest, @testing-library/react, useKeyboardHeight + * @output jsdom coverage of lab's keyboard listener: FR2's measurement on a + * visual-viewport resize where the keyboard covers the page; nothing while + * disabled; where the browser also reports the keyboard itself (Chrome on + * Android), FR2's measurement there too, the browser's report ignored and + * the app's own overlay setting never changed (spec:AST-099 DEC-2, DEC-4) + * @position Testing; validates useKeyboardHeight.ts without a browser + */ + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {useRef} from 'react'; +import {act, render, screen} from '@testing-library/react'; +import { + keyboardCoversPage, + keyboardInsetOf, + useKeyboardHeight, +} from './useKeyboardHeight'; + +function Probe({enabled = true}: {enabled?: boolean}) { + const rootRef = useRef(null); + const height = useKeyboardHeight(enabled, rootRef); + return ( + <> +
+ + + +
+ + {height} + + ); +} + +const height = () => Number(screen.getByTestId('height').textContent); +const field = (name: string) => screen.getByRole('textbox', {name}); + +/** A visual viewport the test shrinks from the bottom, as Mobile Safari does. */ +function stubVisualViewport() { + const viewport = Object.assign(new EventTarget(), { + height: window.innerHeight, + scale: 1, + }); + vi.stubGlobal('visualViewport', viewport); + return (cover: number) => + act(() => { + viewport.height = window.innerHeight - cover; + viewport.dispatchEvent(new Event('resize')); + }); +} + +/** Chrome on Android's own keyboard report. */ +function stubVirtualKeyboard({overlaysContent = false} = {}) { + const keyboard = Object.assign(new EventTarget(), { + overlaysContent, + boundingRect: new DOMRect(0, 0, 0, 0), + }); + Object.defineProperty(navigator, 'virtualKeyboard', { + configurable: true, + value: keyboard, + }); + const report = (cover: number) => + act(() => { + keyboard.boundingRect = new DOMRect( + 0, + window.innerHeight - cover, + window.innerWidth, + cover, + ); + keyboard.dispatchEvent(new Event('geometrychange')); + }); + return {keyboard, report}; +} + +afterEach(() => { + Reflect.deleteProperty(navigator, 'virtualKeyboard'); + vi.unstubAllGlobals(); +}); + +describe('useKeyboardHeight', () => { + it('reads FR2’s height on each visual-viewport resize where the keyboard covers the page', () => { + const keyboard = stubVisualViewport(); + render(); + expect(height()).toBe(0); + keyboard(300); + expect(height()).toBe(300); + keyboard(0); + expect(height()).toBe(0); + }); + + it('reads nothing while disabled', () => { + const keyboard = stubVisualViewport(); + render(); + keyboard(300); + expect(height()).toBe(0); + }); + + describe('where the browser also reports the keyboard itself (Chrome on Android)', () => { + it('reads FR2’s height from the visual viewport there too, and never takes over the app’s keyboard setting (spec:AST-099 DEC-2, DEC-4)', () => { + const {keyboard, report} = stubVirtualKeyboard(); + const resize = stubVisualViewport(); + render(); + act(() => field('Query').focus()); + expect(keyboard.overlaysContent).toBe(false); + resize(300); + expect(height()).toBe(300); + // The browser's own report is not the measure. + report(200); + expect(height()).toBe(300); + act(() => field('Query').blur()); + resize(0); + expect(height()).toBe(0); + expect(keyboard.overlaysContent).toBe(false); + }); + + it('leaves an app’s own overlay setting as the app set it', () => { + const {keyboard} = stubVirtualKeyboard({overlaysContent: true}); + stubVisualViewport(); + const {unmount} = render(); + act(() => field('Query').focus()); + expect(keyboard.overlaysContent).toBe(true); + unmount(); + expect(keyboard.overlaysContent).toBe(true); + }); + + it('counts the keyboard as covering the page, as in Safari (spec:AST-099 FR6)', () => { + stubVirtualKeyboard(); + const resize = stubVisualViewport(); + expect(keyboardCoversPage(window)).toBe(true); + resize(300); + expect(keyboardInsetOf(window)).toBe(300); + }); + }); +}); diff --git a/packages/lab/src/keyboard/useKeyboardHeight.ts b/packages/lab/src/keyboard/useKeyboardHeight.ts new file mode 100644 index 0000000000000..a43d4eca99f56 --- /dev/null +++ b/packages/lab/src/keyboard/useKeyboardHeight.ts @@ -0,0 +1,71 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useKeyboardHeight.ts + * @input React and the page's visual viewport + * @output useKeyboardHeight, keyboardCoversPage and keyboardInsetOf: the + * software keyboard's height from the visual viewport, in every browser + * @position Lab's one keyboard listener. Lab's entry point does not export + * it. SearchField's bottom placement and the tab bar's search field read it. + * + * SYNC: When modified, update useKeyboardHeight.test.tsx and + * SearchField/SearchFieldTouch.ts. + */ + +import {useEffect, useState, type RefObject} from 'react'; + +/** + * Whether a software keyboard would cover the page rather than resize it. + * That needs a visual viewport in the top-level document, because an + * iframe's visual viewport does not change for the keyboard. Chrome on + * Android counts too: its visual viewport shrinks for the keyboard as Mobile + * Safari's does. The browser's own keyboard report is not read. It needs + * `overlaysContent`, which overrides the app's viewport setting. + */ +export function keyboardCoversPage(win: Window): boolean { + return win.visualViewport != null && win === win.top; +} + +/** + * The software keyboard's height: `innerHeight − visualViewport.height × + * scale` where it covers the page, so a pan or a zoom cannot shrink it; 0 + * otherwise. + */ +export function keyboardInsetOf(win: Window): number { + const viewport = win.visualViewport; + if (viewport == null || !keyboardCoversPage(win)) { + return 0; + } + return Math.max(0, win.innerHeight - viewport.height * (viewport.scale ?? 1)); +} + +/** + * The software keyboard's height as of its last report, while `enabled`, + * with no ride and no prediction: keyboardInsetOf on each visual-viewport + * resize, in every browser. `focusRoot` names the document whose window is + * measured. + */ +export function useKeyboardHeight( + enabled: boolean, + focusRoot: RefObject, +): number { + const [height, setHeight] = useState(0); + useEffect(() => { + const win = focusRoot.current?.ownerDocument.defaultView; + if (!enabled || win == null) { + return; + } + const viewport = win.visualViewport; + const onResize = () => setHeight(keyboardInsetOf(win)); + onResize(); + viewport?.addEventListener('resize', onResize); + win.addEventListener('resize', onResize); + return () => { + viewport?.removeEventListener('resize', onResize); + win.removeEventListener('resize', onResize); + }; + }, [enabled, focusRoot]); + return enabled ? height : 0; +} From 7b86fe2c060729a286146fa9befc334f9668c8ef Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 21:07:20 -0800 Subject: [PATCH 2/6] feat(lab): SearchField, the iOS 26 search field that activates on UIKit's clock Lab's SearchField is the phone search field of iOS 26's UISearchController, in three placements: - stacked: under a navigation bar's title, a tap activates it into the bar over UIKit's 383 ms ease-in-ease-out: the field narrows, a glass close button slides in beside it, and the large title rides up and fades; the close button cancels on the same clock; - bottom: iOS 26's toolbar field, resting above the keyboard by the keyboard's height as spec:AST-099 FR2 measures it from the visual viewport, in every browser (DEC-4), never overlaying the keyboard on the page (DEC-2); its gel plays the activation and cancel frames measured on iOS, and a change of mind mid-motion turns from where the field is, without a snap; - tab: the content a tab bar's search tab holds. In the bottom and tab placements, under a finger or a pen on a touch screen, wherever the keyboard covers the page (Mobile Safari, Chrome on Android), the field claims a tap on its text, so it focuses without the browser's reveal, which would pan the page (FR4), and the caret lands where iOS puts it; Core's click guard keeps the claimed tap's late mouse events from the page. The stacked field keeps the browser's own tap. With a fine primary pointer (a mouse, a desktop pen tablet) the browser keeps its own focus and click, and the parts take their places at once. With reduced motion the travel becomes a cross-fade under a finger (design:reduced-motion DR2). The values measured on the iOS 26.2 simulator live in Lab (internal/searchMotion.ts), where the bars that host the field can read them too. Its close and clear buttons' names are in the shared message catalog, as Lab's other components' are. `astryx discover` lists it. Its unit tests cover the model, the motion and the field; its interaction specs prove the stacked, bottom and tab placements in Chromium and WebKit, including the change-of-mind turns on the motion's own clock and, with Chrome on Android's keyboard report present, the claimed tap and the visual-viewport measurement. --- .changeset/cli-discover-search-field.md | 7 + .../storybook/stories/SearchField.stories.tsx | 522 ++++++ internal/shadcn-registry/routes.lock.json | 5 + .../assets/discover/official-packages.json | 4 + packages/core/locales/en.json | 8 + .../lab/src/SearchField/SearchField.doc.mjs | 135 ++ .../SearchField/SearchField.motion.test.tsx | 233 +++ .../lab/src/SearchField/SearchField.test.tsx | 612 +++++++ packages/lab/src/SearchField/SearchField.tsx | 691 ++++++++ .../lab/src/SearchField/SearchFieldTouch.ts | 732 ++++++++ .../SearchField.interaction.browser.spec.ts | 947 +++++++++++ ...rchFieldBottom.interaction.browser.spec.ts | 1496 +++++++++++++++++ ...SearchFieldTab.interaction.browser.spec.ts | 169 ++ .../src/SearchField/__tests__/fadeRecorder.ts | 212 +++ .../src/SearchField/__tests__/pseudoParts.ts | 202 +++ packages/lab/src/SearchField/index.ts | 14 + .../src/SearchField/searchFieldModel.test.ts | 171 ++ .../lab/src/SearchField/searchFieldModel.ts | 360 ++++ packages/lab/src/index.ts | 7 + .../lab/src/internal/searchMotion.test.ts | 101 ++ packages/lab/src/internal/searchMotion.ts | 182 ++ 21 files changed, 6810 insertions(+) create mode 100644 .changeset/cli-discover-search-field.md create mode 100644 apps/storybook/stories/SearchField.stories.tsx create mode 100644 packages/lab/src/SearchField/SearchField.doc.mjs create mode 100644 packages/lab/src/SearchField/SearchField.motion.test.tsx create mode 100644 packages/lab/src/SearchField/SearchField.test.tsx create mode 100644 packages/lab/src/SearchField/SearchField.tsx create mode 100644 packages/lab/src/SearchField/SearchFieldTouch.ts create mode 100644 packages/lab/src/SearchField/__tests__/SearchField.interaction.browser.spec.ts create mode 100644 packages/lab/src/SearchField/__tests__/SearchFieldBottom.interaction.browser.spec.ts create mode 100644 packages/lab/src/SearchField/__tests__/SearchFieldTab.interaction.browser.spec.ts create mode 100644 packages/lab/src/SearchField/__tests__/fadeRecorder.ts create mode 100644 packages/lab/src/SearchField/__tests__/pseudoParts.ts create mode 100644 packages/lab/src/SearchField/index.ts create mode 100644 packages/lab/src/SearchField/searchFieldModel.test.ts create mode 100644 packages/lab/src/SearchField/searchFieldModel.ts create mode 100644 packages/lab/src/internal/searchMotion.test.ts create mode 100644 packages/lab/src/internal/searchMotion.ts diff --git a/.changeset/cli-discover-search-field.md b/.changeset/cli-discover-search-field.md new file mode 100644 index 0000000000000..7af26934d1bfd --- /dev/null +++ b/.changeset/cli-discover-search-field.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx discover` lists the experimental SearchField component from `@astryxdesign/lab`. + +@vjeux diff --git a/apps/storybook/stories/SearchField.stories.tsx b/apps/storybook/stories/SearchField.stories.tsx new file mode 100644 index 0000000000000..fa7c869495cb8 --- /dev/null +++ b/apps/storybook/stories/SearchField.stories.tsx @@ -0,0 +1,522 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import { + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {SearchField, type SearchFieldHandle} from '@astryxdesign/lab'; + +/** Whether the primary pointer is coarse (a finger), as the field reads it. */ +function useCoarsePointer(): boolean { + return useSyncExternalStore( + onChange => { + const query = window.matchMedia('(pointer: coarse)'); + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, + () => window.matchMedia('(pointer: coarse)').matches, + () => false, + ); +} + +// ============================================================================= +// Meta +// ============================================================================= + +const meta: Meta = { + title: 'Lab/SearchField', + component: SearchField, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================================================================= +// A screen like the one UIKit's captures were taken on: a large title, the +// field under it, results below +// ============================================================================= + +const FRUITS = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blueberry', + 'Cherry', + 'Grapefruit', + 'Mango', + 'Orange', + 'Peach', + 'Pear', + 'Plum', + 'Raspberry', + 'Strawberry', + 'Watermelon', +]; + +const page: React.CSSProperties = { + minHeight: '100vh', + boxSizing: 'border-box', + paddingTop: 8, + fontFamily: 'system-ui, sans-serif', +}; +const largeTitle: React.CSSProperties = { + margin: 0, + padding: '4px 16px 8px', + fontSize: 34, + fontWeight: 700, + lineHeight: '41px', +}; +const list: React.CSSProperties = { + listStyle: 'none', + margin: '8px 0 0', + padding: 0, +}; +const row: React.CSSProperties = { + padding: '12px 16px', + borderBottom: '1px solid rgba(127, 127, 127, 0.2)', + fontSize: 17, +}; + +// UIKit's activation clock and curve, as Core's UIKit tables hold them +// (`search.activationMs` and the `timing.curves.move` role). A story uses no +// Core motion source (architecture:react-component-runtime INV13), so it +// restates them and plays them with the Web Animations API. +const ACTIVATION_MS = 383.3; +const MOVE_EASING = 'cubic-bezier(0.42, 0, 0.58, 1)'; +/** Keyframes per curve for the title's fade, which is not a bezier. */ +const FADE_STEPS = 40; + +/** + * The large title's opacity at the activation's progress `a` (1 = active), + * as UIKit fades it: (1 − a / 0.35)² on activation; on cancel it comes back + * from a = 0.35, that is p = 0.65 of the cancel. + */ +function titleOpacity(a: number, activating: boolean): number { + return activating + ? Math.max(0, 1 - a / 0.35) ** 2 + : Math.min(1, Math.max(0, (1 - a - 0.65) / 0.35)); +} + +/** + * A cancelled animation's `ready` or `finished` rejects with an AbortError: a + * change of mind or an unmount stopped it, and the host expects that. Any + * other rejection is a real error. + */ +function unlessAborted(error: unknown): void { + if (!(error instanceof DOMException && error.name === 'AbortError')) { + throw error; + } +} + +/** + * The host's half of a lift, as a navigation bar does it (NavigationStack + * runs the real one): the content rises with the field by `lift` on the + * field's clock, and the large title fades as UIKit's does. Placed at once + * under a mouse, as the field is. Each animation starts at the field's own + * start time, so the two move as one; a change of mind adds the change + * between the targets on top of the motion on screen, as the field does. + */ +function useLiftedHost( + isActive: boolean, + lift: number, + fieldRef: React.RefObject, +) { + const contentRef = useRef(null); + const titleRef = useRef(null); + const isTouch = useCoarsePointer(); + const settled = useRef(null); + const running = useRef([]); + useLayoutEffect(() => { + const content = contentRef.current; + const title = titleRef.current; + if (content == null || title == null) { + return; + } + const target = isActive && lift > 0; + const y = target ? -lift : 0; + const place = () => { + for (const animation of running.current) { + animation.cancel(); + } + running.current = []; + content.style.translate = `0px ${y}px`; + title.style.opacity = String(target ? 0 : 1); + }; + // Placed at once under a mouse, and under Reduce Motion, where the + // field only cross-fades. + const reduced = + content.ownerDocument.defaultView?.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches ?? false; + if (settled.current === null || !isTouch || lift === 0 || reduced) { + settled.current = target; + place(); + return; + } + if (settled.current === target) { + return; + } + settled.current = target; + // From rest, the curves replace what is there; mid-flight they add the + // change between the targets to the motion on screen. Either way the + // content's shape is linear in the eased progress p, and the title's + // value is its fade at p, so both play on the field's easing. + const additive = running.current.length > 0; + // The previous target: rest when activating, the lift when cancelling. + const fromY = target ? 0 : -lift; + const fade = Array.from({length: FADE_STEPS + 1}, (_, k) => { + const p = k / FADE_STEPS; + const value = titleOpacity(target ? p : 1 - p, target); + return { + offset: p, + opacity: additive + ? value - titleOpacity(target ? 0 : 1, target) + : value, + }; + }); + const timing: KeyframeAnimationOptions = { + duration: ACTIVATION_MS, + easing: MOVE_EASING, + fill: 'forwards', + composite: additive ? 'add' : 'replace', + }; + const added = [ + content.animate( + additive + ? [{translate: '0px 0px'}, {translate: `0px ${y - fromY}px`}] + : [{translate: `0px ${fromY}px`}, {translate: `0px ${y}px`}], + timing, + ), + title.animate(fade, timing), + ]; + running.current = [...running.current, ...added]; + // Start on the field's clock. The field starts its motion from the frame + // that presents it at rest, after this commit, so the story's curves wait + // (paused at their start) for the field root's new animation and take its + // start time. + const before = new Set(fieldRef.current?.getAnimations() ?? []); + for (const animation of added) { + animation.pause(); + } + let frames = 0; + const startWithField = () => { + const field = fieldRef.current + ?.getAnimations() + .find(animation => !before.has(animation)); + if (field == null) { + frames += 1; + if (frames > 30) { + for (const animation of added) { + animation.play(); + } + } else { + requestAnimationFrame(startWithField); + } + return; + } + const align = () => { + for (const animation of added) { + animation.startTime = field.startTime; + } + }; + if (field.startTime != null) { + align(); + } else { + void field.ready.then(align, unlessAborted); + } + }; + requestAnimationFrame(startWithField); + let cancelled = false; + void Promise.all(added.map(animation => animation.finished)).then(() => { + if ( + !cancelled && + running.current.every(a => a.playState === 'finished') + ) { + place(); + } + }, unlessAborted); + return () => { + cancelled = true; + }; + }, [isActive, lift, isTouch, fieldRef]); + return {contentRef, titleRef}; +} + +function Screen({ + lift = 0, + initialQuery = '', + initiallyActive = false, + dir, +}: { + lift?: number; + initialQuery?: string; + initiallyActive?: boolean; + dir?: 'rtl'; +}) { + const [query, setQuery] = useState(initialQuery); + const [isActive, setIsActive] = useState(initiallyActive); + const [submitted, setSubmitted] = useState(null); + const results = FRUITS.filter(fruit => + fruit.toLowerCase().includes(query.trim().toLowerCase()), + ); + const fieldRef = useRef(null); + const {contentRef, titleRef} = useLiftedHost(isActive, lift, fieldRef); + return ( +
+

+ Search +

+ +
+
    + {results.map(fruit => ( +
  • + {fruit} +
  • + ))} +
+

+ {isActive ? 'active' : 'inactive'} + {submitted != null ? ` · submitted "${submitted}"` : ''} +

+
+
+ ); +} + +/** + * The stacked field under a large title. A tap activates it: the field + * narrows and the glass close button slides in, on UIKit's 383 ms curve. + */ +export const Default: Story = { + render: () => , +}; + +/** + * Active from the start, with a query: the clear button inside the field and + * the close button beside it. + */ +export const ActiveWithQuery: Story = { + render: () => , +}; + +/** + * Lifted as a navigation bar lifts it: the active field rises over the large + * title (its 53 px here; 99 px from under the title to the bar's row on an + * iPhone 17 Pro) and the results rise with it, while the title fades. The + * screen stands in for the bar here (NavigationStack runs the real one), on + * the field's clock and UIKit's measured title fade. + */ +export const Lifted: Story = { + render: () => , +}; + +/** Right to left: the field narrows from the left; the close button slides in from there. */ +export const RightToLeft: Story = { + render: () => , +}; + +// The bottom placement's bar: kept at the bottom of the viewport, the field's +// bottom 33 px above it, as iOS 26's toolbar holds its search field. +const bottomBar: React.CSSProperties = { + position: 'fixed', + insetInline: 0, + bottom: 33, +}; + +function BottomScreen({dir}: {dir?: 'rtl'}) { + const [query, setQuery] = useState(''); + const [isActive, setIsActive] = useState(false); + const results = FRUITS.filter(fruit => + fruit.toLowerCase().includes(query.trim().toLowerCase()), + ); + return ( +
+

Search

+
    + {results.map(fruit => ( +
  • + {fruit} +
  • + ))} +
+

+ {isActive ? 'active' : 'inactive'} +

+
+ +
+
+ ); +} + +/** + * iOS 26's toolbar field at the bottom of the screen. A tap activates it: it + * keeps above the keyboard (its centre 36 px above the keyboard's top), + * swells, narrows past its place and grows a 38 px glass close button in + * beside it; cancelling springs it back out to rest as the keyboard goes + * down. + */ +export const Bottom: Story = { + render: () => , +}; + +/** The bottom field right to left: it narrows from the left. */ +export const BottomRightToLeft: Story = { + render: () => , +}; + +// A tab bar's search tab draws the field's glass and its cancel button (its +// search circle) and moves them above the keyboard; here a plain box and +// button stand in for them, in a bar kept at the bottom of the viewport. +const TAB_BAR_BOTTOM = 33; +const tabBar: React.CSSProperties = { + position: 'fixed', + insetInline: 0, + height: 48, +}; +const tabGlass: React.CSSProperties = { + position: 'absolute', + insetInlineStart: 8, + top: 0, + width: 330, + height: 48, + borderRadius: 24, + background: 'rgba(127, 127, 127, 0.14)', +}; +const tabCircle: React.CSSProperties = { + position: 'absolute', + insetInlineEnd: 8, + top: 0, + width: 48, + height: 48, + borderRadius: 24, + border: 0, + background: 'rgba(127, 127, 127, 0.14)', + fontSize: 20, +}; + +/** + * The stand-in bar keeps above the software keyboard as a tab bar keeps its + * search field: by layout, its centre 36 px above the keyboard's top while + * the keyboard is up. The keyboard's height comes from the visual viewport in + * every browser, as the field's own listener reads it. Each report moves the + * bar at once, with no ride, as a Lab surface does. + */ +function useStandInTabBarLift() { + const ref = useRef(null); + const [lift, setLift] = useState(0); + const shown = useRef(0); + useLayoutEffect(() => { + shown.current = lift; + }, [lift]); + useEffect(() => { + const element = ref.current; + const win = element?.ownerDocument.defaultView; + if (element == null || win == null) { + return undefined; + } + const read = () => { + const viewport = win.visualViewport; + const inset = + viewport == null || win !== win.top + ? 0 + : Math.max( + 0, + win.innerHeight - viewport.height * (viewport.scale ?? 1), + ); + const rect = element.getBoundingClientRect(); + // Where layout puts the bar without the lift. + const restGap = + win.innerHeight - (rect.top + shown.current + rect.height / 2); + setLift(inset > 0 ? Math.max(0, inset + 36 - restGap) : 0); + }; + read(); + win.visualViewport?.addEventListener('resize', read); + win.addEventListener('resize', read); + return () => { + win.visualViewport?.removeEventListener('resize', read); + win.removeEventListener('resize', read); + }; + }, []); + return {ref, lift}; +} + +function TabScreen() { + const handle = useRef(null); + const [query, setQuery] = useState(''); + const [isActive, setIsActive] = useState(false); + const bar = useStandInTabBarLift(); + return ( +
+

Search

+

+ {isActive ? 'active' : 'inactive'} +

+
+
+ +
+ +
+
+ ); +} + +/** + * The field as a tab bar's search tab holds it: content only, in the box the + * tab bar gives it; the tab bar's own cancel button calls `cancel()`, and the + * stand-in bar keeps above the keyboard as a tab bar does. + */ +export const InTabBar: Story = { + render: () => , +}; diff --git a/internal/shadcn-registry/routes.lock.json b/internal/shadcn-registry/routes.lock.json index 0612525a49021..1d561b01054f4 100644 --- a/internal/shadcn-registry/routes.lock.json +++ b/internal/shadcn-registry/routes.lock.json @@ -642,6 +642,11 @@ "path": "components/lab/schedule", "aliases": [] }, + { + "name": "component-lab-search-field", + "path": "components/lab/search-field", + "aliases": [] + }, { "name": "component-lab-stat", "path": "components/lab/stat", diff --git a/packages/cli/assets/discover/official-packages.json b/packages/cli/assets/discover/official-packages.json index 15bd9a9b23471..8615ac6656b73 100644 --- a/packages/cli/assets/discover/official-packages.json +++ b/packages/cli/assets/discover/official-packages.json @@ -160,6 +160,10 @@ "kind": "component", "name": "Schedule" }, + { + "kind": "component", + "name": "SearchField" + }, { "kind": "component", "name": "Stat" diff --git a/packages/core/locales/en.json b/packages/core/locales/en.json index f6cab932d9d0f..08bbb507d3663 100644 --- a/packages/core/locales/en.json +++ b/packages/core/locales/en.json @@ -2274,5 +2274,13 @@ "@astryx.infoTip.label": { "defaultMessage": "More information", "description": "Default accessible name of an InfoTip, the small info-icon button whose press reveals help about the label, value, or metric beside it." + }, + "@astryx.searchField.cancel": { + "defaultMessage": "Cancel", + "description": "Accessible name of a lab SearchField's close button, which ends the search: it clears the query, ends editing and deactivates the field." + }, + "@astryx.searchField.clear": { + "defaultMessage": "Clear text", + "description": "Accessible name of a lab SearchField's clear button, which empties the query." } } diff --git a/packages/lab/src/SearchField/SearchField.doc.mjs b/packages/lab/src/SearchField/SearchField.doc.mjs new file mode 100644 index 0000000000000..aaac751d1ce41 --- /dev/null +++ b/packages/lab/src/SearchField/SearchField.doc.mjs @@ -0,0 +1,135 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */ + +export const docs = { + name: 'SearchField', + displayName: 'Search Field', + category: 'Form Controls', + keywords: [ + 'search', + 'search field', + 'search bar', + 'searchbar', + 'search controller', + 'mobile search', + 'cancel', + 'clear', + ], + description: + "The search field of a phone app, as on iOS 26: a rounded field with a magnifier that, once tapped, narrows to make room for a glass close button and can rise into its host's bar. Under a finger, on any touch screen, it activates and cancels on the clock iOS uses, with a glass close button; with a mouse or a trackpad its parts take their places at once and the close button is plain. With reduced motion it cross-fades under a finger and changes at once otherwise. Its keyboard shows a Search key and does not autocorrect. Experimental: lands in lab first.", + components: [ + { + name: 'SearchField', + displayName: 'Search Field', + description: + 'The field. Tapping it activates search; the close button (or Escape) cancels: it clears the query, ends editing and returns to rest. A tap elsewhere ends editing but leaves search active, as on iOS.', + props: [ + { + name: 'label', + type: 'string', + description: + "The field's accessible name, and its placeholder unless placeholder is set.", + required: true, + }, + { + name: 'placeholder', + type: 'string', + description: 'Placeholder text. Defaults to label.', + }, + { + name: 'value', + type: 'string', + description: 'The query, for a controlled field. Pair with onChange.', + }, + { + name: 'defaultValue', + type: 'string', + description: 'The initial query, for an uncontrolled field.', + }, + { + name: 'onChange', + type: '(value: string) => void', + description: + 'Called with the query on every edit, and with an empty string when it is cleared or search is cancelled.', + }, + { + name: 'isActive', + type: 'boolean', + description: + 'Whether search is active, for a controlled field. Pair with onActiveChange.', + }, + { + name: 'defaultIsActive', + type: 'boolean', + description: + 'Whether search starts active, for an uncontrolled field.', + }, + { + name: 'onActiveChange', + type: '(isActive: boolean) => void', + description: + 'Called with true when the field gains focus and with false when search is cancelled.', + }, + { + name: 'onSubmit', + type: '(value: string) => void', + description: + "Called with the query when the keyboard's Search key (Enter) is pressed. Focus and the keyboard stay; blur the field yourself if results should take over the screen.", + }, + { + name: 'handleRef', + type: 'Ref', + description: + "focus(), blur() and cancel() for a host that activates the field from its own tap, such as a tab bar's search tab. Call focus() inside that tap, after committing the activation, so the touch keyboard rises; cancel() does what the close button does (clears, ends editing, reports onActiveChange(false)) for a host that draws its own cancel button.", + }, + { + name: 'lift', + type: 'number', + description: + "How far the active field rises into its host's bar, in px. A navigation bar passes the distance from under its large title to its own row. Defaults to 0: the field stays in place.", + }, + { + name: 'placement', + type: "'stacked' | 'bottom' | 'tab'", + description: + "Where the field lives. 'stacked' (default): in a page header or under a navigation bar's title. 'bottom': iOS 26's toolbar field, 38 px tall and 33 px from each side, in a bar you keep at the bottom of the viewport; it keeps above the software keyboard (its centre 36 px above the keyboard's top while active), moving once the keyboard reports where it is, and shows a 38 px close button beside it, which grows in under a finger. 'tab': inside a tab bar's search tab, which draws the glass, the cancel button and every motion; the field draws only its content, sized to the box it is given. Under a finger or a pen on a touch screen, a tap on the text of a 'bottom' or 'tab' field focuses it without the page scrolling. lift applies to 'stacked' only.", + default: "'stacked'", + }, + { + name: 'cancelLabel', + type: 'string', + description: + "The close button's accessible name. Defaults to Cancel.", + }, + { + name: 'clearLabel', + type: 'string', + description: + "The clear button's accessible name. Defaults to Clear text.", + }, + ], + }, + ], + usage: { + description: + "Use SearchField at the top of a phone screen whose content can be searched, such as a list, or with placement='bottom' in a bar at the bottom of the screen, where iOS 26 puts a toolbar's search field. Filter the content as the query changes, and restore it when search is cancelled.", + bestPractices: [ + { + guidance: true, + description: + 'Give it a short label that says what is searched, such as "Search messages".', + }, + { + guidance: true, + description: + 'Keep the results on screen while search is active; a tap on them ends editing without cancelling the search.', + }, + { + guidance: false, + description: + "Don't use it to pick one value from a known list (use Typeahead or a Selector with search) or for a command launcher (use CommandPalette).", + }, + ], + }, +}; diff --git a/packages/lab/src/SearchField/SearchField.motion.test.tsx b/packages/lab/src/SearchField/SearchField.motion.test.tsx new file mode 100644 index 0000000000000..999201b9a479c --- /dev/null +++ b/packages/lab/src/SearchField/SearchField.motion.test.tsx @@ -0,0 +1,233 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file SearchField.motion.test.tsx + * @input vitest, Testing Library, SearchField, searchFieldModel and the + * motion foundation (its `useMotionAnimator` wrapped to record each play) + * @output Proofs that the activation plays the search table's clock and + * curve, which no theme retunes; that a move from rest starts on a + * presented frame and a change of mind adds to the curves on screen; that + * a move stopped early leaves the next one to start from rest; and that a + * mouse plays none + * @position Testing; validates SearchField.tsx and searchFieldModel.ts with a + * stubbed computed style + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {act, fireEvent, render, screen} from '@testing-library/react'; +import type * as AnimatorHook from '../internal/useMotionAnimator'; +import {search} from '../internal/searchMotion'; +import {SearchField} from './SearchField'; +import {activationTiming} from './searchFieldModel'; + +const played = vi.hoisted(() => [] as Array>); +/** The options of each play, in order. */ +const playedOptions = vi.hoisted(() => [] as Array>); +/** Every animator the field played on. */ +const playedOn = vi.hoisted(() => new Set<{stop: () => void}>()); + +// Records the length of every curve the field plays; the animator still +// plays them. +vi.mock('../internal/useMotionAnimator', async importOriginal => { + const actual = await importOriginal(); + const wrapped = new WeakSet(); + return { + ...actual, + useMotionAnimator: ( + ...args: Parameters + ) => { + const result = actual.useMotionAnimator(...args); + const {animator} = result; + if (!wrapped.has(animator)) { + wrapped.add(animator); + const play = animator.play.bind(animator); + animator.play = (curves, options) => { + playedOn.add(animator); + playedOptions.push({...options}); + played.push( + Object.fromEntries( + Object.entries(curves).map(([key, curve]) => [ + key, + curve?.duration ?? 0, + ]), + ), + ); + return play(curves, options); + }; + } + return result; + }, + }; +}); + +/** Theme variables by scope element, as the stubbed computed style reports. */ +let variables: Map>; + +/** Frames the test delivers: the activation starts on a presented one. */ +let frameCallbacks: Map; +let frameTime = 0; +function deliverFrames(count: number) { + for (let k = 0; k < count; k++) { + frameTime += 16.7; + const waiting = [...frameCallbacks.values()]; + frameCallbacks.clear(); + act(() => { + for (const callback of waiting) { + callback(frameTime); + } + }); + } +} + +beforeEach(() => { + played.length = 0; + playedOptions.length = 0; + playedOn.clear(); + variables = new Map(); + frameCallbacks = new Map(); + frameTime = performance.now(); + let nextFrame = 1; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + const id = nextFrame++; + frameCallbacks.set(id, callback); + return id; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + frameCallbacks.delete(id); + }); + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element: Element) => + ({ + direction: 'ltr', + getPropertyValue: (name: string) => + variables.get(element)?.[name] ?? '', + }) as unknown as CSSStyleDeclaration, + ); + // Under a finger (a coarse primary pointer), so the field plays UIKit's + // activation. + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query.includes('pointer: coarse'), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ''; +}); + +/** A theme scope carrying the given CSS variables. */ +function themed(values: Record) { + const scope = document.createElement('div'); + scope.setAttribute('data-astryx-theme', 'test'); + document.body.append(scope); + variables.set(scope, values); + return scope; +} + +describe('SearchField motion', () => { + it('plays the search table’s 383 ms ease-in-ease-out, which no theme retunes', () => { + const timing = activationTiming(); + expect(timing.seconds).toBeCloseTo(search.activationMs / 1000, 6); + // UIKit's ease-in-ease-out, slow at the start. + expect(timing.ease(0.25)).toBeLessThan(0.2); + // A theme scope that writes the variables a motion role would read + // changes nothing: the search table's values are internal constants. + const scope = themed({ + '--motion-search-activation-ms': '600ms', + '--motion-search-activation-curve': 'cubic-bezier(0, 0, 1, 1)', + }); + render(, {container: scope}); + act(() => screen.getByRole('searchbox').focus()); + deliverFrames(2); + expect(played.length).toBeGreaterThan(0); + for (const curves of played) { + for (const duration of Object.values(curves)) { + expect(duration).toBeCloseTo(search.activationMs / 1000, 6); + } + } + }); + + it('starts from rest on the frame that presents the parts at rest, and adds a change of mind to the curves on screen, on the whole clock', () => { + render(); + const input = screen.getByRole('searchbox'); + act(() => input.focus()); + expect(playedOptions.length).toBeGreaterThan(0); + for (const options of playedOptions) { + expect(options).toEqual({start: 'presented'}); + } + const activation = playedOptions.length; + deliverFrames(4); + // A cancel while the activation still plays. + fireEvent.click( + document.querySelector('[data-search-field-close]') as HTMLElement, + ); + const turn = playedOptions.slice(activation); + expect(turn.length).toBeGreaterThan(0); + for (const options of turn) { + expect(options).toEqual({additive: true}); + } + for (const curves of played.slice(activation)) { + for (const duration of Object.values(curves)) { + expect(duration).toBeCloseTo(search.activationMs / 1000, 6); + } + } + }); + + it('starts the next move from rest when a move stops early and no newer move took over', async () => { + render(); + act(() => screen.getByRole('searchbox').focus()); + const activation = playedOptions.length; + expect(activation).toBeGreaterThan(0); + deliverFrames(4); + // Something outside the field stops its moves before they end, as a + // detach on unmount does. + expect(playedOn.size).toBeGreaterThan(0); + act(() => { + for (const animator of playedOn) { + animator.stop(); + } + }); + await act(async () => {}); + fireEvent.click( + document.querySelector('[data-search-field-close]') as HTMLElement, + ); + const turn = playedOptions.slice(activation); + expect(turn.length).toBeGreaterThan(0); + for (const options of turn) { + expect(options).toEqual({start: 'presented'}); + } + }); + + it('plays no activation under a mouse: the parts take their places at once', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + // A fine primary pointer, asserted in this test's own context. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + render(); + act(() => screen.getByRole('searchbox').focus()); + deliverFrames(3); + expect(played).toEqual([]); + }); +}); diff --git a/packages/lab/src/SearchField/SearchField.test.tsx b/packages/lab/src/SearchField/SearchField.test.tsx new file mode 100644 index 0000000000000..ae0facdab9e48 --- /dev/null +++ b/packages/lab/src/SearchField/SearchField.test.tsx @@ -0,0 +1,612 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file SearchField.test.tsx + * @input vitest, @testing-library/react, SearchField + * @output jsdom coverage of SearchField's semantics and state: a search + * landmark holding a named searchbox with a touch keyboard's Search key + * and no autocorrection; focus activates; the close button cancels + * (clears, ends editing, deactivates) and is out of the tab order while + * inactive; Escape cancels outside composition; Enter submits and keeps + * focus; the clear button appears with text, clears and keeps or restores + * focus inside the tap; a host's handle focuses the field right after an + * activation it committed (a tab bar's tap); controlled and uncontrolled; + * the bottom placement's geometry and its lift above the software + * keyboard (spec:AST-099 FR2, from the visual viewport in every browser, + * never the VirtualKeyboard API; none under a mouse), and the tab + * placement's content and cancel; + * the field's background is pseudo-elements, so the field holds only the + * magnifier, the input and the trailing end + * @position Testing; the motion is proven in real engines by + * __tests__/SearchField.interaction.browser.spec.ts + */ + +import {describe, expect, it, vi} from 'vitest'; +import {createRef, useState} from 'react'; +import {flushSync} from 'react-dom'; +import {act, fireEvent, render, screen} from '@testing-library/react'; +import {InternationalizationProvider} from '@astryxdesign/core/i18n'; +import {renderToString} from 'react-dom/server'; +import { + SearchField, + type SearchFieldHandle, + type SearchFieldProps, +} from './SearchField'; +import {search} from '../internal/searchMotion'; + +function Field(props: Partial) { + return ; +} + +const box = () => screen.getByRole('searchbox', {name: 'Search'}); +// The close button stays mounted (hidden while inactive), so it is found by +// its data attribute rather than by role. +const close = () => { + const button = document.querySelector( + '[data-search-field-close]', + ); + if (button == null) { + throw new Error('no close button'); + } + return button; +}; + +/** + * A finger (or a mouse) and a visual viewport that reports a software + * keyboard the way Mobile Safari does, for `run`: `keyboard(cover)` shrinks + * it from the bottom and fires one `resize`. + */ +interface VirtualKeyboardStandIn extends EventTarget { + overlaysContent: boolean; + boundingRect: DOMRect; + /** The keyboard now covers `cover` px of the bottom; Chrome reports it. */ + report(cover: number): void; +} + +async function withKeyboard( + {isTouch = true, virtualKeyboardApi = false} = {}, + run: ( + keyboard: (cover: number) => void, + virtualKeyboard: VirtualKeyboardStandIn, + ) => Promise | void, +) { + const viewport = Object.assign(new EventTarget(), { + height: window.innerHeight, + scale: 1, + }); + vi.stubGlobal('visualViewport', viewport); + // The browser's own keyboard report (Chrome on Android), which the field + // must ignore: its rect, and a `geometrychange` when the keyboard moves. + const virtualKeyboard = Object.assign(new EventTarget(), { + overlaysContent: false, + boundingRect: new DOMRect(0, 0, 0, 0), + report(cover: number) { + act(() => { + virtualKeyboard.boundingRect = new DOMRect( + 0, + window.innerHeight - cover, + window.innerWidth, + cover, + ); + virtualKeyboard.dispatchEvent(new Event('geometrychange')); + }); + }, + }); + if (virtualKeyboardApi) { + Object.defineProperty(navigator, 'virtualKeyboard', { + configurable: true, + value: virtualKeyboard, + }); + } + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: isTouch && query.includes('pointer: coarse'), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + try { + await run(cover => { + act(() => { + viewport.height = window.innerHeight - cover; + viewport.dispatchEvent(new Event('resize')); + }); + }, virtualKeyboard); + } finally { + Reflect.deleteProperty(navigator, 'virtualKeyboard'); + vi.unstubAllGlobals(); + } +} + +describe('SearchField', () => { + it('is a search landmark holding a searchbox named by its label', () => { + render(); + expect(screen.getByRole('search')).toContainElement(box()); + expect(box()).toHaveAttribute('placeholder', 'Search'); + }); + + it('asks a touch keyboard for its Search key and no autocorrection', () => { + render(); + expect(box()).toHaveAttribute('enterkeyhint', 'search'); + expect(box()).toHaveAttribute('autocorrect', 'off'); + }); + + it('activates when the field gains focus, once', () => { + const onActiveChange = vi.fn(); + render(); + act(() => box().focus()); + act(() => box().blur()); + act(() => box().focus()); + expect(onActiveChange.mock.calls).toEqual([[true]]); + expect(screen.getByRole('search')).toHaveAttribute( + 'data-search-field-active', + 'true', + ); + }); + + it('keeps the close button hidden and out of the tab order while inactive', () => { + render(); + expect(close()).toHaveAttribute('aria-hidden', 'true'); + expect(close()).toHaveAttribute('tabindex', '-1'); + act(() => box().focus()); + expect(close()).not.toHaveAttribute('aria-hidden'); + expect(close()).not.toHaveAttribute('tabindex'); + expect(screen.getByRole('button', {name: 'Cancel'})).toBe(close()); + }); + + it('cancels from the close button: clears, ends editing and deactivates', () => { + const onChange = vi.fn(); + const onActiveChange = vi.fn(); + render( + , + ); + act(() => box().focus()); + fireEvent.click(close()); + expect(onChange).toHaveBeenLastCalledWith(''); + expect(onActiveChange).toHaveBeenLastCalledWith(false); + expect(box()).toHaveValue(''); + expect(box()).not.toHaveFocus(); + }); + + it('server-renders an active field narrowed, and steps the CSS pose aside once the parts are placed', () => { + // No effects run: the markup and CSS a server-rendered page paints first. + const host = document.createElement('div'); + host.innerHTML = renderToString( + , + ); + document.body.appendChild(host); + try { + const end = host.querySelector('[data-search-field-end]') as HTMLElement; + // The trailing end moved in by the close button and its gap. (The + // middle, the field's ::after, moves short by the same 55 px; jsdom + // computes no pseudo-element style, so the browser proof of the CSS + // pose checks it.) + const {closeGap, closeSize} = search.stacked; + expect(closeGap + closeSize).toBe(55); + expect(getComputedStyle(end).marginInlineEnd).toBe('55px'); + } finally { + host.remove(); + } + // A client render places the parts and marks the root, so the CSS pose + // no longer applies. + render(); + const root = screen.getByRole('search'); + expect(root).toHaveAttribute('data-search-field-placed'); + const end = root.querySelector('[data-search-field-end]') as HTMLElement; + expect(getComputedStyle(end).marginInlineEnd).not.toBe('55px'); + }); + + it('draws its background as pseudo-elements: the field holds only the magnifier, the input and the trailing end (architecture:component-style-authoring INV7)', () => { + render(); + const field = box().parentElement as HTMLElement; + const parts = [...field.children]; + // The rounded ends and the middle that scales are decoration with no + // role and no theme target: the field's ::before and ::after, and the + // trailing end's ::before, not elements of their own. + expect(parts).toHaveLength(3); + expect(parts[0].querySelector('svg')).not.toBeNull(); + expect(parts[1]).toBe(box()); + expect(parts[2]).toHaveAttribute('data-search-field-end'); + // The trailing end holds nothing while the query is empty, then only the + // clear button. + expect(parts[2].children).toHaveLength(0); + fireEvent.change(box(), {target: {value: 'a'}}); + expect([...parts[2].children].map(child => child.tagName)).toEqual([ + 'BUTTON', + ]); + }); + + it('draws its magnifier as the icon itself, with no wrapper element', () => { + render(); + const input = box(); + // The first svg in the field is the magnifier: the Icon's own root sits + // directly in the field, beside the input, with nothing around it. + const field = input.parentElement as HTMLElement; + const icon = (field.querySelector('svg') as SVGElement) + .parentElement as HTMLElement; + expect(icon.parentElement).toBe(field); + expect(icon).toHaveAttribute('aria-hidden', 'true'); + }); + + it('server-renders the resting close button transparent and out of place, before hydration', () => { + // No effects run: this is the markup and CSS a server-rendered page + // paints first. + const host = document.createElement('div'); + host.innerHTML = renderToString(); + document.body.appendChild(host); + try { + const button = host.querySelector( + '[data-search-field-close]', + ) as HTMLElement; + expect(getComputedStyle(button).opacity).toBe('0'); + expect(getComputedStyle(button).pointerEvents).toBe('none'); + // The CSS offset is the table's close travel. + expect(search.stacked.closeTravel).toBe(44); + } finally { + host.remove(); + } + }); + + it('a cancel from the close button with focus leaves focus on the search landmark, not on the hidden button', () => { + render(); + act(() => box().focus()); + // Keyboard users Tab to the close button, then press Enter or Space. + act(() => close().focus()); + fireEvent.click(close()); + const landmark = screen.getByRole('search'); + expect(close()).toHaveAttribute('aria-hidden', 'true'); + expect(landmark).toHaveFocus(); + // The landmark lets focus go: it is not left in the tab order. + act(() => landmark.blur()); + expect(landmark).not.toHaveAttribute('tabindex'); + }); + + it('Escape leaves focus on the search landmark', () => { + render(); + act(() => box().focus()); + fireEvent.keyDown(box(), {key: 'Escape'}); + expect(screen.getByRole('search')).toHaveFocus(); + }); + + it('names its buttons through the translator by default', () => { + render( + + + , + ); + act(() => screen.getByRole('searchbox').focus()); + expect(screen.getByRole('button', {name: 'Annuler'})).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Effacer le texte'}), + ).toBeInTheDocument(); + }); + + it('cancels on Escape, but not on an Escape that ends a composition', () => { + const onActiveChange = vi.fn(); + render(); + act(() => box().focus()); + fireEvent.keyDown(box(), {key: 'Escape', isComposing: true}); + expect(onActiveChange).toHaveBeenLastCalledWith(true); + fireEvent.keyDown(box(), {key: 'Escape'}); + expect(onActiveChange).toHaveBeenLastCalledWith(false); + }); + + it('submits the query on Enter and keeps editing', () => { + const onSubmit = vi.fn(); + render(); + act(() => box().focus()); + fireEvent.keyDown(box(), {key: 'Enter'}); + expect(onSubmit).toHaveBeenCalledWith('pear'); + expect(box()).toHaveFocus(); + }); + + it('shows the clear button only with text; it clears without taking focus', () => { + render(); + expect( + screen.queryByRole('button', {name: 'Clear text'}), + ).not.toBeInTheDocument(); + act(() => box().focus()); + fireEvent.change(box(), {target: {value: 'ch'}}); + const clear = screen.getByRole('button', {name: 'Clear text'}); + // Its press never takes the input's focus. + expect(fireEvent.mouseDown(clear)).toBe(false); + fireEvent.click(clear); + expect(box()).toHaveValue(''); + expect(box()).toHaveFocus(); + expect( + screen.queryByRole('button', {name: 'Clear text'}), + ).not.toBeInTheDocument(); + }); + + // iOS raises the keyboard only for a focus made during the tap; UIKit's + // clear button makes a field that is not editing first responder. + it('focuses the input inside the tap when clear is tapped while not editing', () => { + render(); + const clear = screen.getByRole('button', {name: 'Clear text'}); + const focusedDuring: (string | undefined)[] = []; + const input = box(); + const focus = HTMLElement.prototype.focus; + Object.defineProperty(input, 'focus', { + configurable: true, + value(options?: FocusOptions) { + focusedDuring.push(window.event?.type); + focus.call(input, options); + }, + }); + fireEvent.click(clear); + expect(input).toHaveFocus(); + expect(focusedDuring).toEqual(['click']); + }); + + it('lets a host focus the field right after committing its activation', () => { + const handle = createRef(); + function Host() { + const [active, setActive] = useState(false); + return ( + <> + + + + ); + } + render(); + fireEvent.click(screen.getByRole('button', {name: 'Search tab'})); + expect(box()).toHaveFocus(); + expect(screen.getByRole('search')).toHaveAttribute( + 'data-search-field-active', + 'true', + ); + act(() => handle.current?.blur()); + expect(box()).not.toHaveFocus(); + }); + + it('follows controlled value and active state', () => { + const {rerender} = render(); + expect(box()).toHaveValue('fig'); + expect(close()).toHaveAttribute('aria-hidden', 'true'); + rerender(); + expect(box()).toHaveValue('figs'); + expect(close()).not.toHaveAttribute('aria-hidden'); + }); + + describe('bottom placement', () => { + it('is a 38 px field 33 px from each side, with a 38 px close button 17 px from the end', () => { + render(); + const root = screen.getByRole('search'); + const field = screen + .getByRole('searchbox') + .closest('[data-search-field-content]')?.parentElement as HTMLElement; + expect(root.getAttribute('style')).toContain('33px'); + expect(field.getAttribute('style')).toContain('38px'); + expect(close().getAttribute('style')).toContain('38px'); + expect(close().getAttribute('style')).toContain('17px'); + }); + + it('draws its background as pseudo-elements: the field holds only the content the gel carries and the trailing end', () => { + render(); + const content = screen + .getByRole('searchbox') + .closest('[data-search-field-content]') as HTMLElement; + const field = content.parentElement as HTMLElement; + // The rounded ends and the middle are the field's ::before and ::after + // and the trailing end's ::before (architecture:component-style-authoring + // INV7); the content wrapper moves the magnifier and the text with the + // leading edge. + expect([...field.children]).toEqual([ + content, + field.querySelector('[data-search-field-end]'), + ]); + expect(content.children).toHaveLength(2); + expect( + (field.querySelector('[data-search-field-end]') as HTMLElement) + .children, + ).toHaveLength(0); + }); + + it('activates on focus and cancels from the close button, as the stacked field does', () => { + const onActiveChange = vi.fn(); + render( + , + ); + act(() => box().focus()); + expect(onActiveChange).toHaveBeenLastCalledWith(true); + fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); + expect(onActiveChange).toHaveBeenLastCalledWith(false); + expect(box()).not.toHaveFocus(); + }); + + it('the clear button refocuses the field without the browser scrolling it into view', () => { + render( + , + ); + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + fireEvent.click(screen.getByRole('button', {name: 'Clear text'})); + expect(focus).toHaveBeenLastCalledWith({preventScroll: true}); + focus.mockRestore(); + }); + + it('keeps above the software keyboard by layout: under a finger the keyboard’s height lifts the field', async () => { + await withKeyboard({}, keyboard => { + render(); + const root = screen.getByRole('search'); + expect(root.getAttribute('style')).not.toContain('300px'); + keyboard(300); + // Inactive, the bar keeps its resting gap above the keyboard. + expect(root.getAttribute('style')).toContain('300px'); + keyboard(0); + expect(root.getAttribute('style')).not.toContain('300px'); + }); + }); + + /** The bottom field resting 52 px above the viewport's bottom edge. */ + function renderActiveBottom() { + render(); + const root = screen.getByRole('search'); + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, window.innerHeight - 52 - 19, 402, 38), + ); + act(() => box().focus()); + return root; + } + + it('moves the bar only on the keyboard’s report, with no prediction: a blur alone moves nothing', async () => { + await withKeyboard({}, keyboard => { + const root = renderActiveBottom(); + keyboard(300); + // Active: its centre 36 px above the keyboard, 52 px above the edge + // at rest. + expect(root.getAttribute('style')).toContain('284px'); + act(() => box().blur()); + expect(root.getAttribute('style')).toContain('284px'); + keyboard(0); + expect(root.getAttribute('style')).not.toContain('284px'); + }); + }); + + it('in Chrome on Android measures the visual viewport too, and never takes over the app’s keyboard setting (spec:AST-099 DEC-2, DEC-4)', async () => { + await withKeyboard({virtualKeyboardApi: true}, (keyboard, chrome) => { + const root = renderActiveBottom(); + expect(chrome.overlaysContent).toBe(false); + keyboard(300); + expect(root.getAttribute('style')).toContain('284px'); + // The browser's own report is not the measure. + chrome.report(100); + expect(root.getAttribute('style')).toContain('284px'); + act(() => box().blur()); + keyboard(0); + expect(root.getAttribute('style')).not.toContain('284px'); + expect(chrome.overlaysContent).toBe(false); + }); + }); + + it('measures no keyboard under a mouse', async () => { + await withKeyboard({isTouch: false}, keyboard => { + // A fine primary pointer, asserted in this test's own context. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + render(); + keyboard(300); + expect(screen.getByRole('search').getAttribute('style')).not.toContain( + '300px', + ); + }); + }); + }); + + it('still claims a focus move into the field after its placement switches between tab and bottom, which remounts the input', async () => { + await withKeyboard({}, () => { + const field = (placement: 'tab' | 'bottom') => ( + <> + + + + ); + const {rerender} = render(field('tab')); + rerender(field('bottom')); + const input = box(); + const calls: unknown[] = []; + const focus = HTMLElement.prototype.focus; + Object.defineProperty(input, 'focus', { + configurable: true, + value(options?: FocusOptions) { + calls.push(options); + focus.call(input, options); + }, + }); + const before = screen.getByRole('button', {name: 'Before'}); + act(() => before.focus()); + // The keyboard's next button: the move's blur names the field. + act(() => { + before.dispatchEvent(new FocusEvent('blur', {relatedTarget: input})); + }); + // Claimed: focused with preventScroll, and never without it. + expect(calls[0]).toEqual({preventScroll: true}); + expect(calls.filter(options => options == null)).toEqual([]); + }); + }); + + describe('tab placement', () => { + it('draws only its content: the tab bar draws the glass and the cancel button', () => { + render(); + const root = screen.getByRole('search'); + expect(box()).toBeInTheDocument(); + // No background: the field paints no ::before or ::after (checked in a + // browser by SearchFieldTab.interaction.browser.spec.ts), and holds + // only the magnifier, the input and the trailing end. + const field = box().parentElement as HTMLElement; + expect(field.children).toHaveLength(3); + expect(root.querySelector('[data-search-field-close]')).toBeNull(); + }); + + it('focuses without Safari’s reveal and cancels from the handle, for the tab bar’s own cancel button', () => { + const handle = createRef(); + const onActiveChange = vi.fn(); + const onChange = vi.fn(); + render( + , + ); + const input = box(); + // An own property, not an assignment: some DOMs expose `focus` as a + // getter-only accessor. + const calls: unknown[] = []; + const focus = HTMLElement.prototype.focus; + Object.defineProperty(input, 'focus', { + configurable: true, + value(options?: FocusOptions) { + calls.push(options); + focus.call(input, options); + }, + }); + act(() => handle.current?.focus()); + expect(input).toHaveFocus(); + expect(calls).toEqual([{preventScroll: true}]); + expect(onActiveChange).toHaveBeenLastCalledWith(true); + act(() => handle.current?.cancel()); + expect(onChange).toHaveBeenLastCalledWith(''); + expect(onActiveChange).toHaveBeenLastCalledWith(false); + expect(input).not.toHaveFocus(); + expect(input).toHaveValue(''); + }); + }); +}); diff --git a/packages/lab/src/SearchField/SearchField.tsx b/packages/lab/src/SearchField/SearchField.tsx new file mode 100644 index 0000000000000..41616fdd14922 --- /dev/null +++ b/packages/lab/src/SearchField/SearchField.tsx @@ -0,0 +1,691 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file SearchField.tsx + * @input React, StyleX, Core's theme tokens, Icon, the search table + * (internal/searchMotion.ts) and the touch layer (SearchFieldTouch.ts) + * @output SearchField, the phone search field of iOS 26's + * UISearchController, and the SearchFieldHandle type + * @position Lab component. A navigation bar, a tab bar's search tab, a page + * header or a bottom toolbar holds it. + * + * SYNC: When modified, update SearchFieldTouch.ts, SearchField.doc.mjs, + * SearchField.test.tsx, searchFieldModel.ts and + * __tests__/SearchField.interaction.browser.spec.ts. + */ + +import { + useCallback, + useImperativeHandle, + useRef, + useState, + type ChangeEvent, + type KeyboardEvent, + type MouseEvent, + type Ref, +} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import type {BaseProps} from '@astryxdesign/core'; +import {Icon} from '@astryxdesign/core/Icon'; +import {useMergedRefs} from '@astryxdesign/core/hooks'; +import {useTranslator} from '@astryxdesign/core/i18n'; +import { + colorVars, + radiusVars, + shadowVars, + textSizeVars, +} from '@astryxdesign/core/theme/tokens.stylex'; +import {mergeProps, themeProps} from '@astryxdesign/core/utils'; +import { + useSearchFieldMotion, + type SearchFieldPlacement, +} from './SearchFieldTouch'; +import {bottomEdges} from './searchFieldModel'; +import {search} from '../internal/searchMotion'; + +const {bottom} = search; + +// ============================================================================= +// Types +// ============================================================================= + +/** What `handleRef` exposes: focus, blur and cancel the field. */ +export interface SearchFieldHandle { + /** + * Focuses the input. Called inside a tap, it raises the touch keyboard + * and activates the field. At the bottom of the screen (`bottom`, `tab`) + * it does not let Safari scroll the field into view. + */ + focus(): void; + /** Ends editing; the field stays active. */ + blur(): void; + /** + * Cancels search as the close button does: clears the query, ends + * editing and reports `onActiveChange(false)`. For a host that draws its + * own cancel button (a tab bar's search tab). + */ + cancel(): void; +} + +export interface SearchFieldProps extends Omit< + BaseProps, + 'onChange' | 'onSubmit' +> { + /** The field's accessible name; also its placeholder by default. */ + label: string; + /** Placeholder text. Defaults to `label`. */ + placeholder?: string; + /** The query (controlled). */ + value?: string; + /** The initial query (uncontrolled). */ + defaultValue?: string; + /** Called with the new query on every edit, and with '' on clear or cancel. */ + onChange?: (value: string) => void; + /** Whether search is active (controlled). */ + isActive?: boolean; + /** Whether search starts active (uncontrolled). */ + defaultIsActive?: boolean; + /** Called when the field activates (it gains focus) or is cancelled. */ + onActiveChange?: (isActive: boolean) => void; + /** Called with the query when the keyboard's Search key (Enter) is used. */ + onSubmit?: (value: string) => void; + /** + * Focus, blur and cancel, for a host that activates the field from its own + * tap or draws its own cancel button. + */ + handleRef?: Ref; + /** + * How far the active field rises into its host's bar, px. The host knows + * the distance: a navigation bar lifts the field from under its large + * title to the bar's row. The default, 0, keeps it in place. + */ + lift?: number; + /** + * Where the field lives. `stacked` (default) sits in a page header or under + * a navigation bar's title. `bottom` is iOS 26's toolbar field, in a bar + * the app keeps at the bottom of the viewport, above the software keyboard. + * `lift` applies to `stacked` only. `tab` is the field inside a tab bar's + * search tab. There the tab bar draws the glass, the cancel button and + * every motion. The field draws only its content, sized to its box. + */ + placement?: SearchFieldPlacement; + /** The close button's accessible name (default: "Cancel", translated). */ + cancelLabel?: string; + /** The clear button's accessible name (default: "Clear text", translated). */ + clearLabel?: string; + /** The root element (the `search` landmark). */ + ref?: Ref; +} + +// ============================================================================= +// Styles +// ============================================================================= + +const px = (value: number) => `${value}px`; + +const styles = stylex.create({ + root: { + position: 'relative', + boxSizing: 'border-box', + width: '100%', + // The inactive close button waits past the field's end and slides in + // from there, as UIKit's does from beyond the screen's edge. Clipping the + // inline overflow keeps it from widening the page, which would otherwise + // scroll sideways on a phone. + overflowX: 'clip', + // No browser tap highlight over the field or its buttons: UIKit's has + // none, and Chromium's fades on the main thread, painting every frame. + WebkitTapHighlightColor: 'transparent', + }, + rootInset: (inset: string) => ({paddingInline: inset}), + // Above the software keyboard, by layout (SearchFieldTouch.ts's + // `keyboardLift`); physical, as the keyboard is. + keyboardLift: (lift: string) => ({bottom: lift}), + // Inside a tab bar's search tab the field fills the box the tab bar gives + // it; the tab bar's glass is its background. + tabFill: { + height: '100%', + }, + field: { + position: 'relative', + display: 'flex', + alignItems: 'center', + minWidth: 0, + // The background pseudo-elements paint under the magnifier and the + // text, inside the field's own stacking context. + isolation: 'isolate', + }, + fieldHeight: (height: string) => ({height}), + // The background: two rounded ends and a middle that scales, so the field + // narrows on the compositor without re-laying out. They are decoration + // with no role and no theme target, so they are pseudo-elements. The + // leading end is the field's ::before, and the middle is its ::after. The + // trailing end is the trailing part's ::before (endCap), which moves with + // it. + background: (cap: string) => ({ + '::before': { + content: '""', + position: 'absolute', + top: 0, + bottom: 0, + insetInlineStart: 0, + zIndex: -1, + width: cap, + borderStartStartRadius: cap, + borderEndStartRadius: cap, + backgroundColor: colorVars['--color-background-muted'], + pointerEvents: 'none', + // The leading end moves under a bottom field's gel: promoted by the + // animator while it moves (motion/animator.ts, "Promotion"). + willChange: 'var(--astryx-motion-will-change-before, auto)', + }, + '::after': { + content: '""', + position: 'absolute', + top: 0, + bottom: 0, + insetInlineStart: cap, + insetInlineEnd: cap, + zIndex: -1, + backgroundColor: colorVars['--color-background-muted'], + pointerEvents: 'none', + transformOrigin: { + default: 'left center', + ':is([dir="rtl"] *)': 'right center', + }, + // The animator promotes the scaling middle through this property on + // the field while it moves (motion/animator.ts, "Promotion"). + willChange: 'var(--astryx-motion-will-change-after, auto)', + }, + }), + end: { + position: 'absolute', + top: 0, + bottom: 0, + insetInlineEnd: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + pointerEvents: 'none', + }, + // The trailing end's ::before is one px wider than the rounded end. It + // reaches under the middle, so the two show no antialiased seam as they + // move. + endCap: (cap: string) => ({ + '::before': { + content: '""', + position: 'absolute', + top: 0, + bottom: 0, + insetInlineEnd: 0, + width: `calc(${cap} + 1px)`, + borderStartEndRadius: cap, + borderEndEndRadius: cap, + backgroundColor: colorVars['--color-background-muted'], + // The cap scales under a bottom field's gel: promoted by the animator + // while it moves (motion/animator.ts, "Promotion"). + willChange: 'var(--astryx-motion-will-change-before, auto)', + }, + }), + // A bottom field's magnifier and text, carried together with its leading + // edge through the gel. + content: { + position: 'relative', + display: 'flex', + alignItems: 'center', + flexGrow: 1, + minWidth: 0, + height: '100%', + }, + magnifier: { + position: 'relative', + display: 'flex', + flexShrink: 0, + marginInlineStart: 12, + color: colorVars['--color-text-secondary'], + pointerEvents: 'none', + }, + input: { + position: 'relative', + flexGrow: 1, + minWidth: 0, + height: '100%', + margin: 0, + paddingBlock: 0, + paddingInlineStart: 8, + borderWidth: 0, + backgroundColor: 'transparent', + color: colorVars['--color-text-primary'], + // 17 px, iOS's body size; at least 16 px also keeps iOS from zooming + // into the field on focus. + fontSize: textSizeVars['--font-size-lg'], + fontFamily: 'inherit', + outline: 'none', + '::placeholder': { + color: colorVars['--color-text-secondary'], + }, + }, + inputEnd: (end: string) => ({paddingInlineEnd: end}), + clear: { + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + padding: 0, + borderWidth: 0, + borderRadius: radiusVars['--radius-full'], + backgroundColor: colorVars['--color-text-secondary'], + color: colorVars['--color-background-body'], + cursor: { + default: 'pointer', + ':is(:disabled,[aria-disabled="true"])': 'default', + }, + pointerEvents: 'auto', + WebkitTapHighlightColor: 'transparent', + }, + clearGeometry: (size: string, inset: string) => ({ + width: size, + height: size, + marginInlineEnd: inset, + }), + close: { + position: 'absolute', + top: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 0, + borderWidth: 0, + borderRadius: radiusVars['--radius-full'], + // Glass under a finger (TOUCH_QUERY, spelled as touchPresentation.ts + // does); under a mouse a plain round button in the field's own fill. + backgroundColor: { + default: colorVars['--color-background-muted'], + '@media (pointer: coarse)': `color-mix(in srgb, ${colorVars['--color-background-popover']} 78%, transparent)`, + }, + backdropFilter: { + default: 'none', + '@media (pointer: coarse)': 'blur(16px) saturate(180%)', + }, + boxShadow: { + default: 'none', + '@media (pointer: coarse)': shadowVars['--shadow-med'], + }, + color: colorVars['--color-text-primary'], + cursor: { + default: 'pointer', + ':is(:disabled,[aria-disabled="true"])': 'default', + }, + touchAction: 'manipulation', + WebkitTapHighlightColor: 'transparent', + }, + closeGeometry: (size: string, inset: string) => ({ + width: size, + height: size, + insetInlineEnd: inset, + }), + // The active pose before the touch layer places the parts, as in a server + // render or the first paint before hydration. The trailing end is already + // in by the close button and its gap. The middle (the field's ::after) is + // short by the same 55 px (`search.stacked.closeGap + closeSize`, kept + // equal by a test). The touch layer marks the root placed in its first + // layout. It sets the parts' held values in the same task, and they take + // over. + beforePlacement: { + marginInlineEnd: { + default: null, + ':is([data-search-field-active]:not([data-search-field-placed]) *)': 55, + }, + }, + middleBeforePlacement: { + '::after': { + marginInlineEnd: { + default: null, + ':is([data-search-field-active]:not([data-search-field-placed]) *)': 55, + }, + }, + }, + // The resting (inactive) close button, in CSS: 44 px toward the trailing + // end (`search.stacked.closeTravel`, kept equal by a test). A + // server-rendered page then paints it transparent and out of place until + // the animator's held values take over. + closeInert: { + pointerEvents: 'none', + opacity: 0, + translate: { + default: '44px 0', + ':is([dir="rtl"] *)': '-44px 0', + }, + }, + // The resting bottom close button, in CSS too. It hides inside the field's + // trailing end at a fifth of its size, 16.67 px toward the field. The + // values match `search.bottom.closeEnterFrames`' first row, and a test + // keeps them equal. + closeInertBottom: { + pointerEvents: 'none', + opacity: 0, + scale: 0.2, + translate: { + default: '-16.67px 0', + ':is([dir="rtl"] *)': '16.67px 0', + }, + }, +}); + +const CLEAR_INSET_PX = 10; + +const PREVENT_SCROLL: FocusOptions = {preventScroll: true}; + +/** + * How the field's own focus calls focus its input: at the bottom or in a + * tab, without the browser's reveal, which would pan the page. + */ +function focusOptionsFor( + placement: SearchFieldPlacement, +): FocusOptions | undefined { + return placement === 'stacked' ? undefined : PREVENT_SCROLL; +} + +// ============================================================================= +// Component +// ============================================================================= + +/** + * The search field of iOS 26's UISearchController for a phone: it + * activates into its host's bar with a glass close button, on UIKit's + * clock. + * + * @example + * ``` + * const [query, setQuery] = useState(''); + * + * ``` + */ +export function SearchField({ + label, + placeholder, + value: valueProp, + defaultValue = '', + onChange, + isActive: isActiveProp, + defaultIsActive = false, + onActiveChange, + onSubmit, + handleRef, + lift = 0, + placement = 'stacked', + cancelLabel, + clearLabel, + xstyle, + className, + style, + ref, + ...rest +}: SearchFieldProps) { + const [valueState, setValueState] = useState(defaultValue); + const value = valueProp ?? valueState; + const [isActiveState, setIsActiveState] = useState(defaultIsActive); + const isActive = isActiveProp ?? isActiveState; + + const inputRef = useRef(null); + const rootRef = useRef(null); + const t = useTranslator(); + const resolvedCancelLabel = cancelLabel ?? t('@astryx.searchField.cancel'); + const resolvedClearLabel = clearLabel ?? t('@astryx.searchField.clear'); + const fieldRef = useRef(null); + const isBottom = placement === 'bottom'; + const isTab = placement === 'tab'; + const motion = useSearchFieldMotion({ + fieldRef, + inputRef, + isActive, + lift, + placement, + }); + // The field box hosts the leading end (::before) and the middle + // (::after); the trailing part hosts the trailing end's cap (::before). + const fieldRefs = useMergedRefs(fieldRef, motion.startRef, motion.middleRef); + const endRefs = useMergedRefs(motion.endRef, motion.endCapRef); + const fieldHeight = isBottom + ? bottom.fieldHeight + : search.stacked.fieldHeight; + + const setValue = useCallback( + (next: string) => { + if (valueProp === undefined) { + setValueState(next); + } + onChange?.(next); + }, + [valueProp, onChange], + ); + const setActive = useCallback( + (next: boolean) => { + if (isActiveProp === undefined) { + setIsActiveState(next); + } + onActiveChange?.(next); + }, + [isActiveProp, onActiveChange], + ); + + // --------------------------------------------------------------------------- + // Events + // --------------------------------------------------------------------------- + + const handleFocus = useCallback(() => { + if (!isActive) { + setActive(true); + } + }, [isActive, setActive]); + + const cancel = useCallback(() => { + setValue(''); + inputRef.current?.blur(); + setActive(false); + }, [setValue, setActive]); + useImperativeHandle( + handleRef, + () => ({ + focus() { + inputRef.current?.focus(focusOptionsFor(placement)); + }, + blur() { + inputRef.current?.blur(); + }, + cancel, + }), + [placement, cancel], + ); + + // A cancel from the keyboard (Escape in the field, or the close button + // with focus) leaves focus on the search landmark. The close button turns + // `aria-hidden` and transparent at once, and the field would activate + // again if it took focus. The landmark takes focus only for this. It lets + // focus go once focus moves on, so Tab continues from the field. + const keepFocusOnLandmark = useCallback(() => { + const root = rootRef.current; + if (root == null) { + return; + } + root.tabIndex = -1; + root.addEventListener('blur', () => root.removeAttribute('tabindex'), { + once: true, + }); + root.focus({preventScroll: true}); + }, []); + const handleClose = useCallback( + (event: MouseEvent) => { + const button = event.currentTarget; + const hadFocus = button.ownerDocument.activeElement === button; + cancel(); + if (hadFocus) { + keepFocusOnLandmark(); + } + }, + [cancel, keepFocusOnLandmark], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing) { + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + onSubmit?.(value); + } else if (event.key === 'Escape' && isActive) { + event.preventDefault(); + cancel(); + keepFocusOnLandmark(); + } + }, + [onSubmit, value, isActive, cancel, keepFocusOnLandmark], + ); + + // The clear button keeps the input's focus, because its press never takes + // it. Tapped while not editing, it focuses the input inside the tap, so the + // keyboard rises. UIKit's clear button also makes its field first + // responder. + const handleClear = useCallback(() => { + setValue(''); + inputRef.current?.focus(focusOptionsFor(placement)); + }, [setValue, placement]); + + const cap = px(fieldHeight / 2); + const showsClear = value !== ''; + // Text stops before the clear button. While active, it also stops before + // the space the narrowed field gives up to the close button. At the bottom, + // that space is less the leading edge's own move, which carries the text. + const edges = bottomEdges(0); + const activeGiveUp = isTab + ? 0 + : isBottom + ? edges.restTrailing - + edges.activeTrailing - + (edges.restLeading - edges.activeLeading) + : search.stacked.closeGap + search.stacked.closeSize; + const inputEnd = + (showsClear ? search.clear.size + CLEAR_INSET_PX : 0) + + (isActive ? activeGiveUp : 0) + + (showsClear ? 0 : CLEAR_INSET_PX); + const closeSize = isBottom ? bottom.closeSize : search.stacked.closeSize; + const closeInset = isBottom ? bottom.activeInset : search.stacked.fieldInset; + const magnifier = ( + + ); + const input = ( + ) => + setValue(event.target.value) + } + onFocus={handleFocus} + onKeyDown={handleKeyDown} + // A UIKit search field's text field: the `.search` return key and + // `autocorrectionType = .no` (measured on iOS 26.2). + enterKeyHint="search" + autoCorrect="off" + autoComplete="off" + spellCheck={false} + {...stylex.props(styles.input, styles.inputEnd(px(inputEnd)))} + /> + ); + + return ( +
+
+ {isBottom ? ( +
+ {magnifier} + {input} +
+ ) : ( + <> + {magnifier} + {input} + + )} +
+ {showsClear ? ( + + ) : null} +
+
+ {isTab ? null : ( + + )} +
+ ); +} + +SearchField.displayName = 'SearchField'; diff --git a/packages/lab/src/SearchField/SearchFieldTouch.ts b/packages/lab/src/SearchField/SearchFieldTouch.ts new file mode 100644 index 0000000000000..e7a719d03c022 --- /dev/null +++ b/packages/lab/src/SearchField/SearchFieldTouch.ts @@ -0,0 +1,732 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file SearchFieldTouch.ts + * @input Core's internal touch entry, the search table, searchFieldModel.ts + * and Lab's keyboard listener + * @output useSearchFieldMotion, SearchField's touch layer: the parts' places, + * UIKit's activation and cancel under a finger, the bottom field's keyboard + * lift, and the claimed tap + * @position Only SearchField.tsx imports it, so a desktop build can swap it + * and the size report can measure it. + * + * SYNC: When modified, update SearchField.tsx and searchFieldModel.ts. + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type RefObject, +} from 'react'; +import { + claimFocusMoves, + isRtlElement, + TapGestureRecognizer, + useGestureRecognizer, + useTouchPresentation, + type AnimateOptions, + type MotionAnimator, + type MotionCurve, + type MotionHandle, +} from '@astryxdesign/core/internal/touch'; +import {observeResize} from '@astryxdesign/core'; +import { + activationTiming, + bottomEdges, + bottomLift, + bottomMotion, + bottomPoseFor, + bottomRedirect, + easedCurve, + heldCurve, + poseFor, + type BottomMotion, + type BottomPose, + type SearchFieldGeometry, + type SearchFieldPose, +} from './searchFieldModel'; +import {useMotionAnimator} from '../internal/useMotionAnimator'; +import {search} from '../internal/searchMotion'; +import { + keyboardCoversPage, + useKeyboardHeight, +} from '../keyboard/useKeyboardHeight'; + +const {bottom} = search; + +/** Where the field lives (SearchField's `placement`). */ +export type SearchFieldPlacement = 'stacked' | 'bottom' | 'tab'; + +/** The refs SearchField puts on the parts the touch layer moves. */ +export interface SearchFieldMotion { + rootRef: (element: HTMLElement | null) => void; + /** + * The field box: the touch layer moves its ::before, the leading end + * (under a bottom field's gel). + */ + startRef: (element: HTMLElement | null) => void; + contentRef: (element: HTMLElement | null) => void; + /** The field box: the touch layer scales its ::after, the middle. */ + middleRef: (element: HTMLElement | null) => void; + endRef: (element: HTMLElement | null) => void; + /** + * The trailing part: the touch layer scales its ::before, the trailing + * end's cap (under a bottom field's gel). + */ + endCapRef: (element: HTMLElement | null) => void; + closeRef: (element: HTMLElement | null) => void; + /** + * How far a bottom field sits above its resting place for the software + * keyboard, px: layout, applied as the root's `bottom`. 0 elsewhere. + */ + keyboardLift: number; +} + +/** + * SearchField's touch layer: the parts' poses, and UIKit's activation under + * a finger (a coarse primary pointer, on every touch device). Reduced motion + * is the animator's: under a finger it turns the travel into a cross-fade. + */ +export function useSearchFieldMotion({ + fieldRef, + inputRef, + isActive, + lift, + placement, +}: { + fieldRef: RefObject; + inputRef: RefObject; + isActive: boolean; + lift: number; + placement: SearchFieldPlacement; +}): SearchFieldMotion { + const rootAnimator = useMotionAnimator(); + // The rounded ends and the middle are pseudo-elements, being decoration: + // the field's ::before and ::after, and the trailing part's ::before. + const startAnimator = useMotionAnimator({pseudoElement: '::before'}); + const contentAnimator = useMotionAnimator(); + const middleAnimator = useMotionAnimator({pseudoElement: '::after'}); + const endAnimator = useMotionAnimator(); + const endCapAnimator = useMotionAnimator({pseudoElement: '::before'}); + const closeAnimator = useMotionAnimator(); + const {isTouch} = useTouchPresentation(); + const isBottom = placement === 'bottom'; + const isStacked = placement === 'stacked'; + + // --------------------------------------------------------------------------- + // Stacked: UIKit's activation on one clock + // --------------------------------------------------------------------------- + + const fieldHeight = isBottom + ? bottom.fieldHeight + : search.stacked.fieldHeight; + const geometry = useCallback((): SearchFieldGeometry => { + const field = fieldRef.current; + return { + restWidth: field?.getBoundingClientRect().width ?? 0, + height: fieldHeight, + lift, + isRtl: isRtlElement(field), + }; + }, [fieldRef, fieldHeight, lift]); + + const presented = useCallback( + (): SearchFieldPose => ({ + y: rootAnimator.animator.get('y'), + middleScale: middleAnimator.animator.get('scaleX'), + endX: endAnimator.animator.get('x'), + closeX: closeAnimator.animator.get('x'), + closeOpacity: closeAnimator.animator.get('opacity'), + }), + [rootAnimator, middleAnimator, endAnimator, closeAnimator], + ); + + const place = useCallback( + (pose: SearchFieldPose) => { + rootAnimator.animator.set({y: pose.y}); + middleAnimator.animator.set({scaleX: pose.middleScale}); + endAnimator.animator.set({x: pose.endX}); + closeAnimator.animator.set({x: pose.closeX, opacity: pose.closeOpacity}); + }, + [rootAnimator, middleAnimator, endAnimator, closeAnimator], + ); + + // `settledRef` is null until the first layout places the parts. + const settledRef = useRef(null); + const isAnimating = useRef(false); + const latestMove = useRef(0); + // Syncs the parts' compositor animations (the animator) with `isActive`. + useLayoutEffect(() => { + if (!isStacked) { + return; + } + const target = isActive; + if (settledRef.current === null || !isTouch) { + // First layout, or a mouse or trackpad: take the places at once. + settledRef.current = target; + place(poseFor(target, geometry())); + // The parts now hold their places: the CSS form of the active pose + // (for a server render) steps aside in the same task. + rootAnimator.animator.element?.setAttribute( + 'data-search-field-placed', + '', + ); + return; + } + if (settledRef.current === target && !isAnimating.current) { + place(poseFor(target, geometry())); + return; + } + settledRef.current = target; + const continuesMotion = isAnimating.current; + isAnimating.current = true; + const move = ++latestMove.current; + const shape = geometry(); + const to = poseFor(target, shape); + const current = presented(); + // UIKit's clock and curve, the search table's. + const timing = activationTiming(); + const duration = timing.seconds; + // From rest the move waits for the frame that presents the parts at rest + // (`start: 'presented'`). Mobile Safari shows no frame for ~150 ms after + // the focus, while the keyboard comes up. A motion made before that stall + // would jump a third of the way when frames resume. A host that moves + // with the field, like a navigation bar, starts the same way in the same + // task, so both start together. A change of mind mid-move adds the turn + // to the curve on screen (`additive`), as UIKit's animations do. The old + // curve carries the rest of its travel, and the added one runs the whole + // clock. So the turn keeps its velocity however late WebKit applies it. + // Under reduced motion the animator plays its cross-fade for these. + const options = continuesMotion + ? {additive: true} + : {start: 'presented' as const}; + const handles = [ + play( + rootAnimator.animator, + {y: easedCurve(current.y, to.y, duration, timing.ease)}, + options, + ), + play( + middleAnimator.animator, + { + scaleX: easedCurve( + current.middleScale, + to.middleScale, + duration, + timing.ease, + ), + }, + options, + ), + play( + endAnimator.animator, + {x: easedCurve(current.endX, to.endX, duration, timing.ease)}, + options, + ), + play( + closeAnimator.animator, + { + x: easedCurve(current.closeX, to.closeX, duration, timing.ease), + // Cancel slides the close button out at the opacity it has and + // removes it at the end, as UIKit does. + opacity: target + ? easedCurve( + current.closeOpacity, + to.closeOpacity, + duration, + timing.ease, + ) + : heldCurve(current.closeOpacity, to.closeOpacity, duration), + }, + options, + ), + ]; + const playing = handles.filter( + (handle): handle is MotionHandle => handle != null, + ); + // When the latest move ends, finished or stopped, the next move starts + // from rest. + void Promise.all(playing.map(handle => handle.finished)).then(() => { + if (latestMove.current === move) { + isAnimating.current = false; + } + }); + // `geometry` changes with `lift`; a lift change while settled re-places + // the field through the same path. + }, [ + isStacked, + isActive, + isTouch, + geometry, + place, + presented, + rootAnimator, + middleAnimator, + endAnimator, + closeAnimator, + ]); + + // --------------------------------------------------------------------------- + // Bottom: the measured gel + // --------------------------------------------------------------------------- + + const bottomShape = useCallback(() => { + const root = rootAnimator.animator.element; + return {width: root?.clientWidth ?? 0, isRtl: isRtlElement(root)}; + }, [rootAnimator]); + + const placeBottom = useCallback( + (pose: BottomPose) => { + const {width, isRtl} = bottomShape(); + const parts = bottomParts(pose, width, isRtl, fieldHeight); + const direction = isRtl ? -1 : 1; + startAnimator.animator.set({x: parts.shift, scaleY: parts.heightScale}); + contentAnimator.animator.set({x: parts.shift}); + middleAnimator.animator.set({ + x: parts.shift, + scaleX: parts.middleScale, + scaleY: parts.heightScale, + }); + endAnimator.animator.set({x: parts.endX}); + endCapAnimator.animator.set({scaleY: parts.heightScale}); + closeAnimator.animator.set({ + x: direction * pose.closeX, + scale: pose.closeScale, + opacity: pose.closeOpacity, + }); + }, + [ + bottomShape, + fieldHeight, + startAnimator, + contentAnimator, + middleAnimator, + endAnimator, + endCapAnimator, + closeAnimator, + ], + ); + + // Where the bottom field shows now, read off its parts. + const presentedBottom = useCallback((): BottomPose => { + const {width, isRtl} = bottomShape(); + const edges = bottomEdges(width); + const direction = isRtl ? -1 : 1; + return { + leading: edges.restLeading + direction * startAnimator.animator.get('x'), + trailing: edges.restTrailing + direction * endAnimator.animator.get('x'), + heightScale: endCapAnimator.animator.get('scaleY'), + closeX: direction * closeAnimator.animator.get('x'), + closeScale: closeAnimator.animator.get('scale'), + closeOpacity: closeAnimator.animator.get('opacity'), + }; + }, [bottomShape, startAnimator, endAnimator, endCapAnimator, closeAnimator]); + + // Syncs the bottom field's compositor animations (the animator) with + // `isActive`. + useLayoutEffect(() => { + if (!isBottom) { + return; + } + const target = isActive; + const {width, isRtl} = bottomShape(); + if (settledRef.current === null || !isTouch) { + // First layout, or a mouse or trackpad: take the places at once. + settledRef.current = target; + placeBottom(bottomPoseFor(target, width)); + return; + } + if (settledRef.current === target && !isAnimating.current) { + placeBottom(bottomPoseFor(target, width)); + return; + } + settledRef.current = target; + // iOS changes the field's frame each frame with no animation object. So + // play the measured frames, every part on one clock. Start from the frame + // that presents the field at rest, as the stacked field does. A change of + // mind mid-move adds an eased turn to the curves on screen. It starts + // where the field shows and runs the activation's whole clock, as UIKit's + // animations do. + const continuesMotion = isAnimating.current; + const motion = continuesMotion + ? (() => { + const timing = activationTiming(); + return bottomRedirect( + presentedBottom(), + target, + width, + timing.seconds, + timing.ease, + ); + })() + : bottomMotion(target, width); + const direction = isRtl ? -1 : 1; + const part = (pick: (parts: BottomParts) => number) => + derivedCurve(motion, pick, width, isRtl, fieldHeight); + const options = continuesMotion + ? {additive: true} + : {start: 'presented' as const}; + isAnimating.current = true; + const move = ++latestMove.current; + const handles = [ + play( + startAnimator.animator, + {x: part(p => p.shift), scaleY: part(p => p.heightScale)}, + options, + ), + play(contentAnimator.animator, {x: part(p => p.shift)}, options), + play( + middleAnimator.animator, + { + x: part(p => p.shift), + scaleX: part(p => p.middleScale), + scaleY: part(p => p.heightScale), + }, + options, + ), + play(endAnimator.animator, {x: part(p => p.endX)}, options), + play( + endCapAnimator.animator, + {scaleY: part(p => p.heightScale)}, + options, + ), + play( + closeAnimator.animator, + { + x: scaled(motion.closeX, direction), + scale: motion.closeScale, + opacity: motion.closeOpacity, + }, + options, + ), + ]; + const playing = handles.filter( + (handle): handle is MotionHandle => handle != null, + ); + // When the latest move ends, finished or stopped, the next move starts + // from rest. + void Promise.all(playing.map(handle => handle.finished)).then(() => { + if (latestMove.current === move) { + isAnimating.current = false; + } + }); + }, [ + isBottom, + isActive, + isTouch, + bottomShape, + placeBottom, + presentedBottom, + fieldHeight, + rootAnimator, + startAnimator, + contentAnimator, + middleAnimator, + endAnimator, + endCapAnimator, + closeAnimator, + ]); + + // --------------------------------------------------------------------------- + // Bottom: above the software keyboard + // --------------------------------------------------------------------------- + + // The keyboard's height as of its last report (lab's one keyboard + // listener, keyboard/useKeyboardHeight.ts). The root's `bottom` + // (SearchField.tsx) takes the lift at once on each report. A Lab surface + // commits the resting value and never rides the keyboard, even under + // reduced motion. + const isLifted = isBottom && isTouch; + const keyboardHeight = useKeyboardHeight(isLifted, inputRef); + // How far the field's resting centre sits above the viewport's bottom + // edge, read once per keyboard change. It is where layout puts the field + // without the lift (the root's `bottom`). + const [restGap, setRestGap] = useState(0); + useLayoutEffect(() => { + const root = rootAnimator.animator.element; + const win = root?.ownerDocument.defaultView; + if (!isLifted || root == null || win == null) { + return; + } + const rect = root.getBoundingClientRect(); + const lift = parseFloat(win.getComputedStyle(root).bottom) || 0; + setRestGap(win.innerHeight - (rect.top + lift + rect.height / 2)); + }, [isLifted, keyboardHeight, rootAnimator]); + const keyboardLift = isLifted + ? bottomLift(isActive, keyboardHeight, restGap) + : 0; + + // --------------------------------------------------------------------------- + // Bottom and tab: a claimed tap on the text + // --------------------------------------------------------------------------- + + // A tap on the text focuses the field with preventScroll. The browser's + // own focus would pan the page under the keyboard. The claim attaches only + // under a coarse pointer where the keyboard covers the page. It takes touch + // and pen. A mouse keeps the browser's focus and caret. `isTouch` is false + // on the server and while hydrating, so no markup differs. + const claimsTap = + !isStacked && + isTouch && + typeof window !== 'undefined' && + keyboardCoversPage(window); + const noClaimTarget = useRef(null); + useGestureRecognizer( + claimsTap ? fieldRef : noClaimTarget, + () => + new TapGestureRecognizer({ + name: 'search-field-bottom-focus', + allowedPointerTypes: ['touch', 'pen'], + shouldReceiveTouch: (_recognizer, touch) => { + const input = inputRef.current; + const target = touch.target as Element | null; + return ( + input != null && + input.ownerDocument.activeElement !== input && + !(target?.closest?.('button') != null) + ); + }, + }), + tap => { + const input = inputRef.current; + if (tap.state !== 'ended' || input == null) { + return; + } + input.focus({preventScroll: true}); + // The browser would have put the caret where the finger lifted. The + // claimed tap cancels that focus, and script focus leaves the caret at + // the start or where it last was. So place it from the tap. + const offset = caretOffsetAt(input, tap.location().x); + if (offset != null) { + input.setSelectionRange(offset, offset); + } + }, + ); + + // Focus navigation into the field (the keyboard's previous and next + // buttons, Tab) moves focus from another control. The browser would then + // reveal the field by panning the page, so the field claims that move + // (claimFocusMoves). A host's own focus goes through the handle, which + // passes preventScroll. This applies only where the tap is claimed. + useEffect(() => { + const input = inputRef.current; + const doc = input?.ownerDocument; + if (!claimsTap || input == null || doc == null) { + return; + } + // Read at event time: a placement switch remounts the input. + return claimFocusMoves(doc, event => { + const field = inputRef.current; + return field != null && event.relatedTarget === field ? field : null; + }); + }, [claimsTap, inputRef]); + + // A resize while settled (the window turning, a host relaying out) moves + // the narrowed end to the new width at once. + useLayoutEffect(() => { + const field = fieldRef.current; + // The tab bar lays out and moves a tab field's box; it has no parts. + if (field == null || placement === 'tab') { + return; + } + return observeResize(field, () => { + if (isAnimating.current || settledRef.current === null) { + return; + } + if (isBottom) { + placeBottom(bottomPoseFor(settledRef.current, bottomShape().width)); + } else { + place(poseFor(settledRef.current, geometry())); + } + }); + }, [ + fieldRef, + placement, + isBottom, + geometry, + place, + placeBottom, + bottomShape, + ]); + + return { + rootRef: rootAnimator.ref, + startRef: startAnimator.ref, + contentRef: contentAnimator.ref, + middleRef: middleAnimator.ref, + endRef: endAnimator.ref, + endCapRef: endCapAnimator.ref, + closeRef: closeAnimator.ref, + keyboardLift, + }; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** The bottom field's part values for one frame. */ +interface BottomParts { + /** The leading edge's move from rest, px (the start cap, text, middle). */ + shift: number; + /** The stretchable middle's width as a fraction of its resting width. */ + middleScale: number; + /** The trailing end's move from rest, px. */ + endX: number; + /** The background's height as a fraction of its resting height. */ + heightScale: number; +} + +function bottomParts( + pose: Pick, + width: number, + isRtl: boolean, + height: number, +): BottomParts { + const edges = bottomEdges(width); + const direction = isRtl ? -1 : 1; + const cap = height / 2; + const restMiddle = Math.max( + 1, + edges.restTrailing - edges.restLeading - 2 * cap, + ); + return { + shift: direction * (pose.leading - edges.restLeading), + middleScale: + Math.max(0, pose.trailing - pose.leading - 2 * cap) / restMiddle, + endX: direction * (pose.trailing - edges.restTrailing), + heightScale: pose.heightScale, + }; +} + +/** A part's curve, read off the field's measured edges each frame. */ +function derivedCurve( + motion: BottomMotion, + pick: (parts: BottomParts) => number, + width: number, + isRtl: boolean, + height: number, +): MotionCurve { + const duration = Math.max( + motion.leading.duration, + motion.trailing.duration, + motion.heightScale.duration, + ); + const at = (t: number) => + pick( + bottomParts( + { + leading: motion.leading.at(t), + trailing: motion.trailing.at(t), + heightScale: motion.heightScale.at(t), + }, + width, + isRtl, + height, + ), + ); + return {duration, at, velocity: t => slope(at, t)}; +} + +function scaled(curve: MotionCurve, factor: number): MotionCurve { + return { + duration: curve.duration, + at: t => factor * curve.at(t), + velocity: t => factor * curve.velocity(t), + }; +} + +/** A numeric derivative, units/s. */ +function slope(at: (t: number) => number, t: number): number { + const lo = Math.max(0, t - 0.001); + const hi = t + 0.001; + return (at(hi) - at(lo)) / (hi - lo); +} + +/** + * The text offset nearest a client x inside a text input: the caret a tap at + * that x would place. Measures the input's text with its own font, from its + * content box's start edge and scroll position; null where text cannot be + * measured. + */ +function caretOffsetAt( + input: HTMLInputElement, + clientX: number, +): number | null { + const doc = input.ownerDocument; + const style = doc.defaultView?.getComputedStyle(input); + const context = + style == null ? null : doc.createElement('canvas').getContext('2d'); + if (style == null || context == null) { + return null; + } + context.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const spacing = parseFloat(style.letterSpacing) || 0; + const rect = input.getBoundingClientRect(); + const isRtl = style.direction === 'rtl'; + const contentStart = isRtl + ? rect.right - + (parseFloat(style.borderRightWidth) || 0) - + (parseFloat(style.paddingRight) || 0) + : rect.left + + (parseFloat(style.borderLeftWidth) || 0) + + (parseFloat(style.paddingLeft) || 0); + const along = + (isRtl ? contentStart - clientX : clientX - contentStart) + + Math.abs(input.scrollLeft); + const text = input.value; + let best = 0; + let bestDistance = Infinity; + for (let offset = 0; offset <= text.length; offset++) { + const width = + context.measureText(text.slice(0, offset)).width + spacing * offset; + const distance = Math.abs(width - along); + if (distance < bestDistance) { + best = offset; + bestDistance = distance; + } + } + return best; +} + +/** + * Plays the given curves. A curve that does not move is put in place instead + * (`set`). That also ends whatever the property was still playing. For + * example, a re-activation keeps a cancel's held opacity at 1, which would + * otherwise drop to 0 when the cancel's clock ends. In Chromium, an animation + * with no visible change would also run on the main thread. + */ +function play( + animator: MotionAnimator, + curves: Partial< + Record<'x' | 'y' | 'scale' | 'scaleX' | 'scaleY' | 'opacity', MotionCurve> + >, + options: AnimateOptions, +): MotionHandle | null { + const moving: typeof curves = {}; + for (const [key, curve] of Object.entries(curves) as [ + keyof typeof curves, + MotionCurve, + ][]) { + const first = curve.at(0); + // A curve can leave its value and come back (the bottom field's swell), + // so look along it, not only at its ends. + const moves = Array.from({length: 33}, (_, i) => + curve.at((curve.duration * i) / 32), + ).some(value => Math.abs(value - first) > 1e-6); + if (moves) { + moving[key] = curve; + } else { + animator.set({[key]: first}); + } + } + if (Object.keys(moving).length === 0) { + return null; + } + return animator.play(moving, options); +} diff --git a/packages/lab/src/SearchField/__tests__/SearchField.interaction.browser.spec.ts b/packages/lab/src/SearchField/__tests__/SearchField.interaction.browser.spec.ts new file mode 100644 index 0000000000000..1c5dd74605e6e --- /dev/null +++ b/packages/lab/src/SearchField/__tests__/SearchField.interaction.browser.spec.ts @@ -0,0 +1,947 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file SearchField.interaction.browser.spec.ts + * @input The built "Lab/SearchField" stories on the harness's phone (touch, + * so the touch presentation applies), real touch input (Chromium CDP / + * WebKit's input command) and the interaction harness + * @output Proofs that SearchField activates and cancels like UIKit's search + * bar on iOS 26.2: a tap narrows the field and slides the glass close + * button in on UIKit's 383 ms ease-in-ease-out, every part on one clock, + * within half a pixel (the middle, the field's ::after, read through + * pseudoParts.ts); the lift rides the same clock; the close button + * cancels on the same clock, sliding out at full opacity; a cancel + * mid-activation turns around from where the parts are; the activation + * keeps advancing every frame through a blocked main thread and does no + * Layout or Paint; right to left it narrows from the left; under a mouse + * every part takes its place at once; in the Lifted + * story the results rise with the field and the title fades as UIKit's + * does (the story stands in for the navigation bar); all of that under a + * finger, one touch model on every touch device (an Android finger, with + * no iOS signals, plays the same activation), where the close button is + * glass; under reduced motion (`design:reduced-motion`) a finger only + * cross-fades (no part moves or scales) and a mouse changes at once; + * storyboards of the activation and the cancel + * @position Interaction proof for lab SearchField (SearchField.tsx, + * searchFieldModel.ts); shape copied from the harness exemplar, + * BottomSheetDetent.interaction.browser.spec.ts + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + blockMainThread, + busyWindowProof, + countBetween, + cubicBezier, + holdAnimations, + markElement, + openStory, + releaseAnimations, + sampleCurve, + startScreencast, + touchDriver, + traceRendering, + trackMarker, + waitForAnimation, + waitForRest, + withStorybook, + writeEvidence, +} from '@astryxdesign/interaction-harness'; + +import {readFades, recordFades} from './fadeRecorder'; +import {MIDDLE, partBox, samplePart, waitForPartRest} from './pseudoParts'; + +const storybook = withStorybook(test); + +/** UIKit's search activation on iOS 26.2 (uikit/search.ts). */ +const ACTIVATION_MS = 383.3; +const ease = cubicBezier(0.42, 0, 0.58, 1); +/** The close button and the gap before it: what the field gives up, px. */ +const NARROWING = 55; +/** How far past its place the close button starts, px. */ +const CLOSE_TRAVEL = 44; + +const times = Array.from({length: 25}, (_, i) => i * 16.667); +const progress = (t: number) => ease(Math.min(1, t / ACTIVATION_MS)); + +async function openField(page: Page, story = 'default') { + await openStory(page, storybook(), `lab-searchfield--${story}`); + const driver = await touchDriver(page); + const field = page.getByRole('searchbox', {name: 'Search'}); + await expect(field).toBeVisible(); + // Installs the harness's page helpers before the tap: on a cold WebKit + // page, installing them after it could outlast the 383 ms activation. + await waitForRest(page.locator('[data-search-field-end]')); + return {driver, field}; +} + +const root = (page: Page) => page.getByRole('search'); +const end = (page: Page) => page.locator('[data-search-field-end]'); +const close = (page: Page) => page.locator('[data-search-field-close]'); + +async function centerOf(locator: Locator) { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('no box'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +/** The field's resting width, px (the stretchable middle spans it less the two rounded ends). */ +async function restWidth(page: Page) { + const box = await page + .locator('[data-search-field-end]') + .evaluate(element => (element.parentElement as HTMLElement).offsetWidth); + return box; +} + +/** + * Records, in the page, when the next animation on `locator` starts: a + * frame callback that reads its animations until one has a start time, then + * stops, so the frames after it run no script. Resolve the returned getter + * after the activation for its window on the page's epoch clock. + */ +async function recordActivationStart(locator: Locator) { + await locator.evaluate(element => { + const store = window as unknown as {__searchFieldStart?: number | null}; + store.__searchFieldStart = null; + const tick = () => { + const animation = element.getAnimations()[0]; + if (animation != null && animation.startTime != null) { + store.__searchFieldStart = + performance.timeOrigin + Number(animation.startTime); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + return async () => { + const from = await locator + .page() + .evaluate( + () => + (window as unknown as {__searchFieldStart?: number | null}) + .__searchFieldStart ?? null, + ); + if (from == null) { + throw new Error('the activation never started'); + } + return {from, to: from + ACTIVATION_MS}; + }; +} + +/** + * Waits for an animation on `locator` that started after `after` (epoch ms, + * an earlier animation's `from`): a reversal starts on the first presented + * frame after the tap, so the move it replaces runs on for those frames. + */ +async function waitForNewAnimation(locator: Locator, after: number) { + const deadline = Date.now() + 5_000; + for (;;) { + const fresh = (await waitForAnimation(locator)).filter( + a => a.from > after + 1, + ); + if (fresh.length > 0) { + return fresh; + } + if (Date.now() > deadline) { + throw new Error('no new animation started'); + } + await locator.page().waitForTimeout(8); + } +} + +/** A presented translateX, px (the animator drives `translate`). */ +const translateX = async (locator: Locator) => + locator.evaluate(element => { + const value = getComputedStyle(element).translate; + return value === 'none' ? 0 : parseFloat(value); + }); + +test('a tap activates on UIKit’s clock: the field narrows and the close button slides in', async ({ + page, +}, testInfo) => { + const {driver, field} = await openField(page); + const width = await restWidth(page); + await driver.tap(await centerOf(field)); + // Catch the curve first: a slow assertion before it could outlast 383 ms. + await waitForAnimation(end(page)); + await holdAnimations(page); + await expect(root(page)).toHaveAttribute('data-search-field-active', 'true'); + + const ends = await sampleCurve(end(page), times); + // The middle is the field's ::after (pseudoParts.ts). + const middles = await samplePart(page, MIDDLE, times); + const closes = await sampleCurve(close(page), times); + const samples = times.map((t, i) => ({ + t: +t.toFixed(1), + endX: ends[i].transform.translateX, + middleScale: middles[i].scaleX, + closeX: closes[i].transform.translateX, + closeOpacity: closes[i].opacity, + analytic: { + endX: -NARROWING * progress(t), + middleScale: 1 - (NARROWING / (width - 44)) * progress(t), + closeX: CLOSE_TRAVEL * (1 - progress(t)), + closeOpacity: progress(t), + }, + })); + await releaseAnimations(page, {finish: true}); + await writeEvidence( + 'curves/searchfield-activation', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {width, samples}, + null, + 1, + ), + }, + testInfo, + ); + + for (const sample of samples) { + const {analytic} = sample; + expect( + Math.abs(sample.endX - analytic.endX), + JSON.stringify(sample), + ).toBeLessThan(0.5); + expect( + Math.abs(sample.middleScale - analytic.middleScale) * (width - 44), + JSON.stringify(sample), + ).toBeLessThan(0.5); + expect( + Math.abs(sample.closeX - analytic.closeX), + JSON.stringify(sample), + ).toBeLessThan(0.5); + expect( + Math.abs(sample.closeOpacity - analytic.closeOpacity), + JSON.stringify(sample), + ).toBeLessThan(0.01); + } + await waitForRest(end(page)); + expect(await translateX(end(page))).toBeCloseTo(-NARROWING, 1); + await expect(close(page)).toBeVisible(); +}); + +test('a lifted field rises on the same clock', async ({page}) => { + const {driver, field} = await openField(page, 'lifted'); + await driver.tap(await centerOf(field)); + await waitForAnimation(root(page)); + await holdAnimations(page); + const ys = await sampleCurve(root(page), times); + const ends = await sampleCurve(end(page), times); + await releaseAnimations(page, {finish: true}); + times.forEach((t, i) => { + const sample = { + t, + y: ys[i].transform.translateY, + endX: ends[i].transform.translateX, + }; + expect( + Math.abs(sample.y + 53 * progress(t)), + JSON.stringify(sample), + ).toBeLessThan(0.5); + // One clock: the rise and the narrowing are at the same point of the curve. + expect( + Math.abs(sample.y / 53 - sample.endX / NARROWING), + JSON.stringify(sample), + ).toBeLessThan(0.01); + }); +}); + +test('in the Lifted story the results rise with the field and the title fades as UIKit’s does', async ({ + page, +}) => { + const {driver, field} = await openField(page, 'lifted'); + const content = page.locator('[data-search-story-content]'); + const title = page.getByRole('heading', {name: 'Search'}); + await driver.tap(await centerOf(field)); + await waitForAnimation(root(page)); + await holdAnimations(page); + const ys = await sampleCurve(root(page), times); + const contents = await sampleCurve(content, times); + const titles = await sampleCurve(title, times); + await releaseAnimations(page, {finish: true}); + times.forEach((t, i) => { + const p = progress(t); + const sample = { + t, + field: ys[i].transform.translateY, + content: contents[i].transform.translateY, + title: titles[i].opacity, + }; + // The results ride with the field: no gap opens under it. + expect( + Math.abs(sample.content - sample.field), + JSON.stringify(sample), + ).toBeLessThan(0.5); + expect( + Math.abs(sample.title - Math.max(0, 1 - p / 0.35) ** 2), + JSON.stringify(sample), + ).toBeLessThan(0.02); + }); +}); + +test('in the Lifted story a cancel brings the results down with the field and the title back from p = 0.65', async ({ + page, +}) => { + const {driver, field} = await openField(page, 'lifted'); + const content = page.locator('[data-search-story-content]'); + const title = page.getByRole('heading', {name: 'Search'}); + await driver.tap(await centerOf(field)); + await waitForAnimation(root(page)); + await waitForRest(root(page)); + await waitForRest(content); + await waitForRest(title); + await driver.tap(await centerOf(close(page))); + await waitForAnimation(content); + await holdAnimations(page); + const back = await sampleCurve(title, times); + const down = await sampleCurve(content, times); + const fieldDown = await sampleCurve(root(page), times); + await releaseAnimations(page, {finish: true}); + times.forEach((t, i) => { + const p = progress(t); + const sample = { + t, + title: back[i].opacity, + content: down[i].transform.translateY, + field: fieldDown[i].transform.translateY, + }; + expect( + Math.abs(sample.content - sample.field), + JSON.stringify(sample), + ).toBeLessThan(0.5); + expect( + Math.abs(sample.content + 53 * (1 - p)), + JSON.stringify(sample), + ).toBeLessThan(0.5); + expect( + Math.abs(sample.title - Math.min(1, Math.max(0, (p - 0.65) / 0.35))), + JSON.stringify(sample), + ).toBeLessThan(0.02); + }); +}); + +test('the close button cancels on the same clock, sliding out at full opacity', async ({ + page, +}, testInfo) => { + const {driver, field} = await openField(page); + await driver.tap(await centerOf(field)); + await waitForAnimation(end(page)); + await waitForRest(end(page)); + await waitForRest(close(page)); + await field.fill('pe'); + + await driver.tap(await centerOf(close(page))); + await waitForAnimation(end(page)); + await holdAnimations(page); + await expect(root(page)).not.toHaveAttribute('data-search-field-active'); + await expect(field).toHaveValue(''); + await expect(field).not.toBeFocused(); + const ends = await sampleCurve(end(page), times); + const closes = await sampleCurve(close(page), times); + const samples = times.map((t, i) => ({ + t: +t.toFixed(1), + endX: ends[i].transform.translateX, + closeX: closes[i].transform.translateX, + closeOpacity: closes[i].opacity, + })); + await releaseAnimations(page, {finish: true}); + await writeEvidence( + 'curves/searchfield-cancel', + {[`${testInfo.project.name}.json`]: JSON.stringify({samples}, null, 1)}, + testInfo, + ); + times.forEach((t, i) => { + const sample = samples[i]; + expect( + Math.abs(sample.endX + NARROWING * (1 - progress(t))), + JSON.stringify(sample), + ).toBeLessThan(0.5); + expect( + Math.abs(sample.closeX - CLOSE_TRAVEL * progress(t)), + JSON.stringify(sample), + ).toBeLessThan(0.5); + // Full opacity while it slides; removed at the end. + expect(sample.closeOpacity, JSON.stringify(sample)).toBe( + t < ACTIVATION_MS - 0.5 ? 1 : 0, + ); + }); + // Gone once it has slid out: transparent, untouchable, out of the + // accessibility tree. + await waitForRest(close(page)); + expect( + await close(page).evaluate(element => ({ + opacity: getComputedStyle(element).opacity, + pointerEvents: getComputedStyle(element).pointerEvents, + hidden: element.getAttribute('aria-hidden'), + })), + ).toEqual({opacity: '0', pointerEvents: 'none', hidden: 'true'}); +}); + +/** The largest step between neighbouring samples, px. */ +const largestStep = (xs: number[]) => + Math.max(...xs.slice(1).map((x, i) => Math.abs(x - xs[i]))); + +/** The activation's own fastest step of the trailing end per frame, px. */ +const PEAK_STEP = + Math.max( + ...Array.from( + {length: 200}, + (_, i) => (ease((i + 1) / 200) - ease(i / 200)) * 200, + ), + ) * + NARROWING * + (16.667 / ACTIVATION_MS); + +/** How many times the samples change direction (steps under 0.01 px skipped). */ +function turnsOf(xs: number[]) { + let turns = 0; + let direction = 0; + for (let i = 1; i < xs.length; i++) { + const step = xs[i] - xs[i - 1]; + if (Math.abs(step) < 0.01) { + continue; + } + const next = Math.sign(step); + if (direction !== 0 && next !== direction) { + turns++; + } + direction = next; + } + return turns; +} + +test('a cancel mid-activation turns around from where the parts are, without a jump', async ({ + page, +}) => { + const {driver, field} = await openField(page); + await driver.tap(await centerOf(field)); + const [forward] = await waitForAnimation(end(page), {minProgressMs: 150}); + const before = await translateX(end(page)); + expect(before).toBeLessThan(-2); + expect(before).toBeGreaterThan(-NARROWING + 2); + await driver.tap(await centerOf(close(page))); + const [reverse] = await waitForNewAnimation(end(page), forward.from); + await holdAnimations(page); + await expect(root(page)).not.toHaveAttribute('data-search-field-active'); + const samples = await sampleCurve(end(page), times); + await releaseAnimations(page, {finish: true}); + const xs = samples.map(sample => sample.transform.translateX); + // It starts where the activation had brought it (between rest and the + // narrowed end, at least as far along as before the second tap) and keeps + // its velocity through the turn, as UIKit's added animations do: it narrows + // on a little, turns once and widens back to rest, in small steps, on the + // whole clock. + const detail = JSON.stringify({before, xs}); + expect(xs[0], detail).toBeLessThanOrEqual(before + 0.5); + expect(xs[0], detail).toBeGreaterThan(-NARROWING); + expect(xs[1], detail).toBeLessThan(xs[0]); + expect(turnsOf(xs), detail).toBe(1); + expect(largestStep(xs), detail).toBeLessThan(PEAK_STEP + 0.5); + expect(reverse.to - reverse.from).toBeGreaterThanOrEqual(ACTIVATION_MS - 1); + await waitForRest(end(page)); + expect(Math.abs(await translateX(end(page)))).toBeLessThan(0.5); +}); + +test('a tap on the field during the cancel re-activates from where the parts are', async ({ + page, +}) => { + const {driver, field} = await openField(page); + await driver.tap(await centerOf(field)); + await waitForAnimation(end(page)); + await waitForRest(end(page)); + await waitForRest(close(page)); + await driver.tap(await centerOf(close(page))); + const [cancel] = await waitForAnimation(end(page), {minProgressMs: 150}); + const before = await translateX(end(page)); + expect(before).toBeGreaterThan(-NARROWING + 2); + expect(before).toBeLessThan(-2); + await driver.tap(await centerOf(field)); + const [again] = await waitForNewAnimation(end(page), cancel.from); + await holdAnimations(page); + const samples = await sampleCurve(end(page), times); + await releaseAnimations(page, {finish: true}); + await expect(root(page)).toHaveAttribute('data-search-field-active', 'true'); + const xs = samples.map(sample => sample.transform.translateX); + // From where the cancel had brought it, keeping its velocity through the + // turn: it widens on a little, turns once and narrows again, in small + // steps, on the whole clock. + const detail = JSON.stringify({before, xs}); + expect(xs[0], detail).toBeGreaterThanOrEqual(before - 0.5); + expect(xs[0], detail).toBeLessThan(0); + expect(xs[1], detail).toBeGreaterThan(xs[0]); + expect(turnsOf(xs), detail).toBe(1); + expect(largestStep(xs), detail).toBeLessThan(PEAK_STEP + 0.5); + expect(again.to - again.from).toBeGreaterThanOrEqual(ACTIVATION_MS - 1); + await waitForRest(end(page)); + expect(await translateX(end(page))).toBeCloseTo(-NARROWING, 1); + // The close button is back, past the time the cancel would have ended. + await page.waitForTimeout(ACTIVATION_MS); + await waitForRest(close(page)); + expect( + await close(page).evaluate(element => + Number(getComputedStyle(element).opacity), + ), + ).toBe(1); + expect(await translateX(close(page))).toBeCloseTo(0, 1); +}); + +// Polling a page whose input has focus repaints its document every frame in +// Chromium (the harness's waits read styles), so the next two proofs never +// poll while the activation runs: the page blocks itself once the activation +// has started, and the trace waits a fixed time. +test('the activation keeps advancing 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: the iOS simulator)', + ); + // The lifted field's rise: a 53 px travel on one element that nothing + // passes over (the close button slides over the field's trailing end, + // which would hide a marker there). Judged from 60 ms in, past the + // curve's slowest start, to 320 ms, before its slowest end. + const {driver, field} = await openField(page, 'lifted'); + const lifted = root(page); + await markElement(lifted); + const target = await centerOf(field); + const started = await recordActivationStart(lifted); + const cast = await startScreencast(page); + await driver.tap(target); + const block = await blockMainThread(page, 600, { + during: lifted, + minProgressMs: 60, + }); + await page.waitForTimeout(150); + const castFrames = await cast.stop(); + const slide = await started(); + const tracked = await trackMarker(page, castFrames); + const judged = {from: block.from, to: Math.min(block.to, slide.from + 320)}; + const proof = busyWindowProof(tracked, judged, { + axis: 'y', + settledWithin: 1, + }); + await writeEvidence( + 'busy-main-thread/searchfield-activation', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {block, slide, proof}, + null, + 1, + ), + }, + testInfo, + ); + // The verdict judges the motion: enough judged frames + // (`minJudgedFrames`), no stall over a few delivered frames, nearly every + // frame moved. The screencast's own delivery rate is not the animation's. + expect(proof.verdict.failures).toEqual([]); + expect(proof.frames).toBeGreaterThanOrEqual(proof.expectedFrames - 2); + expect(proof.changed).toBeGreaterThanOrEqual(proof.frames - 1); +}); + +test('the activation does no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {driver, field} = await openField(page); + const target = await centerOf(field); + const started = await recordActivationStart(close(page)); + const trace = await traceRendering(page, async () => { + await driver.tap(target); + await page.waitForTimeout(ACTIVATION_MS + 200); + }); + const slide = await started(); + expect(slide.to - slide.from).toBeGreaterThan(300); + // The activation's first frame (the commit that activates) may lay out. + // At the curve's end the animator writes the end values once (and Chromium + // repaints the document once); its clock starts at the commit, a few frames + // before the animation's start time, so that write lands up to ~50 ms + // before `slide.to`. Judge the frames before it. + const during = countBetween(trace, slide.from + 40, slide.to - 70); + const when = trace.events + .filter(e => e.name === 'Layout' || e.name === 'Paint') + .map(e => `${e.name}@${Math.round(e.t - slide.from)}`); + expect(during.Layout ?? 0, JSON.stringify({during, when})).toBe(0); + expect(during.Paint ?? 0, JSON.stringify({during, when})).toBe(0); +}); + +test('right to left: the field narrows from the left and the close button slides in from there', async ({ + page, +}) => { + const {driver, field} = await openField(page, 'right-to-left'); + await driver.tap(await centerOf(field)); + const [activation] = await waitForAnimation(end(page)); + await holdAnimations(page); + const mid = activation.from + (activation.to - activation.from) / 2; + const samples = await sampleCurve(close(page), [ + 0, + (mid - activation.from) * 0.5, + ]); + await releaseAnimations(page, {finish: true}); + // The close button starts past its place on the left (negative x) and + // moves right, toward it. + expect(samples[0].transform.translateX).toBeCloseTo(-CLOSE_TRAVEL, 0); + expect(samples[1].transform.translateX).toBeGreaterThan(-CLOSE_TRAVEL); + expect(samples[1].transform.translateX).toBeLessThan(0); + await waitForRest(end(page)); + // The trailing end is on the left: it moves right by what the field + // gives up. + expect(await translateX(end(page))).toBeCloseTo(NARROWING, 1); + expect(await translateX(close(page))).toBeCloseTo(0, 1); +}); + +test('the waiting close button does not widen the page: nothing scrolls sideways', async ({ + page, +}) => { + await openField(page); + // A full-bleed field, as on a phone screen: the root spans the viewport, + // so the inactive close button (resting 44 px past its place) would end + // 28 px past the screen's edge. + // (A phone browser widens its layout viewport to fit what overflows, so + // the page is compared with the screen's width, read first.) + const widths = await page.evaluate(() => { + const screenWidth = window.innerWidth; + const root = document.querySelector('[role="search"]') as HTMLElement; + const left = root.getBoundingClientRect().left; + root.style.width = `${screenWidth}px`; + root.style.marginInlineStart = `${-left}px`; + return { + scroll: document.documentElement.scrollWidth, + viewport: screenWidth, + layoutViewport: window.innerWidth, + }; + }); + expect(widths.scroll, JSON.stringify(widths)).toBeLessThanOrEqual( + widths.viewport, + ); +}); + +test('the CSS form of the active pose, painted before hydration, matches the placed pose', async ({ + page, +}) => { + const {driver, field} = await openField(page); + await driver.tap(await centerOf(field)); + await expect(root(page)).toHaveAttribute('data-search-field-active', 'true'); + await waitForRest(end(page)); + await waitForPartRest(page, MIDDLE); + // The trailing end is an element; the middle is the field's ::after. + const rects = async () => { + const endBox = await end(page).evaluate(element => { + const rect = element.getBoundingClientRect(); + return {left: rect.left, right: rect.right}; + }); + const middleBox = await partBox(page, MIDDLE); + return { + end: endBox, + middle: {left: middleBox.left, right: middleBox.right}, + }; + }; + const placed = await rects(); + // As a server render paints it: no placement yet, no held values. + await page.evaluate(() => { + const searchRoot = document.querySelector('[role="search"]') as HTMLElement; + searchRoot.removeAttribute('data-search-field-placed'); + const endPart = document.querySelector( + '[data-search-field-end]', + ) as HTMLElement; + const fieldBox = document.querySelector('[role="searchbox"]') + ?.parentElement as HTMLElement; + // The end's own animations, and the field's ::after (the middle), which + // the animator holds with a static filling animation. + for (const animation of [ + ...endPart.getAnimations(), + ...fieldBox + .getAnimations({subtree: true}) + .filter( + animation => + (animation.effect as KeyframeEffect | null)?.pseudoElement === + '::after', + ), + ]) { + animation.cancel(); + } + endPart.style.removeProperty('translate'); + endPart.style.removeProperty('scale'); + }); + const css = await rects(); + for (const part of ['end', 'middle'] as const) { + expect(Math.abs(css[part].right - placed[part].right), part).toBeLessThan( + 0.5, + ); + expect(Math.abs(css[part].left - placed[part].left), part).toBeLessThan( + 0.5, + ); + } +}); + +test('under a finger the close button is glass', async ({page}) => { + await openField(page); + const style = await close(page).evaluate(element => { + const computed = getComputedStyle(element); + return { + backdrop: computed.backdropFilter, + shadow: computed.boxShadow, + }; + }); + expect(style.backdrop).toContain('blur'); + expect(style.shadow).not.toBe('none'); +}); + +test('on Android (Chromium under a finger, no iOS signals) it plays the same activation, with the glass close button', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'Android browsers are Chromium'); + // One touch model on every touch device: nothing here answers iOS's + // `-webkit-touch-callout`, and the field still plays UIKit's activation. + expect( + await page.evaluate(() => CSS.supports('-webkit-touch-callout', 'none')), + ).toBe(false); + const {driver, field} = await openField(page); + await driver.tap(await centerOf(field)); + const [activation] = await waitForAnimation(end(page)); + expect(activation.to - activation.from).toBeCloseTo(ACTIVATION_MS, -1); + const style = await close(page).evaluate(element => { + const computed = getComputedStyle(element); + return {backdrop: computed.backdropFilter, shadow: computed.boxShadow}; + }); + expect(style.backdrop).toContain('blur'); + expect(style.shadow).not.toBe('none'); + await waitForRest(end(page)); + expect(await translateX(end(page))).toBeCloseTo(-NARROWING, 1); +}); + +test.describe('with a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('a click puts every part at its end at once, with no animation', async ({ + page, + }) => { + await openStory(page, storybook(), 'lab-searchfield--default'); + // A fine primary pointer, asserted in this test's own context. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const field = page.getByRole('searchbox', {name: 'Search'}); + await expect(field).toBeVisible(); + await field.click(); + await expect(root(page)).toHaveAttribute( + 'data-search-field-active', + 'true', + ); + // No animation moves anything. (The animator holds the middle, a + // pseudo-element with no inline style, by a static filling animation + // whose keyframes keep one value.) + const moving = await page.evaluate( + () => + ( + document + .querySelector('[role="search"]') + ?.getAnimations({subtree: true}) ?? [] + ).filter(animation => { + const frames = + (animation.effect as KeyframeEffect | null)?.getKeyframes() ?? []; + return Object.keys(frames[0] ?? {}).some( + key => + !['offset', 'computedOffset', 'easing', 'composite'].includes( + key, + ) && new Set(frames.map(frame => String(frame[key]))).size > 1, + ); + }).length, + ); + expect(moving).toBe(0); + expect(await translateX(end(page))).toBeCloseTo(-NARROWING, 1); + expect(await translateX(close(page))).toBeCloseTo(0, 1); + await close(page).click(); + await expect(root(page)).not.toHaveAttribute('data-search-field-active'); + expect(await translateX(end(page))).toBeCloseTo(0, 1); + }); +}); + +test.describe('with Reduce Motion', () => { + test('on a touch screen the activation and the cancel only cross-fade: no part moves or scales', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver, field} = await openField(page); + // design:reduced-motion DR1 and DR2: opacity only, 200 ms or less. The + // page records each animation as it starts, so a busy runner cannot make + // the proof miss one. Each part's travel lands while it is transparent, + // then it fades back in: the proof reads once the travel has landed and + // every part has come to rest. + await recordFades(page); + await driver.tap(await centerOf(field)); + await expect + .poll(async () => translateX(end(page))) + .toBeCloseTo(-NARROWING, 1); + await waitForRest(end(page)); + await waitForRest(close(page)); + const activation = await readFades(page); + expect(activation.properties).toEqual(['opacity']); + expect(activation.fadeMs).toBeLessThanOrEqual(200); + await recordFades(page); + await driver.tap(await centerOf(close(page))); + await expect + .poll(async () => Math.abs(await translateX(end(page)))) + .toBeLessThan(0.5); + await waitForRest(end(page)); + await waitForRest(close(page)); + const cancel = await readFades(page); + expect(cancel.properties).toEqual(['opacity']); + expect(cancel.fadeMs).toBeLessThanOrEqual(200); + }); + + test.describe('and a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('a click changes every part at once, with no animation', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await openStory(page, storybook(), 'lab-searchfield--default'); + // A fine primary pointer, asserted in this test's own context. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const field = page.getByRole('searchbox', {name: 'Search'}); + await expect(field).toBeVisible(); + await recordFades(page); + await field.click(); + await expect(root(page)).toHaveAttribute( + 'data-search-field-active', + 'true', + ); + await waitForRest(end(page)); + // design:reduced-motion DR3: nothing animates. + expect((await readFades(page)).properties).toEqual([]); + expect(await translateX(end(page))).toBeCloseTo(-NARROWING, 1); + }); + }); +}); + +test( + 'storyboard: a tap activates the field', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, field} = await openField(page); + await driver.tap(await centerOf(field)); + await waitForAnimation(end(page)); + const box = await root(page).boundingBox(); + if (box == null) { + throw new Error('no field'); + } + const {storyboard} = await animationStoryboard(page, { + name: 'searchfield-activation', + clip: { + x: 0, + y: box.y - 12, + width: box.x * 2 + box.width, + height: box.height + 24, + }, + times: Array.from({length: 25}, (_, i) => i * 16.667), + measure: async () => ({ + endX: await translateX(end(page)), + closeX: await translateX(close(page)), + }), + columns: 5, + cellScale: 0.6, + }); + await storyboard.write(testInfo); + await releaseAnimations(page, {finish: true}); + const frames = storyboard.frames.map( + f => f.data as {endX: number; closeX: number}, + ); + expect(Math.abs(frames[0].endX)).toBeLessThan(0.5); + expect(frames[0].closeX).toBeGreaterThan(CLOSE_TRAVEL - 0.5); + expect(frames.at(-1)?.endX ?? 0).toBeLessThan(-NARROWING + 0.5); + expect(Math.abs(frames.at(-1)?.closeX ?? Infinity)).toBeLessThan(0.5); + }, +); + +test( + 'storyboard: the close button cancels', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, field} = await openField(page, 'active-with-query'); + await expect(field).toHaveValue('ap'); + await driver.tap(await centerOf(close(page))); + await waitForAnimation(end(page)); + const box = await root(page).boundingBox(); + if (box == null) { + throw new Error('no field'); + } + const {storyboard} = await animationStoryboard(page, { + name: 'searchfield-cancel', + clip: { + x: 0, + y: box.y - 12, + width: box.x * 2 + box.width, + height: box.height + 24, + }, + times: Array.from({length: 25}, (_, i) => i * 16.667), + measure: async () => ({ + endX: await translateX(end(page)), + closeX: await translateX(close(page)), + }), + columns: 5, + cellScale: 0.6, + }); + await storyboard.write(testInfo); + await releaseAnimations(page, {finish: true}); + const frames = storyboard.frames.map( + f => f.data as {endX: number; closeX: number}, + ); + expect(frames[0].endX).toBeLessThan(-NARROWING + 0.5); + expect(Math.abs(frames.at(-1)?.endX ?? Infinity)).toBeLessThan(0.5); + expect(frames.at(-1)?.closeX ?? 0).toBeGreaterThan(CLOSE_TRAVEL - 0.5); + }, +); + +test( + 'storyboard: a cancel mid-activation turns around', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, field} = await openField(page); + await driver.tap(await centerOf(field)); + const [forward] = await waitForAnimation(end(page), {minProgressMs: 120}); + await driver.tap(await centerOf(close(page))); + await waitForNewAnimation(end(page), forward.from); + const box = await root(page).boundingBox(); + if (box == null) { + throw new Error('no field'); + } + const {storyboard} = await animationStoryboard(page, { + name: 'searchfield-cancel-mid-activation', + clip: { + x: 0, + y: box.y - 12, + width: box.x * 2 + box.width, + height: box.height + 24, + }, + // The whole clock: the turn adds to the curves on screen. + times: Array.from({length: 25}, (_, i) => i * 16.667), + measure: async () => ({ + endX: await translateX(end(page)), + closeX: await translateX(close(page)), + }), + columns: 5, + cellScale: 0.6, + }); + await storyboard.write(testInfo); + await releaseAnimations(page, {finish: true}); + const frames = storyboard.frames.map( + f => f.data as {endX: number; closeX: number}, + ); + // It starts part-way, not at either end, and widens back to rest. + expect(frames[0].endX).toBeLessThan(-0.5); + expect(frames[0].endX).toBeGreaterThan(-NARROWING + 0.5); + expect(Math.abs(frames.at(-1)?.endX ?? Infinity)).toBeLessThan(0.5); + }, +); diff --git a/packages/lab/src/SearchField/__tests__/SearchFieldBottom.interaction.browser.spec.ts b/packages/lab/src/SearchField/__tests__/SearchFieldBottom.interaction.browser.spec.ts new file mode 100644 index 0000000000000..e93c3dfcb55e3 --- /dev/null +++ b/packages/lab/src/SearchField/__tests__/SearchFieldBottom.interaction.browser.spec.ts @@ -0,0 +1,1496 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file SearchFieldBottom.interaction.browser.spec.ts + * @input The built "Lab/SearchField" Bottom story on the harness's phone + * (a finger, so the touch presentation applies), a stand-in visual + * viewport that reports a software keyboard, real touch input (Chromium + * CDP / WebKit's input command), the UIKit search table and the + * interaction harness + * @output Proofs that the bottom (toolbar) placement moves as iOS 26.2's + * does under any finger: a tap plays the measured activation on every part + * (the field's swell and narrowing, the close button growing in) within + * half a pixel; a change of mind turns around from where the field is; a + * tap focuses without Safari's reveal (preventScroll), puts the caret where + * the finger lifted and keeps its late mouse events from the page; a + * resize while active rescales the middle at once; the field + * keeps above the keyboard by layout (its centre 36 px above the keyboard's + * top), each keyboard report moving it at once with no animation and no + * prediction (spec:AST-099 FR8: a Lab surface commits at rest); the close + * button narrows the field 16 px at each side, springs it out to rest and + * shrinks the button in place; under reduced motion only opacity fades, + * within 200 ms; where the browser also reports the keyboard itself + * (Chrome on Android) the field still claims the tap and measures the + * visual viewport, leaving the app's keyboard setting alone; under a mouse + * the parts take their places at once. + * Storyboards of the activation and the cancel + * @position Interaction proof for lab SearchField's bottom placement + * (SearchField.tsx, SearchFieldTouch.ts, searchFieldModel.ts). Headless + * browsers have no software keyboard, so the page's `visualViewport` is + * replaced by one the test shrinks the way Mobile Safari does (one + * `resize`, final height); Mobile Safari itself is proven on the iOS + * simulator (pull request evidence) + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + armHold, + holdAnimations, + openStory, + phone, + releaseAnimations, + sampleCurve, + timeline, + touchDriver, + waitForAnimation, + waitForRest, + withStorybook, + writeEvidence, + PEN_SKIP, + webInputDriver, + type HeldAnimation, +} from '@astryxdesign/interaction-harness'; + +import {readFades, recordFades} from './fadeRecorder'; +import {END_CAP, MIDDLE, START, partBox, samplePart} from './pseudoParts'; + +const storybook = withStorybook(test); + +// The bottom placement's measurements, as in the UIKit table +// (`search.bottom`, motion/uikit/search.ts, which search.test.ts holds +// together): the field's geometry and its measured frames, [ms, leading +// progress, trailing progress, height scale] for the field and [ms, opacity, +// scale, x] for the close button. Spelled out here because a spec runs +// against the built Storybook, outside the packages' builds. +const bottom = { + fieldHeight: 38, + keyboardCenterOffset: 36, + restInset: 33, + activeInset: 17, + closeSize: 38, + closeGap: 22, + cancelNarrowing: 16, + // prettier-ignore + activationFrames: [[0, 0, 0, 1], [66, 1.076, -0.365, 1.003], [87, 1.008, -0.35, 0.999], [103, 0.994, -0.203, 0.998], [120, 1.099, -0.019, 1.076], [137, 1.201, 0.183, 1.152], [153, 1.243, 0.387, 1.162], [170, 1.206, 0.581, 1.115], [187, 1.146, 0.742, 1.059], [203, 1.042, 0.877, 1.014], [220, 0.994, 0.96, 0.989], [237, 0.946, 1.017, 0.979], [253, 0.933, 1.043, 0.979], [270, 0.931, 1.052, 0.983], [287, 0.938, 1.051, 0.989], [303, 0.952, 1.043, 0.994], [320, 0.964, 1.033, 0.997], [337, 0.978, 1.024, 0.998], [353, 0.986, 1.016, 0.999], [370, 0.993, 1.002, 0.999], [387, 0.996, 1.002, 0.999], [403, 0.999, 1, 1], [420, 0.999, 1, 1], [437, 0.999, 1, 1], [453, 1, 1, 1]], + // prettier-ignore + cancelFrames: [[0, 0, 1.364, 1], [17, 0, 1.364, 1], [34, 0, 1.252, 1], [51, -0.004, 0.948, 1.079], [67, -0.007, 0.631, 1.144], [84, -0.009, 0.351, 1.191], [101, -0.009, 0.15, 1.2], [117, -0.009, 0.001, 1.202], [134, -0.009, -0.103, 1.176], [151, -0.008, -0.142, 1.119], [167, -0.006, -0.128, 1.052], [184, -0.003, -0.094, 1.02], [201, -0.001, -0.058, 0.992], [217, 0, -0.031, 0.985], [234, 0.001, -0.013, 0.984], [251, 0.001, -0.003, 0.988], [267, 0.001, 0, 0.994], [284, 0.001, 0.001, 0.997], [301, 0, 0, 1]], + // prettier-ignore + closeEnterFrames: [[0, 0, 0.2, -16.67], [153, 0, 0.238, -16.67], [170, 0.024, 0.257, -16.26], [187, 0.067, 0.287, -15.55], [203, 0.157, 0.348, -14.05], [220, 0.228, 0.396, -12.86], [237, 0.342, 0.474, -10.96], [253, 0.42, 0.53, -9.67], [270, 0.532, 0.615, -7.81], [287, 0.602, 0.671, -6.64], [303, 0.698, 0.75, -5.04], [320, 0.755, 0.798, -4.09], [337, 0.829, 0.861, -2.85], [353, 0.871, 0.896, -2.15], [370, 0.923, 0.939, -1.28], [387, 0.958, 0.966, -0.7], [403, 0.985, 0.988, -0.25], [420, 1, 1.005, 0.1], [437, 1, 1.015, 0.31], [453, 1, 1.025, 0.52], [470, 1, 1.029, 0.61], [487, 1, 1.033, 0.68], [503, 1, 1.033, 0.7], [520, 1, 1.033, 0.68], [537, 1, 1.031, 0.65], [553, 1, 1.028, 0.59], [570, 1, 1.026, 0.54], [587, 1, 1.022, 0.46], [603, 1, 1.019, 0.41], [620, 1, 1.016, 0.33], [637, 1, 1.013, 0.27], [653, 1, 1.011, 0.22], [670, 1, 1.008, 0.16], [687, 1, 1.006, 0], [703, 1, 1, 0]], + // prettier-ignore + closeExitFrames: [[0, 1, 1.069], [17, 1, 1.387], [34, 0.918, 1.282], [51, 0.76, 1.083], [67, 0.581, 0.861], [84, 0.414, 0.66], [101, 0.274, 0.497], [117, 0.167, 0.376], [134, 0.089, 0.291], [151, 0.037, 0.236], [167, 0.005, 0.201], [184, 0, 0.182]], +} as const; + +// The cover Mobile Safari reports on an iPhone 17 Pro. +const COVER = 337; +// The phone's width: the field's edges as on a 402 pt iPhone 17 Pro. +const WIDTH = phone.viewport.width; +const REST = {leading: bottom.restInset, trailing: WIDTH - bottom.restInset}; +const ACTIVE = { + leading: bottom.activeInset, + trailing: WIDTH - bottom.activeInset - bottom.closeSize - bottom.closeGap, +}; +const CAP = bottom.fieldHeight / 2; + +/** A frame table's column at `ms`, linear between rows, held at the ends. */ +function frameAt( + table: readonly (readonly number[])[], + column: number, + ms: number, +) { + if (ms <= table[0][0]) { + return table[0][column]; + } + const last = table[table.length - 1]; + if (ms >= last[0]) { + return last[column]; + } + let i = 1; + while (table[i][0] < ms) { + i++; + } + const a = table[i - 1]; + const b = table[i]; + return a[column] + ((b[column] - a[column]) * (ms - a[0])) / (b[0] - a[0]); +} + +/** The field's edges at `ms` into a gel table, px. */ +function edgesAt(table: readonly (readonly number[])[], ms: number) { + return { + leading: + REST.leading + frameAt(table, 1, ms) * (ACTIVE.leading - REST.leading), + trailing: + REST.trailing + frameAt(table, 2, ms) * (ACTIVE.trailing - REST.trailing), + heightScale: frameAt(table, 3, ms), + }; +} + +/** + * Before the page loads: `window.visualViewport` becomes one the test + * drives: `__keyboard(cover)` shrinks it from the bottom and fires one + * `resize`, as Mobile Safari and Chrome on Android do. Mobile Safari has no + * VirtualKeyboard API, so it is removed (Chromium has one), unless + * `virtualKeyboardApi` asks for Chrome on Android's: then + * `navigator.virtualKeyboard` is one the test drives, and + * `__chromeKeyboard(cover)` sets its rect and fires `geometrychange`, as + * Chrome does when a page overlays the keyboard. + */ +async function installKeyboard( + page: Page, + {virtualKeyboardApi = false}: {virtualKeyboardApi?: boolean} = {}, +) { + await page.addInitScript(withApi => { + if (withApi) { + const keyboard = Object.assign(new EventTarget(), { + overlaysContent: false, + boundingRect: new DOMRect(0, 0, 0, 0), + }); + Object.defineProperty(navigator, 'virtualKeyboard', { + configurable: true, + value: keyboard, + }); + ( + window as unknown as {__chromeKeyboard: (cover: number) => void} + ).__chromeKeyboard = cover => { + keyboard.boundingRect = new DOMRect( + 0, + window.innerHeight - cover, + window.innerWidth, + cover, + ); + keyboard.dispatchEvent(new Event('geometrychange')); + }; + } else { + Reflect.deleteProperty(Navigator.prototype, 'virtualKeyboard'); + Reflect.deleteProperty(navigator, 'virtualKeyboard'); + } + const viewport = Object.assign(new EventTarget(), { + height: window.innerHeight, + width: window.innerWidth, + offsetTop: 0, + offsetLeft: 0, + pageTop: 0, + pageLeft: 0, + scale: 1, + }); + Object.defineProperty(window, 'visualViewport', { + configurable: true, + get: () => viewport, + }); + (window as unknown as {__keyboard: (cover: number) => void}).__keyboard = + cover => { + viewport.height = window.innerHeight - cover; + viewport.dispatchEvent(new Event('resize')); + }; + }, virtualKeyboardApi); +} + +const showKeyboard = async (page: Page, cover = COVER) => + page.evaluate( + c => (window as unknown as {__keyboard: (c: number) => void}).__keyboard(c), + cover, + ); + +/** Chrome on Android's own keyboard report (with `virtualKeyboardApi`). */ +const reportChromeKeyboard = async (page: Page, cover = COVER) => + page.evaluate( + c => + ( + window as unknown as {__chromeKeyboard: (c: number) => void} + ).__chromeKeyboard(c), + cover, + ); + +/** Whether the keyboard overlays the page (Chrome's `overlaysContent`). */ +const overlaysContent = async (page: Page) => + page.evaluate( + () => + (navigator as unknown as {virtualKeyboard: {overlaysContent: boolean}}) + .virtualKeyboard.overlaysContent, + ); + +/** The field's computed `touch-action` (the gesture host writes it). */ +const touchActionOf = async (page: Page) => + root(page).evaluate(element => + element.firstElementChild == null + ? null + : getComputedStyle(element.firstElementChild).touchAction, + ); + +/** How many animations the root itself runs. */ +const rootAnimations = async (page: Page) => + root(page).evaluate(element => element.getAnimations().length); + +async function openBottom( + page: Page, + {virtualKeyboardApi = false}: {virtualKeyboardApi?: boolean} = {}, +) { + await installKeyboard(page, {virtualKeyboardApi}); + await openStory(page, storybook(), 'lab-searchfield--bottom'); + const driver = await touchDriver(page); + const field = page.getByRole('searchbox', {name: 'Search'}); + await expect(field).toBeVisible(); + await waitForRest(page.locator('[data-search-field-end]')); + return {driver, field}; +} + +const root = (page: Page) => page.getByRole('search'); +const end = (page: Page) => page.locator('[data-search-field-end]'); +const close = (page: Page) => page.locator('[data-search-field-close]'); + +async function centerOf(locator: Locator) { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('no box'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +/** The presented `translate`, px: [x, y]. */ +const translate = async (locator: Locator) => + locator.evaluate(element => { + const value = getComputedStyle(element).translate; + if (value === 'none') { + return [0, 0]; + } + const [x, y] = value.split(' ').map(part => parseFloat(part) || 0); + return [x ?? 0, y ?? 0]; + }); + +/** The root's layout lift above its resting place (its `bottom`), px. */ +const liftOf = async (page: Page) => + root(page).evaluate(element => parseFloat(getComputedStyle(element).bottom)); + +/** Where the field's resting centre sits above the viewport's bottom, px. */ +const restGapOf = async (page: Page) => + root(page).evaluate(element => { + const rect = element.getBoundingClientRect(); + const lift = parseFloat(getComputedStyle(element).bottom) || 0; + return window.innerHeight - (rect.top + lift + rect.height / 2); + }); + +/** Opens the Bottom story, activates it and brings the keyboard up. */ +async function openActive(page: Page) { + const {driver, field} = await openBottom(page); + await driver.tap(await centerOf(field)); + await showKeyboard(page); + await expect(root(page)).toHaveAttribute('data-search-field-active', 'true'); + await waitForRest(close(page)); + await waitForRest(root(page)); + return {driver, field}; +} + +test('a tap plays iOS’s measured activation on every part', async ({ + page, +}, testInfo) => { + const {driver, field} = await openBottom(page); + await driver.tap(await centerOf(field)); + await waitForAnimation(end(page)); + const held = await holdAnimations(page); + await expect(root(page)).toHaveAttribute('data-search-field-active', 'true'); + const curveEnd = Math.max(...held.map(a => a.curveEndMs)); + const times = timeline(curveEnd); + const [ends, closes] = await Promise.all( + [end, close].map(async part => sampleCurve(part(page), times)), + ); + // The rounded ends and the middle are pseudo-elements (pseudoParts.ts), + // each sampled by seeking every held animation: one after another. + const starts = await samplePart(page, START, times); + const middles = await samplePart(page, MIDDLE, times); + const caps = await samplePart(page, END_CAP, times); + await releaseAnimations(page, {finish: true}); + const samples = times.map((t, i) => { + const edges = edgesAt(bottom.activationFrames, t); + return { + t: +t.toFixed(1), + startX: starts[i].translateX, + middleScale: middles[i].scaleX, + endX: ends[i].transform.translateX, + heightScale: caps[i].scaleY, + closeX: closes[i].transform.translateX, + closeScale: closes[i].transform.scaleX, + closeOpacity: closes[i].opacity, + measured: { + startX: edges.leading - REST.leading, + middleScale: + (edges.trailing - edges.leading - 2 * CAP) / + (REST.trailing - REST.leading - 2 * CAP), + endX: edges.trailing - REST.trailing, + heightScale: edges.heightScale, + closeX: frameAt(bottom.closeEnterFrames, 3, t), + closeScale: frameAt(bottom.closeEnterFrames, 2, t), + closeOpacity: frameAt(bottom.closeEnterFrames, 1, t), + }, + }; + }); + await writeEvidence( + 'curves/searchfield-bottom-activation', + {[`${testInfo.project.name}.json`]: JSON.stringify(samples, null, 1)}, + testInfo, + ); + for (const sample of samples) { + const {measured} = sample; + const at = `at ${sample.t} ms`; + expect(Math.abs(sample.startX - measured.startX), at).toBeLessThan(0.5); + expect(Math.abs(sample.endX - measured.endX), at).toBeLessThan(0.5); + expect( + Math.abs(sample.middleScale - measured.middleScale) * + (REST.trailing - REST.leading - 2 * CAP), + at, + ).toBeLessThan(0.5); + expect( + Math.abs(sample.heightScale - measured.heightScale), + at, + ).toBeLessThan(0.01); + expect(Math.abs(sample.closeX - measured.closeX), at).toBeLessThan(0.5); + expect(Math.abs(sample.closeScale - measured.closeScale), at).toBeLessThan( + 0.01, + ); + expect( + Math.abs(sample.closeOpacity - measured.closeOpacity), + at, + ).toBeLessThan(0.02); + } + // It swells past rest before it narrows, as iOS's field does. + expect(Math.max(...samples.map(s => s.endX))).toBeGreaterThan(10); + await waitForRest(end(page)); + expect((await translate(end(page)))[0]).toBeCloseTo( + ACTIVE.trailing - REST.trailing, + 1, + ); +}); + +/** + * The trailing end's samples from its own start. The parts' turns start a few + * ms apart (each is created after the one before it), and a hold samples from + * the earliest start among them: until the end's own start it reads its first + * keyframe, the same presented frame again. On a slow runner that gap passes a + * sample step (24 ms at 8x CPU throttling), so the repeats are dropped and + * every sample left is a new frame. + */ +function fromOwnStart(xs: number[]): number[] { + let first = 0; + while (first + 1 < xs.length && xs[first + 1] === xs[first]) { + first += 1; + } + return xs.slice(first); +} + +/** When a frame table column reaches its largest or smallest value, ms. */ +function extremeMs( + table: readonly (readonly number[])[], + column: number, + pick: 'max' | 'min', +): number { + let best = table[0]; + for (const row of table) { + if ( + pick === 'max' ? row[column] > best[column] : row[column] < best[column] + ) { + best = row; + } + } + return best[0]; +} + +/** When the activation's trailing edge narrows furthest: the gel's peak, ms. */ +const ACTIVATION_PEAK_MS = extremeMs(bottom.activationFrames, 2, 'max'); +/** When the cancel's trailing edge widens furthest, past rest, ms. */ +const CANCEL_PEAK_MS = extremeMs(bottom.cancelFrames, 2, 'min'); + +/** One frame at 60 Hz, ms: the step between samples. */ +const FRAME_MS = 1000 / 60; + +/** + * Where the change-of-mind proofs turn each motion, on its own clock, so the + * turn's own motion starts while the gel is still going toward its peak + * (270 ms and 151 ms): it starts a frame or two after the input, more on a + * busy runner, so the cancel is turned on its first frame. + */ +const ACTIVATION_TURN_AT_MS = 120; +const CANCEL_TURN_AT_MS = 0; + +/** What armTurn's trigger saw when it turned the field. */ +interface Turn { + /** The motion's own time at the turning frame, ms. */ + readonly atMs: number; + /** When the motion started, on the page clock (as `ActiveAnimation.from`). */ + readonly motionFrom: number; + /** The trailing end's presented translate there, px, read just before. */ + readonly endX: number; + /** When the turn's own motion started, on the page clock. */ + readonly turnFrom: number; +} + +/** + * Arms a turn in the page: on the first frame the trailing end's next motion + * (an animation that starts after this call) reaches `atMs` on its own clock, + * `close` clicks the close button and `focus` focuses the field. Armed before + * the input that starts the motion, the turn lands at that point of the + * motion however late a busy runner delivers either input; a scripted tap + * would land wherever the runner's delay put it, even past the peak. It + * also reports when the turn's own motion started. + */ +async function armTurn(page: Page, atMs: number, action: 'close' | 'focus') { + await page.evaluate( + ([ms, what]) => { + const w = window as unknown as {__turn?: Turn | {error: string}}; + delete w.__turn; + const field = document.querySelector('[role="search"]'); + const trailing = field?.querySelector('[data-search-field-end]'); + if (field == null || !(trailing instanceof HTMLElement)) { + w.__turn = {error: 'armTurn: no search field on the page'}; + return; + } + const armedAt = Number(document.timeline.currentTime); + const deadline = performance.now() + 10_000; + // Looks for the turn's motion (a trailing-end animation the turn + // started) right after the turn's own task, then on every frame and + // every 4 ms between them. + const findTurn = ( + at: {atMs: number; motionFrom: number; endX: number}, + before: Set, + ) => { + let done = false; + let round = 0; + // Of the frame and the timer a round schedules, the first looks. + const schedule = () => { + const mine = ++round; + const run = () => { + if (mine === round) { + look(); + } + }; + requestAnimationFrame(run); + setTimeout(run, 4); + }; + const look = () => { + if (done) { + return; + } + const fresh = trailing + .getAnimations() + .find(animation => !before.has(animation)); + if (fresh != null) { + done = true; + const start = + fresh.startTime != null + ? Number(fresh.startTime) + : Number(document.timeline.currentTime) - + Number(fresh.currentTime ?? 0); + w.__turn = {...at, turnFrom: performance.timeOrigin + start}; + } else if (performance.now() > deadline) { + done = true; + w.__turn = {error: 'armTurn: the turn started no motion'}; + } else { + schedule(); + } + }; + queueMicrotask(look); + }; + const step = () => { + let motion: Animation | null = null; + for (const animation of trailing.getAnimations()) { + if ( + animation.startTime != null && + Number(animation.startTime) > armedAt && + (motion == null || + Number(animation.startTime) > Number(motion.startTime)) + ) { + motion = animation; + } + } + const at = motion == null ? -1 : Number(motion.currentTime); + if (motion != null && at >= ms) { + const value = getComputedStyle(trailing).translate; + const turn = { + atMs: at, + motionFrom: performance.timeOrigin + Number(motion.startTime), + endX: value === 'none' ? 0 : parseFloat(value), + }; + const target = field.querySelector( + what === 'close' ? '[data-search-field-close]' : 'input', + ); + if (!(target instanceof HTMLElement)) { + w.__turn = {error: `armTurn: no ${what} target`}; + return; + } + const before = new Set(trailing.getAnimations()); + if (what === 'close') { + target.click(); + } else { + target.focus({preventScroll: true}); + } + findTurn(turn, before); + return; + } + if (performance.now() > deadline) { + w.__turn = {error: `armTurn: no motion reached ${ms} ms`}; + return; + } + later(); + }; + // Looked at on every frame and every 4 ms between them, as `armHold` + // does: WebKit can present no frame for hundreds of milliseconds while + // its animations run on. + let round = 0; + const later = () => { + const mine = ++round; + const run = () => { + if (mine === round) { + step(); + } + }; + requestAnimationFrame(run); + setTimeout(run, 4); + }; + later(); + }, + [atMs, action] as const, + ); +} + +/** Where the held trailing end's motion stops changing, ms from its start. */ +function curveMsOf(held: readonly HeldAnimation[]): number { + const motion = held.find(animation => + animation.keyframes.some(keyframe => 'translate' in keyframe), + ); + if (motion == null) { + throw new Error( + `the hold took no trailing-end motion: ${JSON.stringify(held)}`, + ); + } + return motion.curveEndMs; +} + +/** The turn armTurn made, once it has. */ +async function turned(page: Page): Promise { + const handle = await page.waitForFunction( + () => (window as unknown as {__turn?: Turn | {error: string}}).__turn, + null, + {timeout: 15_000}, + ); + const turn = (await handle.jsonValue()) as Turn | {error: string}; + if ('error' in turn) { + throw new Error(turn.error); + } + return turn; +} + +/** The largest step between neighbouring samples, px. */ +const largestStep = (xs: number[]) => + Math.max(...xs.slice(1).map((x, i) => Math.abs(x - xs[i]))); + +/** How many times the samples change direction (steps under 0.01 px skipped). */ +function turnsOf(xs: number[]) { + let turns = 0; + let direction = 0; + for (let i = 1; i < xs.length; i++) { + const step = xs[i] - xs[i - 1]; + if (Math.abs(step) < 0.01) { + continue; + } + const next = Math.sign(step); + if (direction !== 0 && next !== direction) { + turns++; + } + direction = next; + } + return turns; +} + +/** The measured gel's own fastest step of the trailing edge per frame, px. */ +const GEL_PEAK_STEP = Math.max( + ...[bottom.activationFrames, bottom.cancelFrames].flatMap(table => + table + .slice(1) + .map( + (row, i) => + (Math.abs(row[2] - table[i][2]) * + Math.abs(ACTIVE.trailing - REST.trailing) * + 16.667) / + (row[0] - table[i][0]), + ), + ), +); + +test('a cancel mid-activation turns around from where the field is, without a snap', async ({ + page, +}) => { + const {field} = await openBottom(page); + // The page holds the turn's own motion: armed before the activation, the + // hold counts only animations that start after it, once they have run as + // long as the turn may start at the latest; the activation's end at the + // turn, before that. + const hold = await armHold(page, end(page), { + newOnly: true, + minProgressMs: ACTIVATION_PEAK_MS - 2 * FRAME_MS, + }); + // The close button's click lands ACTIVATION_TURN_AT_MS into the + // activation, on the activation's own clock. The activation starts in the + // page too, from the field's focus: a scripted tap's dispatch can keep the + // page from looking at the motion for hundreds of milliseconds as it starts + // on a busy runner (on two shared cores, 2 of 24 tap-started cancels were + // first seen 250 and 350 ms in; 0 of 24 click-started ones). + await armTurn(page, ACTIVATION_TURN_AT_MS, 'close'); + await field.evaluate(element => + (element as HTMLInputElement).focus({preventScroll: true}), + ); + const turn = await turned(page); + const before = turn.endX; + const times = timeline(curveMsOf(await hold.held())); + await expect(root(page)).not.toHaveAttribute('data-search-field-active'); + const xs = fromOwnStart( + (await sampleCurve(end(page), times)).map( + sample => sample.transform.translateX, + ), + ); + await releaseAnimations(page, {finish: true}); + // The measured cancel starts from the settled active field, so replaying + // it from its first row snapped the trailing edge to it (32 px on this + // screen). The turn adds to the curves on screen instead, as UIKit's + // animations do: it starts where the activation had brought the field + // (further along than before the second tap; the gel itself overshoots + // the end), keeps narrowing for a moment, since the turn starts before the + // gel's peak, turns once and widens back to rest, never stepping faster + // than the measured gel itself. + const turnAtMs = turn.turnFrom - turn.motionFrom; + const detail = JSON.stringify({turn, turnAtMs, xs}); + expect(turnAtMs, detail).toBeLessThan(ACTIVATION_PEAK_MS - 2 * FRAME_MS); + expect(xs[0], detail).toBeLessThanOrEqual(before + 0.5); + expect(xs[1], detail).toBeLessThan(xs[0]); + expect(turnsOf(xs), detail).toBe(1); + expect(largestStep(xs), detail).toBeLessThan(GEL_PEAK_STEP + 0.5); + await waitForRest(end(page)); + expect(Math.abs((await translate(end(page)))[0])).toBeLessThan(0.5); +}); + +test('focusing the field early in a cancel re-activates from where the field is, without a snap', async ({ + page, +}) => { + await openActive(page); + // The page holds the turn's own motion, as in the cancel above. + const hold = await armHold(page, end(page), { + newOnly: true, + minProgressMs: CANCEL_PEAK_MS - 2 * FRAME_MS, + }); + // The bar is moving to its resting gap by then, faster than a scripted + // tap can land on it; the field's own tap focuses it this way, here + // CANCEL_TURN_AT_MS into the cancel on the cancel's own clock. + await armTurn(page, CANCEL_TURN_AT_MS, 'focus'); + // The cancel starts from the close button's click in the page, as the + // activation does above. + await close(page).evaluate(element => (element as HTMLElement).click()); + const turn = await turned(page); + const before = turn.endX; + const times = timeline(curveMsOf(await hold.held())); + await expect(root(page)).toHaveAttribute('data-search-field-active', 'true'); + const xs = fromOwnStart( + (await sampleCurve(end(page), times)).map( + sample => sample.transform.translateX, + ), + ); + await releaseAnimations(page, {finish: true}); + // From where the cancel had brought it (further toward rest than before + // the turn), it keeps widening for a moment, since the turn starts before + // the cancel's peak, turns once and narrows again, never stepping faster + // than the measured gel itself. + const turnAtMs = turn.turnFrom - turn.motionFrom; + const detail = JSON.stringify({turn, turnAtMs, xs}); + expect(turnAtMs, detail).toBeLessThan(CANCEL_PEAK_MS - 2 * FRAME_MS); + expect(xs[0], detail).toBeGreaterThanOrEqual(before - 0.5); + expect(xs[1], detail).toBeGreaterThan(xs[0]); + expect(turnsOf(xs), detail).toBe(1); + expect(largestStep(xs), detail).toBeLessThan(GEL_PEAK_STEP + 0.5); + await waitForRest(end(page)); + expect((await translate(end(page)))[0]).toBeCloseTo( + ACTIVE.trailing - REST.trailing, + 1, + ); +}); + +test('a tap focuses the field without Safari’s reveal', async ({page}) => { + const {driver, field} = await openBottom(page); + // Every focus the page makes, with the options it passes. + await page.evaluate(() => { + const calls: unknown[] = []; + (window as unknown as {__focusCalls: unknown[]}).__focusCalls = calls; + const focus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = function (options?: FocusOptions) { + calls.push({role: this.getAttribute('role'), options: options ?? null}); + focus.call(this, options); + }; + }); + const scrollBefore = await page.evaluate(() => window.scrollY); + await driver.tap(await centerOf(field)); + await expect(field).toBeFocused(); + const calls = await page.evaluate( + () => (window as unknown as {__focusCalls: unknown[]}).__focusCalls, + ); + // The field's own tap focused it, asking the browser not to scroll it into + // view (Mobile Safari would pan the page instead of letting the bar ride). + expect(calls).toEqual([{role: 'searchbox', options: {preventScroll: true}}]); + expect(await page.evaluate(() => window.scrollY)).toBe(scrollBefore); +}); + +test("a pen tap on the text is claimed as a finger's is: the field's own tap focuses it with preventScroll (spec:AST-094 FR43)", async ({ + page, +}) => { + const {field} = await openBottom(page); + const input = await webInputDriver(page); + test.skip(!input.pens, PEN_SKIP); + // Every focus the page makes, with its options, and the pointer types that + // press. + await page.evaluate(() => { + const seen = {focus: [] as unknown[], pointers: [] as string[]}; + (window as unknown as {__penSeen: typeof seen}).__penSeen = seen; + const focus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = function (options?: FocusOptions) { + seen.focus.push({ + role: this.getAttribute('role'), + options: options ?? null, + }); + focus.call(this, options); + }; + document.addEventListener( + 'pointerdown', + event => seen.pointers.push(event.pointerType), + true, + ); + }); + const scrollBefore = await page.evaluate(() => window.scrollY); + const {x, y} = await centerOf(field); + await input.pen([ + {x, y, t: 0}, + {x, y, t: 60}, + ]); + await expect(field).toBeFocused(); + const seen = await page.evaluate( + () => + (window as unknown as {__penSeen: {focus: unknown[]; pointers: string[]}}) + .__penSeen, + ); + // A pen, and the claim took it: the field's own tap focused it with + // preventScroll, as for a finger (without the pen in the claim, no focus + // comes from script). Chromium sends a pen's compatibility mousedown right + // after its pointerdown, before any tap is recognized, so the browser has + // already focused the field there; on iOS a pencil's compatibility events + // follow its touchend. + expect(seen.pointers).toEqual(['pen']); + expect(seen.focus).toEqual([ + {role: 'searchbox', options: {preventScroll: true}}, + ]); + expect(await page.evaluate(() => window.scrollY)).toBe(scrollBefore); +}); + +test('the tap claim attaches only where it claims taps', async ({page}) => { + // Where the keyboard covers the page (Mobile Safari), the bottom field + // claims a tap, so the gesture host owns its touch-action. + await openBottom(page); + expect(await touchActionOf(page)).not.toBe('auto'); + // A stacked field never claims a tap: it keeps the browser's own. + await openStory(page, storybook(), 'lab-searchfield--default'); + await expect(page.getByRole('searchbox').first()).toBeVisible(); + expect(await touchActionOf(page)).toBe('auto'); +}); + +/** + * Records every focus call on the search field and every document scroll, + * from here on. + */ +async function spyFocusAndScroll(page: Page) { + await page.evaluate(() => { + const spy = {focus: [] as unknown[], scrolls: 0, scrollY: window.scrollY}; + (window as unknown as {__spy: typeof spy}).__spy = spy; + const focus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = function (options?: FocusOptions) { + if (this.getAttribute('role') === 'searchbox') { + spy.focus.push(options ?? null); + } + focus.call(this, options); + }; + window.addEventListener('scroll', () => { + spy.scrolls++; + }); + }); + return async () => + page.evaluate(() => { + const spy = ( + window as unknown as { + __spy: {focus: unknown[]; scrolls: number; scrollY: number}; + } + ).__spy; + return {...spy, scrolled: window.scrollY - spy.scrollY}; + }); +} + +test('focus moved from another control (Tab, as the keyboard’s next button moves it) reaches the field without a reveal', async ({ + page, +}) => { + const {field} = await openBottom(page); + // Another text field, before the bar in the document. + await page.evaluate(() => { + const other = document.createElement('input'); + other.setAttribute('aria-label', 'Name'); + document.body.prepend(other); + }); + await page.getByRole('textbox', {name: 'Name'}).focus(); + const read = await spyFocusAndScroll(page); + await page.keyboard.press('Tab'); + await expect(field).toBeFocused(); + // The field took the move over with preventScroll (spec:AST-099 FR4), and + // the document did not move. + const spy = await read(); + expect(spy.focus).toEqual([{preventScroll: true}]); + expect(spy.scrolls).toBe(0); + expect(spy.scrolled).toBe(0); +}); + +test('after the keyboard’s Done, the next tap focuses the field without a reveal', async ({ + page, +}) => { + const {driver, field} = await openBottom(page); + await driver.tap(await centerOf(field)); + await expect(field).toBeFocused(); + // Done ends editing: focus leaves the field for nothing. + await field.evaluate(element => (element as HTMLElement).blur()); + const read = await spyFocusAndScroll(page); + await driver.tap(await centerOf(field)); + await expect(field).toBeFocused(); + const spy = await read(); + expect(spy.focus).toEqual([{preventScroll: true}]); + expect(spy.scrolls).toBe(0); + expect(spy.scrolled).toBe(0); +}); + +test('a tap into an existing query puts the caret where the finger lifted', async ({ + page, +}) => { + const {driver, field} = await openBottom(page); + await driver.tap(await centerOf(field)); + await expect(field).toBeFocused(); + await page.keyboard.type('hello world'); + // Editing ends (the keyboard goes down) and the field stays active. + await field.evaluate(element => (element as HTMLInputElement).blur()); + await expect(root(page)).toHaveAttribute('data-search-field-active', 'true'); + // The parts finish moving first: as the keyboard goes down the field is + // still on its way to rest, and a point read mid-move lands the tap a + // character or two off once it arrives. + await waitForRest(root(page)); + await waitForRest(end(page)); + await waitForRest(close(page)); + // Where "hello" ends, measured with a span in the input's font. + const point = await field.evaluate(element => { + const input = element as HTMLInputElement; + const style = getComputedStyle(input); + const span = document.createElement('span'); + span.textContent = 'hello'; + span.style.font = style.font; + span.style.letterSpacing = style.letterSpacing; + span.style.position = 'absolute'; + span.style.whiteSpace = 'pre'; + document.body.appendChild(span); + const width = span.getBoundingClientRect().width; + span.remove(); + const rect = input.getBoundingClientRect(); + return { + x: + rect.left + + parseFloat(style.borderLeftWidth) + + parseFloat(style.paddingLeft) + + width, + y: rect.top + rect.height / 2, + }; + }); + await driver.tap(point); + await expect(field).toBeFocused(); + const caret = await field.evaluate(element => [ + (element as HTMLInputElement).selectionStart, + (element as HTMLInputElement).selectionEnd, + ]); + // Script focus alone would leave it at the end (11). + expect(caret[0], JSON.stringify(caret)).toBeGreaterThanOrEqual(4); + expect(caret[0], JSON.stringify(caret)).toBeLessThanOrEqual(6); + expect(caret[1]).toBe(caret[0]); +}); + +test('the clear button refocuses the field without Safari’s reveal', async ({ + page, +}) => { + const {driver, field} = await openBottom(page); + await driver.tap(await centerOf(field)); + await expect(field).toBeFocused(); + // The gel settled, so the clear button is where it rests. + await waitForRest(end(page)); + await page.keyboard.type('apples'); + await field.evaluate(element => (element as HTMLInputElement).blur()); + await page.evaluate(() => { + const calls: unknown[] = []; + (window as unknown as {__focusCalls: unknown[]}).__focusCalls = calls; + const focus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = function (options?: FocusOptions) { + calls.push({role: this.getAttribute('role'), options: options ?? null}); + focus.call(this, options); + }; + }); + const clear = page.getByRole('button', {name: 'Clear text'}); + await driver.tap(await centerOf(clear)); + await expect(field).toBeFocused(); + await expect(field).toHaveValue(''); + const calls = await page.evaluate( + () => (window as unknown as {__focusCalls: unknown[]}).__focusCalls, + ); + expect(calls).toEqual([{role: 'searchbox', options: {preventScroll: true}}]); +}); + +test.describe('with Reduce Motion', () => { + test('a finger sees only opacity fades of 200 ms or less, and the bar moves to the keyboard at once', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver, field} = await openBottom(page); + // design:reduced-motion DR1 and DR2: no travel, opacity only, 200 ms or + // less. The page records each animation as it starts, so a busy runner + // cannot make the proof miss one; it reads once the parts have landed + // and come to rest. + await recordFades(page); + await driver.tap(await centerOf(field)); + await expect + .poll(async () => (await translate(end(page)))[0]) + .toBeCloseTo(ACTIVE.trailing - REST.trailing, 1); + await waitForRest(end(page)); + await waitForRest(close(page)); + const activation = await readFades(page); + expect(activation.properties).toEqual(['opacity']); + expect(activation.fadeMs).toBeLessThanOrEqual(200); + // The keyboard: the field's layout takes the new place at once. + const restGap = await restGapOf(page); + await showKeyboard(page); + await expect + .poll(async () => liftOf(page)) + .toBeCloseTo(COVER + bottom.keyboardCenterOffset - restGap, 0); + expect(await rootAnimations(page)).toBe(0); + }); +}); + +test('the field keeps 36 px above the keyboard by layout, each report moving it at once', async ({ + page, +}) => { + const {driver, field} = await openBottom(page); + await driver.tap(await centerOf(field)); + await waitForRest(end(page)); + await waitForRest(close(page)); + // Active with the keyboard not yet reported: the field rests. + expect(await liftOf(page)).toBeCloseTo(0, 1); + const restGap = await restGapOf(page); + await showKeyboard(page); + // Committed at rest (spec:AST-099 FR8): the layout takes the new place + // at once, with no animation and no translate. + await expect + .poll(async () => liftOf(page)) + .toBeCloseTo(COVER + bottom.keyboardCenterOffset - restGap, 0); + expect(await rootAnimations(page)).toBe(0); + expect((await translate(root(page)))[1]).toBeCloseTo(0, 1); + const box = await root(page).boundingBox(); + const keyboardTop = await page.evaluate( + cover => window.innerHeight - cover, + COVER, + ); + expect(keyboardTop - ((box?.y ?? 0) + (box?.height ?? 0) / 2)).toBeCloseTo( + bottom.keyboardCenterOffset, + 0, + ); + // The keyboard down: back at rest, at once. + await showKeyboard(page, 0); + await expect.poll(async () => liftOf(page)).toBeCloseTo(0, 1); + expect(await rootAnimations(page)).toBe(0); +}); + +test('the close button narrows the field, springs it out to rest and shrinks in place, and the bar goes down with the keyboard’s report', async ({ + page, +}, testInfo) => { + const {driver} = await openActive(page); + await driver.tap(await centerOf(close(page))); + await showKeyboard(page, 0); + await waitForAnimation(end(page)); + const held = await holdAnimations(page); + await expect(root(page)).not.toHaveAttribute('data-search-field-active'); + const fieldEnd = Math.max( + ...held.filter(a => a.curveEndMs < 1000).map(a => a.curveEndMs), + ); + const times = timeline(fieldEnd); + const [ends, closes] = await Promise.all( + [end, close].map(async part => sampleCurve(part(page), times)), + ); + await releaseAnimations(page, {finish: true}); + const samples = times.map((t, i) => ({ + t: +t.toFixed(1), + endX: ends[i].transform.translateX, + closeX: closes[i].transform.translateX, + closeScale: closes[i].transform.scaleX, + closeOpacity: closes[i].opacity, + measured: { + endX: edgesAt(bottom.cancelFrames, t).trailing - REST.trailing, + closeX: -bottom.cancelNarrowing, + closeScale: frameAt(bottom.closeExitFrames, 2, t), + closeOpacity: frameAt(bottom.closeExitFrames, 1, t), + }, + })); + await writeEvidence( + 'curves/searchfield-bottom-cancel', + {[`${testInfo.project.name}.json`]: JSON.stringify(samples, null, 1)}, + testInfo, + ); + // The trailing edge starts 16 px inside the active field's. + expect(samples[0].endX).toBeCloseTo( + ACTIVE.trailing - bottom.cancelNarrowing - REST.trailing, + 0, + ); + for (const sample of samples) { + const {measured} = sample; + const at = `at ${sample.t} ms`; + expect(Math.abs(sample.endX - measured.endX), at).toBeLessThan(0.5); + expect(Math.abs(sample.closeX - measured.closeX), at).toBeLessThan(0.5); + expect(Math.abs(sample.closeScale - measured.closeScale), at).toBeLessThan( + 0.01, + ); + expect( + Math.abs(sample.closeOpacity - measured.closeOpacity), + at, + ).toBeLessThan(0.02); + } + await waitForRest(root(page)); + // The bar back at rest by layout, with no translate left. + expect(await liftOf(page)).toBeCloseTo(0, 1); + expect((await translate(root(page)))[1]).toBeCloseTo(0, 0); + await expect(close(page)).toHaveAttribute('aria-hidden', 'true'); + await expect(page.getByRole('searchbox')).not.toBeFocused(); +}); + +test('a cancel keeps the bar above the keyboard until Safari reports the hide, with no prediction', async ({ + page, +}) => { + const {driver} = await openActive(page); + await driver.tap(await centerOf(close(page))); + await expect(root(page)).not.toHaveAttribute('data-search-field-active'); + // Inactive with the keyboard still reported up: the bar keeps its resting + // gap above it, at once. + await expect.poll(async () => liftOf(page)).toBeCloseTo(COVER, 0); + await page.waitForTimeout(400); + expect(await liftOf(page)).toBeCloseTo(COVER, 0); + // Safari's report: the bar goes down at once. + await showKeyboard(page, 0); + await expect.poll(async () => liftOf(page)).toBeCloseTo(0, 1); + expect(await rootAnimations(page)).toBe(0); +}); + +test( + 'storyboard: the bottom field activating with the keyboard', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, field} = await openBottom(page); + const restTop = (await root(page).boundingBox())?.y ?? 0; + await driver.tap(await centerOf(field)); + await showKeyboard(page); + await waitForAnimation(end(page)); + const {storyboard} = await animationStoryboard(page, { + name: 'searchfield-bottom-activation', + clip: {x: 0, y: 300, width: WIDTH, height: 414}, + times: Array.from({length: 30}, (_, i) => i * 16.667), + measure: async () => ({ + y: ((await root(page).boundingBox())?.y ?? 0) - restTop, + endX: (await translate(end(page)))[0], + closeOpacity: await close(page).evaluate(element => + parseFloat(getComputedStyle(element).opacity), + ), + }), + columns: 6, + cellScale: 0.4, + }); + await storyboard.write(testInfo); + await releaseAnimations(page, {finish: true}); + const frames = storyboard.frames.map( + f => f.data as {y: number; endX: number; closeOpacity: number}, + ); + expect(frames.at(-1)?.y ?? 0).toBeLessThan(-200); + expect(frames.at(-1)?.closeOpacity ?? 0).toBeGreaterThan(0.95); + }, +); + +test( + 'storyboard: the bottom field cancelling with the keyboard', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, field} = await openBottom(page); + const restTop = (await root(page).boundingBox())?.y ?? 0; + await driver.tap(await centerOf(field)); + await showKeyboard(page); + await expect(root(page)).toHaveAttribute( + 'data-search-field-active', + 'true', + ); + await waitForRest(close(page)); + await waitForRest(root(page)); + await driver.tap(await centerOf(close(page))); + await showKeyboard(page, 0); + await waitForAnimation(end(page)); + const {storyboard} = await animationStoryboard(page, { + name: 'searchfield-bottom-cancel', + clip: {x: 0, y: 300, width: WIDTH, height: 414}, + times: Array.from({length: 30}, (_, i) => i * 16.667), + measure: async () => ({ + y: ((await root(page).boundingBox())?.y ?? 0) - restTop, + endX: (await translate(end(page)))[0], + closeOpacity: await close(page).evaluate(element => + parseFloat(getComputedStyle(element).opacity), + ), + }), + columns: 6, + cellScale: 0.4, + }); + await storyboard.write(testInfo); + await releaseAnimations(page, {finish: true}); + const frames = storyboard.frames.map( + f => f.data as {y: number; endX: number; closeOpacity: number}, + ); + expect(Math.abs(frames.at(-1)?.y ?? 99)).toBeLessThan(2); + expect(frames.at(-1)?.closeOpacity ?? 1).toBeLessThan(0.01); + }, +); + +test("a claimed tap's late mouse events never reach the page: the click guard takes them until their click (spec:AST-094 FR21)", async ({ + page, +}) => { + const {driver, field} = await openBottom(page); + await page.evaluate(() => { + const late = window as unknown as { + __seen: string[]; + __lateSent: boolean; + __sendLate: () => void; + }; + late.__seen = []; + late.__lateSent = false; + for (const type of ['mousedown', 'mouseup', 'click']) { + document.body.addEventListener(type, event => + late.__seen.push(`${type}:${(event as MouseEvent).detail}`), + ); + } + // A held tap's mouse events, aimed at whatever is under the finger by + // then (here, the page itself). + late.__sendLate = () => { + for (const type of ['mousedown', 'mouseup', 'click']) { + document.body.dispatchEvent( + new MouseEvent(type, {bubbles: true, cancelable: true, detail: 1}), + ); + } + }; + // Neither engine here sends them, so the page sends them just after the + // tap's touchend, as iOS WebKit does. + window.addEventListener( + 'touchend', + () => + setTimeout(() => { + late.__sendLate(); + late.__lateSent = true; + }, 0), + {once: true}, + ); + }); + await driver.tap(await centerOf(field)); + await expect(field).toBeFocused(); + await expect + .poll(async () => + page.evaluate( + () => (window as unknown as {__lateSent: boolean}).__lateSent, + ), + ) + .toBe(true); + expect( + await page.evaluate(() => (window as unknown as {__seen: string[]}).__seen), + ).toEqual([]); + // That click spent the guard: the next ones reach the page. + expect( + await page.evaluate(() => { + const late = window as unknown as { + __seen: string[]; + __sendLate: () => void; + }; + late.__seen.length = 0; + late.__sendLate(); + return [...late.__seen]; + }), + ).toEqual(['mousedown:1', 'mouseup:1', 'click:1']); +}); + +test('a resize while the field is active rescales its middle to the new width at once', async ({ + page, +}) => { + await openActive(page); + // The trailing rounded end reaches 1 px under the scaling middle at any + // width (no antialiased seam), so their overlap holds through a resize. + const gap = async () => { + const [middle, cap] = await Promise.all([ + partBox(page, MIDDLE), + partBox(page, END_CAP), + ]); + return cap.left - middle.right; + }; + const widthOf = async () => + root(page).evaluate(element => element.getBoundingClientRect().width); + const overlap = await gap(); + const before = await widthOf(); + const size = page.viewportSize(); + if (size == null) { + throw new Error('no viewport'); + } + await page.setViewportSize({width: size.width - 40, height: size.height}); + await expect.poll(widthOf).toBeLessThan(before); + // The resize observation lands before the next frame paints. + await page.evaluate( + async () => + new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + expect(Math.abs((await gap()) - overlap)).toBeLessThanOrEqual(0.5); + // At once: nothing animates the trailing end there. + expect( + await end(page).evaluate(element => element.getAnimations().length), + ).toBe(0); +}); + +test.describe('where the browser also reports the keyboard itself (Chrome on Android)', () => { + test('the field claims the tap there too and rides the visual viewport, leaving the app’s keyboard setting alone (spec:AST-099 FR4, DEC-2, DEC-4)', async ({ + page, + }) => { + const {driver, field} = await openBottom(page, {virtualKeyboardApi: true}); + // Chrome's own tap pans the page once its keyboard is up (on the Android + // emulator the document scrolls 169 px), so the field claims the tap + // there as in Safari: the claim sets the field's touch-action. + expect(await touchActionOf(page)).not.toBe('auto'); + await page.evaluate(() => { + const calls: unknown[] = []; + (window as unknown as {__focusCalls: unknown[]}).__focusCalls = calls; + const focus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = function (options?: FocusOptions) { + calls.push({role: this.getAttribute('role'), options: options ?? null}); + focus.call(this, options); + }; + }); + await driver.tap(await centerOf(field)); + await expect(field).toBeFocused(); + expect( + await page.evaluate( + () => (window as unknown as {__focusCalls: unknown[]}).__focusCalls, + ), + ).toEqual([{role: 'searchbox', options: {preventScroll: true}}]); + expect(await overlaysContent(page)).toBe(false); + await waitForRest(end(page)); + await waitForRest(close(page)); + const restGap = await restGapOf(page); + // Chrome's own report moves nothing: the visual viewport is the measure + // in every browser (DEC-4), committed at once, with no ride. + await reportChromeKeyboard(page); + await page.waitForTimeout(100); + expect(await liftOf(page)).toBeCloseTo(0, 1); + await showKeyboard(page); + await expect + .poll(async () => liftOf(page)) + .toBeCloseTo(COVER + bottom.keyboardCenterOffset - restGap, 0); + expect(await rootAnimations(page)).toBe(0); + // The close button: the inactive bar keeps its resting gap above the + // keyboard until the visual viewport recovers. + await driver.tap(await centerOf(close(page))); + await expect(root(page)).not.toHaveAttribute('data-search-field-active'); + expect(await liftOf(page)).toBeCloseTo(COVER, 0); + await showKeyboard(page, 0); + await expect.poll(async () => liftOf(page)).toBeCloseTo(0, 1); + expect(await overlaysContent(page)).toBe(false); + }); +}); + +/** + * Counts, on the field's input, its focus events, its clicks and the presses + * whose `pointerdown` a listener cancelled. + */ +async function countFieldInput(field: Locator) { + await field.evaluate(input => { + const counts = {focus: 0, click: 0, pointerdownPrevented: 0}; + (window as unknown as {__searchCounts: typeof counts}).__searchCounts = + counts; + input.addEventListener('focus', () => { + counts.focus += 1; + }); + input.addEventListener('click', () => { + counts.click += 1; + }); + input.ownerDocument.addEventListener('pointerdown', event => { + // Read after every listener ran. + setTimeout(() => { + if (event.defaultPrevented) { + counts.pointerdownPrevented += 1; + } + }); + }); + }); + return async () => + field + .page() + .evaluate( + () => + (window as unknown as {__searchCounts: Record}) + .__searchCounts, + ); +} + +/** Every focus the page makes from script, with its options. */ +async function spyScriptFocus(page: Page) { + await page.evaluate(() => { + const calls: unknown[] = []; + (window as unknown as {__scriptFocus: unknown[]}).__scriptFocus = calls; + const focus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = function (options?: FocusOptions) { + calls.push({role: this.getAttribute('role'), options: options ?? null}); + focus.call(this, options); + }; + }); + return async () => + page.evaluate( + () => (window as unknown as {__scriptFocus: unknown[]}).__scriptFocus, + ); +} + +test.describe('with a mouse on a touch screen (a trackpad on a tablet)', () => { + test("a click on the text is the browser's own, though the tap claim is attached (spec:AST-094 DEC-21)", async ({ + page, + }) => { + // A coarse primary pointer with the keyboard covering the page: the + // field claims a finger's or a pen's tap on its text (spec:AST-099 FR4). + // A mouse press stays the browser's, as DEC-21 keeps off what would + // fight the browser: it focuses the field itself, puts the caret and + // lets the click reach the input. + const {field} = await openBottom(page); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); + expect(await touchActionOf(page)).not.toBe('auto'); + const counts = await countFieldInput(field); + const {x, y} = await centerOf(field); + await page.mouse.click(x, y); + await expect(root(page)).toHaveAttribute( + 'data-search-field-active', + 'true', + ); + await page.waitForTimeout(100); + expect(await counts()).toEqual({ + focus: 1, + click: 1, + pointerdownPrevented: 0, + }); + await expect(field).toBeFocused(); + }); +}); + +test.describe('with a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test("under a fine primary pointer the field claims no tap: a click on the text is the browser's own (spec:AST-017 FR59)", async ({ + page, + }) => { + // No VirtualKeyboard API, as on desktop Safari and Firefox, where no + // software keyboard rises: the tap claim is touch work, so under a fine + // primary pointer it does not attach, and a mouse keeps the browser's own + // focus, caret and click. + await installKeyboard(page); + await openStory(page, storybook(), 'lab-searchfield--bottom'); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const field = page.getByRole('searchbox', {name: 'Search'}); + await expect(field).toBeVisible(); + expect(await touchActionOf(page)).toBe('auto'); + const counts = await countFieldInput(field); + await field.click(); + await expect(root(page)).toHaveAttribute( + 'data-search-field-active', + 'true', + ); + await page.waitForTimeout(100); + expect(await counts()).toEqual({ + focus: 1, + click: 1, + pointerdownPrevented: 0, + }); + await expect(field).toBeFocused(); + }); + + test("under a fine primary pointer a pen's tap on the text is the browser's own, as on a desktop pen tablet (spec:AST-017 FR59)", async ({ + page, + }) => { + await installKeyboard(page); + await openStory(page, storybook(), 'lab-searchfield--bottom'); + const field = page.getByRole('searchbox', {name: 'Search'}); + await expect(field).toBeVisible(); + const input = await webInputDriver(page); + test.skip(!input.pens, PEN_SKIP); + const scriptFocus = await spyScriptFocus(page); + const counts = await countFieldInput(field); + const {x, y} = await centerOf(field); + await input.pen([ + {x, y, t: 0}, + {x, y, t: 60}, + ]); + await expect(field).toBeFocused(); + await page.waitForTimeout(100); + // The browser focused it from the pen's own press; no claim took the tap, + // so no focus came from script and the click reached the input. + expect(await scriptFocus()).toEqual([]); + expect(await counts()).toEqual({ + focus: 1, + click: 1, + pointerdownPrevented: 0, + }); + }); + + test('a click puts every part at its end at once, and a keyboard report moves nothing', async ({ + page, + }) => { + await installKeyboard(page); + await openStory(page, storybook(), 'lab-searchfield--bottom'); + // A fine primary pointer, asserted in this test's own context. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const field = page.getByRole('searchbox', {name: 'Search'}); + await field.click(); + await expect(root(page)).toHaveAttribute( + 'data-search-field-active', + 'true', + ); + // No animation moves anything. (The animator holds the rounded ends and + // the middle, pseudo-elements with no inline style, by static filling + // animations whose keyframes keep one value.) + expect( + await root(page).evaluate( + element => + element.getAnimations({subtree: true}).filter(animation => { + const frames = + (animation.effect as KeyframeEffect | null)?.getKeyframes() ?? []; + return Object.keys(frames[0] ?? {}).some( + key => + !['offset', 'computedOffset', 'easing', 'composite'].includes( + key, + ) && new Set(frames.map(frame => String(frame[key]))).size > 1, + ); + }).length, + ), + ).toBe(0); + expect((await translate(end(page)))[0]).toBeCloseTo( + ACTIVE.trailing - REST.trailing, + 1, + ); + await showKeyboard(page); + await page.waitForTimeout(100); + expect(await liftOf(page)).toBeCloseTo(0, 1); + }); +}); diff --git a/packages/lab/src/SearchField/__tests__/SearchFieldTab.interaction.browser.spec.ts b/packages/lab/src/SearchField/__tests__/SearchFieldTab.interaction.browser.spec.ts new file mode 100644 index 0000000000000..ceb469056234b --- /dev/null +++ b/packages/lab/src/SearchField/__tests__/SearchFieldTab.interaction.browser.spec.ts @@ -0,0 +1,169 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file SearchFieldTab.interaction.browser.spec.ts + * @input The built "Lab/SearchField" InTabBar story on the harness's phone + * (touch), a stand-in visual viewport that reports a software keyboard the + * way Mobile Safari does, real touch input and the interaction harness + * @output Proofs that the tab placement's field ends up above the keyboard: + * a tap focuses it without moving the page, and the story's stand-in tab + * bar keeps above the keyboard by layout, committed on each report, so + * the field's centre rests 36 px above the keyboard's top, and back down + * when the field is cancelled, with Chrome's keyboard API on the page + * too; the field paints no background of its own + * (no ::before or ::after on the field or the trailing end: the tab bar + * draws the glass) + * @position Interaction proof for lab SearchField's tab placement and its + * story (SearchField.tsx, SearchField.stories.tsx); the real tab bar's + * keyboard handling is TabBar's + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + openStory, + touchDriver, + withStorybook, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +/** The keyboard cover Mobile Safari reports on an iPhone 17 Pro, px. */ +const COVER = 337; +/** The field's centre above the keyboard's top, px. */ +const CENTER_ABOVE_KEYBOARD = 36; + +/** + * Mobile Safari, before the page loads: no VirtualKeyboard API (Chromium's + * is removed), and a visual viewport the test shrinks from the bottom with + * one `resize` (`__keyboard(cover)`). `virtualKeyboardApi` keeps Chrome on + * Android's API on the page instead, as an object that reports nothing. + */ +async function installKeyboard( + page: Page, + {virtualKeyboardApi = false}: {virtualKeyboardApi?: boolean} = {}, +) { + await page.addInitScript(withApi => { + if (withApi) { + Object.defineProperty(navigator, 'virtualKeyboard', { + configurable: true, + value: Object.assign(new EventTarget(), { + overlaysContent: false, + boundingRect: new DOMRect(0, 0, 0, 0), + }), + }); + } else { + Reflect.deleteProperty(Navigator.prototype, 'virtualKeyboard'); + Reflect.deleteProperty(navigator, 'virtualKeyboard'); + } + const viewport = Object.assign(new EventTarget(), { + height: window.innerHeight, + width: window.innerWidth, + offsetTop: 0, + offsetLeft: 0, + pageTop: 0, + pageLeft: 0, + scale: 1, + }); + Object.defineProperty(window, 'visualViewport', { + configurable: true, + get: () => viewport, + }); + (window as unknown as {__keyboard: (cover: number) => void}).__keyboard = + cover => { + viewport.height = window.innerHeight - cover; + viewport.dispatchEvent(new Event('resize')); + }; + }, virtualKeyboardApi); +} + +const keyboard = async (page: Page, cover: number) => + page.evaluate( + c => (window as unknown as {__keyboard: (c: number) => void}).__keyboard(c), + cover, + ); + +async function centerOf(locator: Locator) { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('no box'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +test('the field paints no background of its own: the tab bar draws the glass', async ({ + page, +}) => { + await openStory(page, storybook(), 'lab-searchfield--in-tab-bar'); + const field = page.getByRole('searchbox', {name: 'Search'}); + await expect(field).toBeVisible(); + const contents = await page.evaluate(() => { + const box = document.querySelector('[role="search"]') + ?.firstElementChild as HTMLElement; + const end = document.querySelector( + '[data-search-field-end]', + ) as HTMLElement; + return [ + getComputedStyle(box, '::before').content, + getComputedStyle(box, '::after').content, + getComputedStyle(end, '::before').content, + ]; + }); + // A pseudo-element with no content is not generated. + expect(contents).toEqual(['none', 'none', 'none']); +}); + +test('the stand-in tab bar keeps above the keyboard: the field rests 36 px above it, and goes back down on cancel', async ({ + page, +}) => { + await installKeyboard(page); + await openStory(page, storybook(), 'lab-searchfield--in-tab-bar'); + const field = page.getByRole('searchbox', {name: 'Search'}); + await expect(field).toBeVisible(); + const rest = (await centerOf(field)).y; + await (await touchDriver(page)).tap(await centerOf(field)); + await expect(field).toBeFocused(); + // No page move: the tap focuses without Safari's reveal. + expect(await page.evaluate(() => window.scrollY)).toBe(0); + await keyboard(page, COVER); + const keyboardTop = await page.evaluate( + cover => window.innerHeight - cover, + COVER, + ); + // The field ends with its centre 36 px above the keyboard's top. + await expect + .poll(async () => Math.round(keyboardTop - (await centerOf(field)).y), { + timeout: 3000, + }) + .toBe(CENTER_ABOVE_KEYBOARD); + // The tab bar's cancel button cancels through the handle; the keyboard + // goes down and the bar goes back to rest. + await page.getByRole('button', {name: 'Cancel'}).click(); + await keyboard(page, 0); + await expect + .poll(async () => Math.round((await centerOf(field)).y), {timeout: 3000}) + .toBe(Math.round(rest)); +}); + +test('with Chrome on Android’s keyboard API on the page, the stand-in tab bar still keeps above the keyboard', async ({ + page, +}) => { + await installKeyboard(page, {virtualKeyboardApi: true}); + await openStory(page, storybook(), 'lab-searchfield--in-tab-bar'); + expect(await page.evaluate(() => 'virtualKeyboard' in navigator)).toBe(true); + const field = page.getByRole('searchbox', {name: 'Search'}); + await expect(field).toBeVisible(); + await (await touchDriver(page)).tap(await centerOf(field)); + await expect(field).toBeFocused(); + await keyboard(page, COVER); + const keyboardTop = await page.evaluate( + cover => window.innerHeight - cover, + COVER, + ); + // The bar measures the visual viewport, as without the API: the field's + // centre ends 36 px above the keyboard's top. + await expect + .poll(async () => Math.round(keyboardTop - (await centerOf(field)).y), { + timeout: 3000, + }) + .toBe(CENTER_ABOVE_KEYBOARD); +}); diff --git a/packages/lab/src/SearchField/__tests__/fadeRecorder.ts b/packages/lab/src/SearchField/__tests__/fadeRecorder.ts new file mode 100644 index 0000000000000..869aa4ed6417c --- /dev/null +++ b/packages/lab/src/SearchField/__tests__/fadeRecorder.ts @@ -0,0 +1,212 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file fadeRecorder.ts + * @input A Playwright page showing a SearchField story + * @output Exports recordFades, which starts a record, kept in the page, of + * the animations an action starts inside the search field, and readFades, + * which reads it back: the properties those animations change and when the + * last opacity change settles, on the animations' own clock + * @position Test helper for SearchField's Reduce Motion proofs + * (`design:reduced-motion`: opacity only, 200 ms or less, or no animation + * at all) + * + * The page records each animation as it is made (`Element.animate`, and the + * CSS animation and transition events for any other) and reads its start + * when the browser commits it. A proof that read `getAnimations()` after the + * action instead could miss on a busy runner: by the time its read ran, an + * animation could have finished or been replaced by the next one. + * + * An animation that holds each property at one value moves nothing: the + * animator holds a pseudo-element's resting value that way (a pseudo-element + * has no inline style), as it does for the field's background pieces. Such a + * hold changes no property and does not start the fade's clock. + */ + +import type {Page} from '@playwright/test'; + +/** What the animations an action started changed, from the page's record. */ +export interface RecordedFades { + /** Every property one of the recorded animations changes, sorted. */ + readonly properties: readonly string[]; + /** + * When the last opacity change settles, ms after the first recorded + * animation that changes something starts; 0 when nothing fades. + */ + readonly fadeMs: number; + /** How many of the recorded animations the browser started. */ + readonly started: number; +} + +/** One recorded animation, as the page keeps it. */ +interface FadeEntry { + readonly keyframes: ComputedKeyframe[]; + readonly delay: number; + readonly duration: number; + /** Its start on the document timeline, once the browser committed it. */ + start: number | null; + /** When it was cancelled (replaced by a later animation), if it was. */ + stoppedAt: number | null; +} + +/** Page side: installs the recorder once per document. */ +function installFadeRecorder(): void { + const w = window as unknown as {__searchFieldFades?: {entries: FadeEntry[]}}; + if (w.__searchFieldFades != null) { + return; + } + const recorder: {entries: FadeEntry[]} = {entries: []}; + w.__searchFieldFades = recorder; + const seen = new WeakSet(); + // A cancelled animation's `ready` rejects with an AbortError: it was never + // shown, which the record expects. Any other rejection is a real error. + const unlessAborted = (error: unknown) => { + if (!(error instanceof DOMException && error.name === 'AbortError')) { + throw error; + } + }; + const remember = (animation: Animation) => { + const effect = animation.effect; + if (seen.has(animation) || !(effect instanceof KeyframeEffect)) { + return; + } + const target = effect.target; + const root = document.querySelector('[role="search"]'); + if (target == null || root == null || !root.contains(target)) { + return; + } + seen.add(animation); + const timing = effect.getComputedTiming(); + const entry: FadeEntry = { + keyframes: effect.getKeyframes(), + delay: Number(timing.delay ?? 0), + duration: Number(timing.activeDuration ?? 0), + start: animation.startTime == null ? null : Number(animation.startTime), + stoppedAt: null, + }; + recorder.entries.push(entry); + // The browser sets the start when it commits the animation. One that is + // cancelled before then was never shown, and keeps no start. + animation.ready.then(() => { + if (entry.start == null && animation.startTime != null) { + entry.start = Number(animation.startTime); + } + }, unlessAborted); + animation.addEventListener('cancel', event => { + if (entry.start != null && entry.stoppedAt == null) { + const at = (event as AnimationPlaybackEvent).timelineTime; + entry.stoppedAt = Number(at ?? document.timeline.currentTime ?? 0); + } + }); + }; + const animate = Element.prototype.animate; + Element.prototype.animate = function ( + this: Element, + ...args: Parameters + ) { + const animation = animate.apply(this, args); + remember(animation); + return animation; + }; + for (const type of ['animationstart', 'transitionrun']) { + document.addEventListener( + type, + event => { + if (event.target instanceof Element) { + for (const animation of event.target.getAnimations()) { + remember(animation); + } + } + }, + true, + ); + } +} + +/** + * Starts a fresh record, before the action whose animations a proof judges. + */ +export async function recordFades(page: Page): Promise { + await page.evaluate(installFadeRecorder); + await page.evaluate(() => { + const w = window as unknown as {__searchFieldFades: {entries: unknown[]}}; + w.__searchFieldFades.entries = []; + }); +} + +/** + * Reads the record back once the action's motion is over. Each animation is + * judged from its own keyframes and committed start; one replaced by a later + * animation stops changing where it was cancelled. + */ +export async function readFades(page: Page): Promise { + return page.evaluate(() => { + const w = window as unknown as { + __searchFieldFades: { + entries: { + keyframes: ComputedKeyframe[]; + delay: number; + duration: number; + start: number | null; + stoppedAt: number | null; + }[]; + }; + }; + const SKIP = new Set(['offset', 'computedOffset', 'easing', 'composite']); + const started = w.__searchFieldFades.entries.flatMap(entry => + entry.start == null ? [] : [{...entry, start: entry.start}], + ); + if (started.length === 0) { + return {properties: [], fadeMs: 0, started: 0}; + } + // A property held at one value does not move anything. + const moves = (entry: {keyframes: ComputedKeyframe[]}) => + Object.keys(entry.keyframes[0] ?? {}).some( + key => + !SKIP.has(key) && + new Set(entry.keyframes.map(frame => String(frame[key]))).size > 1, + ); + const moving = started.filter(moves); + if (moving.length === 0) { + return {properties: [], fadeMs: 0, started: started.length}; + } + const startedAt = Math.min(...moving.map(entry => entry.start)); + const properties = new Set(); + let fadeMs = 0; + for (const entry of moving) { + const frames = entry.keyframes; + const begin = entry.start + entry.delay; + const timeOf = (i: number) => + begin + Number(frames[i].computedOffset) * entry.duration; + for (const key of Object.keys(frames[0] ?? {})) { + if (SKIP.has(key)) { + continue; + } + const values = new Set(frames.map(frame => String(frame[key]))); + if (values.size > 1) { + properties.add(key); + } + if (key === 'opacity') { + // When the opacity reaches the value it ends on, for good, or + // where a later animation replaced this one. + const final = String(frames[frames.length - 1].opacity); + let settles = timeOf(0); + for (let i = 0; i < frames.length; i++) { + if (String(frames[i].opacity) !== final) { + settles = timeOf(Math.min(i + 1, frames.length - 1)); + } + } + if (entry.stoppedAt != null) { + settles = Math.min(settles, entry.stoppedAt); + } + fadeMs = Math.max(fadeMs, settles - startedAt); + } + } + } + return { + properties: [...properties].sort(), + fadeMs, + started: started.length, + }; + }); +} diff --git a/packages/lab/src/SearchField/__tests__/pseudoParts.ts b/packages/lab/src/SearchField/__tests__/pseudoParts.ts new file mode 100644 index 0000000000000..de91dc5a8fd0b --- /dev/null +++ b/packages/lab/src/SearchField/__tests__/pseudoParts.ts @@ -0,0 +1,202 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file pseudoParts.ts + * @input A Playwright page showing a SearchField story, and the interaction + * harness's held animations (`holdAnimations`, `seekAnimations`) + * @output The field's background pieces as parts a browser proof reads: + * their presented translate, scale and opacity now (`readPart`), at each + * held time (`samplePart`: a seek, then the pseudo-element's computed + * style), their box on the page (`partBox`), and a wait until one stops + * moving (`waitForPartRest`) + * @position Testing helper for SearchField's browser proofs. The harness + * reads an element's own presented style, and the field's rounded ends and + * scaling middle are pseudo-elements + * (architecture:component-style-authoring INV7), so they are read through + * `getComputedStyle(host, pseudo)` + */ + +import type {Page} from '@playwright/test'; +import {seekAnimations} from '@astryxdesign/interaction-harness'; + +/** One background piece: its host element and its pseudo-element. */ +export interface Part { + /** + * `field`: the box around the input (the search landmark's first child, + * in every placement); `end`: the trailing part. + */ + readonly host: 'field' | 'end'; + readonly pseudo: '::before' | '::after'; +} + +/** The leading rounded end: the field's ::before. */ +export const START: Part = {host: 'field', pseudo: '::before'}; +/** The middle that scales: the field's ::after. */ +export const MIDDLE: Part = {host: 'field', pseudo: '::after'}; +/** The trailing rounded end: the trailing part's ::before. */ +export const END_CAP: Part = {host: 'end', pseudo: '::before'}; + +/** A piece's presented translate, scale and opacity. */ +export interface PartSample { + readonly translateX: number; + readonly translateY: number; + readonly scaleX: number; + readonly scaleY: number; + readonly opacity: number; +} + +/** A piece's box on the page, px, as it is presented. */ +export interface PartBox { + readonly left: number; + readonly right: number; + readonly top: number; + readonly bottom: number; +} + +/** The piece's presented values, read in the page. */ +export function readPart(page: Page, part: Part): Promise { + return page.evaluate( + ({host, pseudo}) => { + const field = document.querySelector('[role="search"]') + ?.firstElementChild as HTMLElement | null | undefined; + const element = + host === 'field' + ? field + : document.querySelector('[data-search-field-end]'); + if (element == null) { + throw new Error(`readPart: no ${host} element`); + } + const style = getComputedStyle(element, pseudo); + const translate = + style.translate === 'none' + ? [0, 0] + : style.translate.split(' ').map(value => Number.parseFloat(value)); + const scale = + style.scale === 'none' + ? [1, 1] + : style.scale.split(' ').map(value => Number(value)); + return { + translateX: translate[0] ?? 0, + translateY: translate[1] ?? 0, + scaleX: scale[0] ?? 1, + // One value scales both axes. + scaleY: scale[1] ?? scale[0] ?? 1, + opacity: Number(style.opacity), + }; + }, + {host: part.host, pseudo: part.pseudo}, + ); +} + +/** + * The piece's presented values at each time (ms from the held origin, as for + * `seekAnimations`): hold the animations first. Each time seeks every held + * animation, the pseudo-element's included, then reads the piece. + */ +export async function samplePart( + page: Page, + part: Part, + times: readonly number[], +): Promise { + const samples: PartSample[] = []; + for (const t of times) { + await seekAnimations(page, t); + samples.push(await readPart(page, part)); + } + return samples; +} + +/** + * The piece's box on the page as presented: its used box inside its host, + * moved and scaled about its own transform origin. + */ +export function partBox(page: Page, part: Part): Promise { + return page.evaluate( + ({host, pseudo}) => { + const field = document.querySelector('[role="search"]') + ?.firstElementChild as HTMLElement | null | undefined; + const element = + host === 'field' + ? field + : document.querySelector('[data-search-field-end]'); + if (element == null) { + throw new Error(`partBox: no ${host} element`); + } + const style = getComputedStyle(element, pseudo); + const rect = element.getBoundingClientRect(); + const translate = + style.translate === 'none' + ? [0, 0] + : style.translate.split(' ').map(value => Number.parseFloat(value)); + const scale = + style.scale === 'none' + ? [1, 1] + : style.scale.split(' ').map(value => Number(value)); + const scaleX = scale[0] ?? 1; + const scaleY = scale[1] ?? scale[0] ?? 1; + const [originX, originY] = style.transformOrigin + .split(' ') + .map(value => Number.parseFloat(value)); + const left = rect.left + Number.parseFloat(style.left); + const top = rect.top + Number.parseFloat(style.top); + const width = Number.parseFloat(style.width); + const height = Number.parseFloat(style.height); + const x = left + (translate[0] ?? 0) + originX * (1 - scaleX); + const y = top + (translate[1] ?? 0) + originY * (1 - scaleY); + return { + left: x, + right: x + width * scaleX, + top: y, + bottom: y + height * scaleY, + }; + }, + {host: part.host, pseudo: part.pseudo}, + ); +} + +/** + * Resolves once the piece's presented translate, scale and opacity have held + * still for `frames` consecutive frames; rejects after `timeout` ms. + */ +export async function waitForPartRest( + page: Page, + part: Part, + {timeout = 5_000, frames = 3}: {timeout?: number; frames?: number} = {}, +): Promise { + await page.evaluate( + ({host, pseudo, ms, need}) => + new Promise((resolve, reject) => { + const field = document.querySelector('[role="search"]') + ?.firstElementChild as HTMLElement | null | undefined; + const element = + host === 'field' + ? field + : document.querySelector('[data-search-field-end]'); + if (element == null) { + reject(new Error(`waitForPartRest: no ${host} element`)); + return; + } + const deadline = performance.now() + ms; + const read = () => { + const style = getComputedStyle(element, pseudo); + return [style.translate, style.scale, style.opacity].join(' | '); + }; + let last = read(); + let still = 0; + const tick = () => { + const now = read(); + still = now === last ? still + 1 : 0; + last = now; + if (still >= need) { + resolve(); + } else if (performance.now() > deadline) { + reject(new Error(`waitForPartRest: ${host}${pseudo} still moving`)); + } else { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }), + {host: part.host, pseudo: part.pseudo, ms: timeout, need: frames}, + ); +} diff --git a/packages/lab/src/SearchField/index.ts b/packages/lab/src/SearchField/index.ts new file mode 100644 index 0000000000000..8f63d3cd22a28 --- /dev/null +++ b/packages/lab/src/SearchField/index.ts @@ -0,0 +1,14 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file index.ts + * @input SearchField.tsx + * @output Re-exports SearchField and its types + * @position Entry of lab's SearchField directory + */ + +export { + SearchField, + type SearchFieldHandle, + type SearchFieldProps, +} from './SearchField'; diff --git a/packages/lab/src/SearchField/searchFieldModel.test.ts b/packages/lab/src/SearchField/searchFieldModel.test.ts new file mode 100644 index 0000000000000..176707496a367 --- /dev/null +++ b/packages/lab/src/SearchField/searchFieldModel.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file searchFieldModel.test.ts + * @input vitest, searchFieldModel.ts and the UIKit timing table + * @output The model's geometry and clock: the active width on an iPhone 17 + * Pro, every part's pose at rest and active (both directions), the eased + * curve's ends, symmetry and velocities, and the bottom placement: its edges, the measured + * frames played as curves, its poses and its lift above the keyboard + * @position Testing; validates searchFieldModel.ts without a DOM + */ + +import {describe, expect, it} from 'vitest'; +import { + ACTIVATION_S, + activationEase, + activeFieldWidth, + easedCurve, + heldCurve, + poseFor, + bottomEdges, + bottomMotion, + bottomPoseFor, + bottomLift, + framesCurve, +} from './searchFieldModel'; +import {timing} from '@astryxdesign/core/internal/touch'; + +const IPHONE = {restWidth: 370, height: 44, lift: 99, isRtl: false}; + +describe('searchFieldModel', () => { + it('runs on the UIKit activation clock and curve', () => { + expect(ACTIVATION_S).toBeCloseTo(0.3833, 4); + // UIKit's ease-in-ease-out (the `timing.curves.move` role): symmetric, + // slow at the start. + expect(timing.curves.move).toEqual([0.42, 0, 0.58, 1]); + expect(activationEase(0.5)).toBeCloseTo(0.5, 6); + expect(activationEase(0.25)).toBeLessThan(0.2); + }); + + it('narrows the field to 315 px beside the close button on a 402 px screen', () => { + expect(activeFieldWidth(370)).toBe(315); + }); + + it('poses every part at rest and active', () => { + expect(poseFor(false, IPHONE)).toEqual({ + y: 0, + middleScale: 1, + endX: 0, + closeX: 44, + closeOpacity: 0, + }); + const active = poseFor(true, IPHONE); + expect(active.y).toBe(-99); + expect(active.endX).toBe(-55); + expect(active.middleScale).toBeCloseTo(271 / 326, 9); + expect(active.closeX).toBe(0); + expect(active.closeOpacity).toBe(1); + }); + + it('mirrors the trailing end and the close button right to left', () => { + const rtl = {...IPHONE, isRtl: true}; + expect(poseFor(false, rtl).closeX).toBe(-44); + expect(poseFor(true, rtl).endX).toBe(55); + }); + + it('eases from rest to rest, symmetric, through its middle at half the clock', () => { + const curve = easedCurve(0, -55, ACTIVATION_S); + expect(curve.duration).toBeCloseTo(0.3833, 4); + expect(curve.at(0)).toBe(0); + expect(curve.at(ACTIVATION_S)).toBeCloseTo(-55, 9); + expect(curve.at(ACTIVATION_S / 2)).toBeCloseTo(-27.5, 6); + expect(Math.abs(curve.velocity(0))).toBeLessThan(1); + expect(Math.abs(curve.velocity(ACTIVATION_S))).toBeLessThan(1); + // Fastest in the middle: 55 px over 0.383 s at the curve's peak slope. + expect(curve.velocity(ACTIVATION_S / 2)).toBeLessThan(-200); + expect(easedCurve(3, 9, 0).at(0)).toBe(9); + }); + + it('holds a value to the end of the clock, then lands on its target', () => { + const curve = heldCurve(0.6, 0, ACTIVATION_S); + expect(curve.at(0)).toBe(0.6); + expect(curve.at(ACTIVATION_S - 0.001)).toBe(0.6); + expect(curve.at(ACTIVATION_S)).toBe(0); + expect(curve.velocity(0.1)).toBe(0); + }); + + describe('bottom placement', () => { + it('puts the edges where iOS does on a 402 px screen', () => { + expect(bottomEdges(402)).toEqual({ + restLeading: 33, + restTrailing: 369, + activeLeading: 17, + activeTrailing: 325, + }); + }); + + it('plays a frame table linearly between rows and holds its ends', () => { + const curve = framesCurve( + [ + [0, 0], + [100, 10], + [200, 30], + ], + row => row[1], + ); + expect(curve.duration).toBeCloseTo(0.2, 6); + expect(curve.at(0)).toBe(0); + expect(curve.at(0.05)).toBeCloseTo(5, 6); + expect(curve.at(0.15)).toBeCloseTo(20, 6); + expect(curve.at(0.5)).toBe(30); + expect(curve.velocity(0.15)).toBeCloseTo(200, 0); + }); + + it('activates from rest to the active field, swelling past both places on the way', () => { + const motion = bottomMotion(true, 402); + const end = motion.leading.duration; + expect(motion.leading.at(0)).toBeCloseTo(33, 6); + expect(motion.trailing.at(0)).toBeCloseTo(369, 6); + expect(motion.leading.at(end)).toBeCloseTo(17, 6); + expect(motion.trailing.at(motion.trailing.duration)).toBeCloseTo(325, 6); + // The swell: the trailing edge goes out past rest before it narrows, + // the leading edge past its active place. + const samples = Array.from({length: 28}, (_, i) => i / 60); + expect( + Math.max(...samples.map(t => motion.trailing.at(t))), + ).toBeGreaterThan(380); + expect(Math.min(...samples.map(t => motion.leading.at(t)))).toBeLessThan( + 14, + ); + expect(motion.closeOpacity.at(0)).toBe(0); + expect(motion.closeOpacity.at(motion.closeOpacity.duration)).toBe(1); + expect(motion.closeScale.at(motion.closeScale.duration)).toBe(1); + }); + + it('cancels from 16 px inside the active field at each side back to rest', () => { + const motion = bottomMotion(false, 402); + expect(motion.leading.at(0)).toBeCloseTo(33, 6); + expect(motion.trailing.at(0)).toBeCloseTo(309, 0); + expect(motion.trailing.at(motion.trailing.duration)).toBeCloseTo(369, 6); + expect(motion.closeX.at(0)).toBe(-16); + expect(motion.closeOpacity.at(motion.closeOpacity.duration)).toBe(0); + }); + + it('settles to the same poses the frames end at', () => { + expect(bottomPoseFor(true, 402)).toEqual({ + leading: 17, + trailing: 325, + heightScale: 1, + closeX: 0, + closeScale: 1, + closeOpacity: 1, + }); + expect(bottomPoseFor(false, 402)).toMatchObject({ + leading: 33, + trailing: 369, + heightScale: 1, + closeOpacity: 0, + }); + }); + + it('lifts the field above the keyboard: its centre 36 px above it while active, the resting gap otherwise', () => { + // The resting field's centre 52 px above the viewport's bottom edge. + expect(bottomLift(true, 335, 52)).toBe(319); + expect(bottomLift(false, 335, 52)).toBe(335); + // With the keyboard down the field rests, active or not. + expect(bottomLift(true, 0, 52)).toBe(0); + expect(bottomLift(false, 0, 52)).toBe(0); + }); + }); +}); diff --git a/packages/lab/src/SearchField/searchFieldModel.ts b/packages/lab/src/SearchField/searchFieldModel.ts new file mode 100644 index 0000000000000..80a838d13258e --- /dev/null +++ b/packages/lab/src/SearchField/searchFieldModel.ts @@ -0,0 +1,360 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file searchFieldModel.ts + * @input The search table (internal/searchMotion.ts) and Core's cubic-bézier + * curves (`timing` and `cubicBezier` from `@astryxdesign/core/internal/touch`) + * @output Pure math for SearchField: the active field's width, each part's + * target at rest and while active, and the timed curves that move them + * @position The model behind SearchField, with no DOM and no React + * + * SYNC: When modified, update searchFieldModel.test.ts and SearchField.tsx. + */ + +import { + cubicBezier, + type MotionCurve, + timing, +} from '@astryxdesign/core/internal/touch'; +import {search} from '../internal/searchMotion'; + +const {bottom} = search; + +/** + * The activation's curve, UIKit's ease-in-ease-out (the `timing.curves.move` + * role), as a function of the clock's fraction. + */ +export const activationEase = cubicBezier(timing.curves.move); + +/** The activation's length, s. */ +export const ACTIVATION_S = search.activationMs / 1000; + +/** An activation's clock and curve. */ +export interface ActivationTiming { + /** The full activation's length, s. */ + readonly seconds: number; + /** The curve, as a function of the clock's fraction. */ + readonly ease: (x: number) => number; +} + +/** The activation's clock and curve: UIKit's 383 ms ease-in-ease-out. */ +export function activationTiming(): ActivationTiming { + return {seconds: ACTIVATION_S, ease: activationEase}; +} + +/** + * The active field's width beside the close button, from its resting width + * (both px). The field gives up the close button and the gap before it. + */ +export function activeFieldWidth(restWidth: number): number { + return Math.max( + 0, + restWidth - search.stacked.closeGap - search.stacked.closeSize, + ); +} + +/** Values of the animated parts at one end of the activation. */ +export interface SearchFieldPose { + /** The whole field's rise, px (negative is up). */ + y: number; + /** The field background's stretchable middle, as a fraction of its width. */ + middleScale: number; + /** The field's trailing end (its end cap and clear button), px. */ + endX: number; + /** The close button's offset from its place, px. */ + closeX: number; + /** The close button's opacity. */ + closeOpacity: number; +} + +export interface SearchFieldGeometry { + /** The field's resting width, px. */ + restWidth: number; + /** The field's height, px: its rounded ends are half of it each. */ + height: number; + /** How far the host lifts the active field, px (0: it stays put). */ + lift: number; + /** Right-to-left: the trailing end is on the left. */ + isRtl: boolean; +} + +/** Where each part rests while inactive (`isActive` false) or active. */ +export function poseFor( + isActive: boolean, + {restWidth, height, lift, isRtl}: SearchFieldGeometry, +): SearchFieldPose { + const direction = isRtl ? -1 : 1; + if (!isActive) { + return { + y: 0, + middleScale: 1, + endX: 0, + closeX: direction * search.stacked.closeTravel, + closeOpacity: 0, + }; + } + const activeWidth = activeFieldWidth(restWidth); + const middle = Math.max(1, restWidth - height); + return { + y: -lift, + middleScale: Math.max(0, activeWidth - height) / middle, + endX: -direction * (restWidth - activeWidth), + closeX: 0, + closeOpacity: 1, + }; +} + +/** + * A timed move from `from` to `to` on the activation curve (or `ease`) over + * `duration` seconds, as a curve the animator plays. + */ +export function easedCurve( + from: number, + to: number, + duration: number, + ease: (x: number) => number = activationEase, +): MotionCurve { + const delta = to - from; + const step = 1e-4; + return { + duration, + at(t) { + if (duration <= 0) { + return to; + } + return from + delta * ease(Math.min(1, Math.max(0, t / duration))); + }, + velocity(t) { + if (duration <= 0 || delta === 0) { + return 0; + } + const x = Math.min(1, Math.max(0, t / duration)); + const lo = Math.max(0, x - step); + const hi = Math.min(1, x + step); + return (delta * (ease(hi) - ease(lo))) / (hi - lo) / duration; + }, + }; +} + +/** + * Holds `from` for `duration` seconds, then is `to`. The close button's + * opacity ends a cancel this way: it slides out at the opacity it has and is + * removed at the end. + */ +export function heldCurve( + from: number, + to: number, + duration: number, +): MotionCurve { + return { + duration, + at: t => (t >= duration ? to : from), + velocity: () => 0, + }; +} + +/** A frame table: rows of [ms, ...values]. */ +export type FrameTable = readonly (readonly number[])[]; + +/** + * A curve through measured frames: linear between rows, held at both ends. + * `value` reads one row (already mapped to the property's unit). + */ +export function framesCurve( + frames: FrameTable, + value: (row: readonly number[]) => number, +): MotionCurve { + const last = frames[frames.length - 1]; + const duration = last[0] / 1000; + const at = (t: number): number => { + const ms = t * 1000; + if (ms <= frames[0][0]) { + return value(frames[0]); + } + if (ms >= last[0]) { + return value(last); + } + let i = 1; + while (frames[i][0] < ms) { + i++; + } + const a = frames[i - 1]; + const b = frames[i]; + const f = (ms - a[0]) / (b[0] - a[0]); + return value(a) + (value(b) - value(a)) * f; + }; + return { + duration, + at, + velocity(t) { + const h = 0.001; + return ( + (at(t + h) - at(Math.max(0, t - h))) / (t + h - Math.max(0, t - h)) + ); + }, + }; +} + +/** The bottom field's edges, px from the root's leading edge. */ +export interface BottomEdges { + restLeading: number; + restTrailing: number; + activeLeading: number; + activeTrailing: number; +} + +/** Where the bottom field's edges rest and go, in a root `width` px wide. */ +export function bottomEdges(width: number): BottomEdges { + return { + restLeading: bottom.restInset, + restTrailing: width - bottom.restInset, + activeLeading: bottom.activeInset, + activeTrailing: + width - bottom.activeInset - bottom.closeSize - bottom.closeGap, + }; +} + +/** One frame of the bottom field, in px and plain numbers. */ +export interface BottomPose { + /** The field's leading edge, px from the root's leading edge. */ + leading: number; + /** The field's trailing edge, px from the root's leading edge. */ + trailing: number; + /** The background's height as a fraction of its resting height. */ + heightScale: number; + /** The close button's offset from its place, px toward the trailing end. */ + closeX: number; + /** The close button's scale. */ + closeScale: number; + /** The close button's opacity. */ + closeOpacity: number; +} + +/** A row of a field gel table ([ms, leading, trailing, height]) as px. */ +export function bottomFieldAt( + row: readonly number[], + edges: BottomEdges, +): Pick { + return { + leading: + edges.restLeading + row[1] * (edges.activeLeading - edges.restLeading), + trailing: + edges.restTrailing + row[2] * (edges.activeTrailing - edges.restTrailing), + heightScale: row[3], + }; +} + +/** The bottom field settled inactive or active. */ +export function bottomPoseFor(isActive: boolean, width: number): BottomPose { + const edges = bottomEdges(width); + const enter = bottom.closeEnterFrames; + const hidden = enter[0]; + return isActive + ? { + leading: edges.activeLeading, + trailing: edges.activeTrailing, + heightScale: 1, + closeX: 0, + closeScale: 1, + closeOpacity: 1, + } + : { + leading: edges.restLeading, + trailing: edges.restTrailing, + heightScale: 1, + closeX: hidden[3], + closeScale: hidden[2], + closeOpacity: 0, + }; +} + +/** The curves that play the bottom field's activation or cancel. */ +export interface BottomMotion { + leading: MotionCurve; + trailing: MotionCurve; + heightScale: MotionCurve; + closeX: MotionCurve; + closeScale: MotionCurve; + closeOpacity: MotionCurve; +} + +/** + * The bottom field's activation (`isActive` true) or cancel as measured + * curves, in a root `width` px wide. + */ +export function bottomMotion(isActive: boolean, width: number): BottomMotion { + const edges = bottomEdges(width); + const field = isActive ? bottom.activationFrames : bottom.cancelFrames; + const hold = (value: number): MotionCurve => ({ + duration: 0, + at: () => value, + velocity: () => 0, + }); + if (isActive) { + const enter = bottom.closeEnterFrames; + return { + leading: framesCurve(field, row => bottomFieldAt(row, edges).leading), + trailing: framesCurve(field, row => bottomFieldAt(row, edges).trailing), + heightScale: framesCurve(field, row => row[3]), + closeX: framesCurve(enter, row => row[3]), + closeScale: framesCurve(enter, row => row[2]), + closeOpacity: framesCurve(enter, row => row[1]), + }; + } + const exit = bottom.closeExitFrames; + return { + leading: framesCurve(field, row => bottomFieldAt(row, edges).leading), + trailing: framesCurve(field, row => bottomFieldAt(row, edges).trailing), + heightScale: framesCurve(field, row => row[3]), + closeX: hold(-bottom.cancelNarrowing), + closeScale: framesCurve(exit, row => row[2]), + closeOpacity: framesCurve(exit, row => row[1]), + }; +} + +/** + * A change of mind mid-move: eased curves from the pose the field is in to + * its settled pose for `isActive`. They run on the activation's whole clock + * (`seconds`, `ease`). SearchField adds them to the curves on screen (the + * animator's `additive`), as UIKit's animations add. The measured frame + * tables start from a settled field, so replaying one would first snap the + * field there. + */ +export function bottomRedirect( + from: BottomPose, + isActive: boolean, + width: number, + seconds: number, + ease: (x: number) => number, +): BottomMotion { + const to = bottomPoseFor(isActive, width); + const curve = (key: keyof BottomPose) => + easedCurve(from[key], to[key], seconds, ease); + return { + leading: curve('leading'), + trailing: curve('trailing'), + heightScale: curve('heightScale'), + closeX: curve('closeX'), + closeScale: curve('closeScale'), + closeOpacity: curve('closeOpacity'), + }; +} + +/** + * How far the bottom field sits above its resting place for a keyboard + * `inset`, px. Positive is up, and layout applies it as the root's `bottom`. + * While active with the keyboard up, the field's centre stays + * `keyboardCenterOffset` above the keyboard's top. Otherwise the bar keeps + * its resting gap above the keyboard (0 with the keyboard down). `restGap` + * is how far the field's resting centre sits above the viewport's bottom + * edge. + */ +export function bottomLift( + isActive: boolean, + inset: number, + restGap: number, +): number { + return isActive && inset > 0 + ? inset + bottom.keyboardCenterOffset - restGap + : inset; +} diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index 5c7f9fcfb441e..8476272ba2707 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -333,6 +333,13 @@ export { type MobilePowerSearchProps, } from './MobilePowerSearch'; +// SearchField: the phone search field of iOS 26's UISearchController. +export { + SearchField, + type SearchFieldHandle, + type SearchFieldProps, +} from './SearchField'; + // RichTextEditor (RFC meta/astryx#3899) moved out of @astryxdesign/lab into // its own package, @astryxdesign/richtext. Import it from there: // import {RichTextEditor, RichTextView} from '@astryxdesign/richtext'; diff --git a/packages/lab/src/internal/searchMotion.test.ts b/packages/lab/src/internal/searchMotion.test.ts new file mode 100644 index 0000000000000..ede1bd81449ad --- /dev/null +++ b/packages/lab/src/internal/searchMotion.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file searchMotion.test.ts + * @input vitest, Lab's search-bar values and Core's UIKit timing table + * @output The search table's values agree with each other and with the + * frames they were read from: the activation runs on UIKit's + * ease-in-ease-out, the stacked field narrows to 315 pt beside its close + * button on a 402 pt screen, the bottom field rests 17 pt above the + * keyboard, its geometry and measured frames agree (336 pt at rest, 308 + * active beside a 38 pt close button, every frame table in time order + * ending where the field rests) + * @position Testing; validates searchMotion.ts without a browser + */ + +import {describe, expect, it} from 'vitest'; +import {timing} from '@astryxdesign/core/internal/touch'; +import {search} from './searchMotion'; + +describe('UIKit search table', () => { + it('activates over 383 ms on UIKit’s ease-in-ease-out, the timing.curves.move role', () => { + // The animator's UICubicTimingParameters, read at setup, are the role's + // control points; the table keeps no copy of the curve. + expect(timing.curves.move).toEqual([0.42, 0, 0.58, 1]); + expect('activationCurve' in search).toBe(false); + expect(search.activationMs).toBeCloseTo(383.3, 1); + }); + + it('holds no Reduce Motion setting: design:reduced-motion decides', () => { + expect('reducedMotion' in search).toBe(false); + }); + + it('narrows the stacked field to 315 pt beside the close button on a 402 pt screen', () => { + const {fieldInset, closeGap, closeSize} = search.stacked; + const rest = 402 - 2 * fieldInset; + expect(rest).toBe(370); + expect(rest - closeGap - closeSize).toBe(315); + // The close button ends at the trailing inset (x 342 + 44 = 386). + expect(402 - fieldInset - closeSize).toBe(342); + }); + + it('rests the bottom field 17 pt above the keyboard, as the cited frames put it', () => { + const {fieldHeight, keyboardCenterOffset} = search.bottom; + // The cited capture: the pinned field's centre at y 503 with the + // keyboard's top at 539 (the value's @source). + const centre = 503; + const keyboardTop = 539; + expect(keyboardCenterOffset).toBe(keyboardTop - centre); + expect(keyboardTop - (centre + fieldHeight / 2)).toBe(17); + }); + + it('lays the bottom field out as measured on a 402 pt screen', () => { + const {restInset, activeInset, closeSize, closeGap, activeWidth} = + search.bottom; + expect(402 - 2 * restInset).toBe(336); + expect(402 - activeInset - closeSize - closeGap - activeInset).toBe( + activeWidth, + ); + // The close button at x 347, the active field ending at x 325. + expect(402 - activeInset - closeSize).toBe(347); + expect(activeInset + activeWidth).toBe(325); + }); + + it('keeps every bottom frame table in time order, ending where the field rests', () => { + const { + activationFrames, + cancelFrames, + closeEnterFrames, + closeExitFrames, + cancelNarrowing, + restInset, + activeInset, + closeSize, + closeGap, + } = search.bottom; + for (const table of [ + activationFrames, + cancelFrames, + closeEnterFrames, + closeExitFrames, + ]) { + const times = table.map(row => row[0]); + expect(times[0]).toBe(0); + for (let i = 1; i < times.length; i++) { + expect(times[i]).toBeGreaterThan(times[i - 1]); + } + } + expect(activationFrames[0].slice(1)).toEqual([0, 0, 1]); + expect(activationFrames.at(-1)?.slice(1)).toEqual([1, 1, 1]); + expect(cancelFrames.at(-1)?.slice(1)).toEqual([0, 0, 1]); + expect(closeEnterFrames.at(-1)?.slice(1)).toEqual([1, 1, 0]); + expect(closeExitFrames.at(-1)?.[1]).toBe(0); + // A cancel starts with the trailing edge `cancelNarrowing` inside the + // active field's (x 309 on a 402 pt screen). + const travel = 402 - restInset - (402 - activeInset - closeSize - closeGap); + expect(cancelFrames[0][2]).toBeCloseTo( + (travel + cancelNarrowing) / travel, + 2, + ); + }); +}); diff --git a/packages/lab/src/internal/searchMotion.ts b/packages/lab/src/internal/searchMotion.ts new file mode 100644 index 0000000000000..0793c5d7bb4a5 --- /dev/null +++ b/packages/lab/src/internal/searchMotion.ts @@ -0,0 +1,182 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file searchMotion.ts + * @input Measurements of UIKit's search bar on the iOS 26.2 simulator + * @output `search`: UISearchController's activation clock, the stacked and + * bottom fields' geometry, and the close and clear buttons + * @position Lab's measured values for iOS 26's search bar. Only Lab reads + * them, so they stay in Lab and not in Core's UIKit tables. + * + * SYNC: When modified, update searchMotion.test.ts. + */ + +/** + * UISearchController's search bar: activation, cancel, geometry and + * placements. Every leaf names its source in `@source`. A `measured` value + * comes from the UIKit lab: an injected host app on the iOS 26.2 simulator, + * iPhone 17 Pro. + */ +export const search = { + /** + * The activation's clock, in ms. One UIViewPropertyAnimator moves the + * field, its width, the close button and the large title. It runs on + * UIKit's ease-in-ease-out, the `timing.curves.move` role, whose control + * points are the animator's UICubicTimingParameters. Cancel runs the same + * animator backwards over the same time. + * @source measured: UIViewPropertyAnimator duration and + * UICubicTimingParameters read at setup, and the field's frame per frame + * (rms 1.5 % against this clock and curve) + */ + activationMs: 383.3, + // Reduce Motion: UIKit keeps the activation's travel with or without + // Prefer Cross-Fade Transitions, measured in the same lab. Astryx's own + // reduced-motion handling decides instead, so the table has no setting for + // it. + stacked: { + /** + * The stacked field's height, pt. + * @source measured: UISearchTextField frame (370 × 44 at x 16) + */ + fieldHeight: 44, + /** + * The field's inset from each side of its container, pt (370 wide on a + * 402 pt screen). + * @source measured: UISearchTextField frame + */ + fieldInset: 16, + /** + * The glass close button that replaces iOS 18's Cancel text: a square of + * this side, pt, at the trailing inset. + * @source measured: the close UIButton's frame (44 × 44 at x 342) + */ + closeSize: 44, + /** + * The gap between the active field and the close button, pt. The active + * field narrows to the container's width minus the two insets, this gap + * and the button (315 on a 402 pt screen). + * @source measured: the active field (x 16, 315 wide) and the close button + * (x 342) + */ + closeGap: 11, + /** + * How far past its resting place the close button starts, toward the + * trailing edge, pt. It slides in from there while its opacity goes 0 → 1, + * both on the activation's curve. On cancel it slides back out at full + * opacity and is removed at the end. + * @source measured: the close button's frame and alpha per frame (x 386 → + * 342, alpha 0 → 1; cancel 342 → 386 at alpha 1) + */ + closeTravel: 44, + }, + bottom: { + /** + * The bottom (toolbar) field's height at rest, pt. It is 336 wide at the + * bottom of a 402 pt screen. A tab bar's search tab has its own glass + * geometry, which belongs with the tab bar's values. + * @source measured: the `.integrated` field's frame (336 × 38 at y 803) + */ + fieldHeight: 38, + /** + * While active, the field's centre stays this far above the keyboard's + * top edge, pt (a 17 pt gap), on every frame. The bar rides the + * keyboard's own spring (`springs.keyboard`). Only the bottom field was + * measured, so this value makes no claim about a tab bar's search field. + * @source measured: the bar container's additive CASpringAnimation on + * position, the keyboard's spring. The field's centre against the + * keyboard per frame (y 503, keyboard top 539). + */ + keyboardCenterOffset: 36, + /** + * The active field's width, pt, beside the close button. + * @source measured: the `.integrated` field's frame once settled + */ + activeWidth: 308, + /** + * The resting field's inset from each side of the screen, pt. It sits + * 33 pt above the screen's bottom edge. While the keyboard hides, the bar + * keeps that gap above it, so the field's centre is 52 pt above the + * keyboard's top. + * @source measured: the field's frame at rest (x 33, 336 wide) and per + * frame against the keyboard during the hide + */ + restInset: 33, + /** + * The active field's leading inset, pt. The close button keeps the same + * inset from the trailing edge. + * @source measured: the active field at x 17 and the close button ending + * 17 pt from the trailing edge + */ + activeInset: 17, + /** + * The close button's side, pt (a 38 pt button in a 48 pt glass). + * @source measured: `_UIButtonBarButton` 38 × 38 at (347, 484) + */ + closeSize: 38, + /** + * Between the active field and the close button, pt. + * @source measured: the field ending at x 325 and the close button starting + * at x 347 + */ + closeGap: 22, + /** + * At a cancel's start the active field narrows by this much on each side, + * pt. The close button moves this much toward it, before the field springs + * out to rest. + * @source measured: the field at x 33, 276 wide, and the close button's + * centre at x 350 on the hide's first frame (two runs) + */ + cancelNarrowing: 16, + /** + * The activation's gel, per 60 Hz frame from the activation animator's + * start. The columns are [ms, leading-edge progress, trailing-edge + * progress, height scale], with progress 0 at rest and 1 active. SwiftUI + * sets these frames each frame with no animation object, so the measured + * frames are the curve. The field swells, then narrows past its place and + * settles. + * @source measured: the field's frame per frame from the animator's setup + */ + // prettier-ignore + activationFrames: [[0, 0, 0, 1], [66, 1.076, -0.365, 1.003], [87, 1.008, -0.35, 0.999], [103, 0.994, -0.203, 0.998], [120, 1.099, -0.019, 1.076], [137, 1.201, 0.183, 1.152], [153, 1.243, 0.387, 1.162], [170, 1.206, 0.581, 1.115], [187, 1.146, 0.742, 1.059], [203, 1.042, 0.877, 1.014], [220, 0.994, 0.96, 0.989], [237, 0.946, 1.017, 0.979], [253, 0.933, 1.043, 0.979], [270, 0.931, 1.052, 0.983], [287, 0.938, 1.051, 0.989], [303, 0.952, 1.043, 0.994], [320, 0.964, 1.033, 0.997], [337, 0.978, 1.024, 0.998], [353, 0.986, 1.016, 0.999], [370, 0.993, 1.002, 0.999], [387, 0.996, 1.002, 0.999], [403, 0.999, 1, 1], [420, 0.999, 1, 1], [437, 0.999, 1, 1], [453, 1, 1, 1]], + /** + * The cancel's gel from the keyboard's start, in the same columns. The + * trailing edge starts `cancelNarrowing` inside the active field's and + * springs out past its rest. The field swells up to 1.2 times its height. + * @source measured: the field's frame per frame from the hide's start (two + * runs) + */ + // prettier-ignore + cancelFrames: [[0, 0, 1.364, 1], [17, 0, 1.364, 1], [34, 0, 1.252, 1], [51, -0.004, 0.948, 1.079], [67, -0.007, 0.631, 1.144], [84, -0.009, 0.351, 1.191], [101, -0.009, 0.15, 1.2], [117, -0.009, 0.001, 1.202], [134, -0.009, -0.103, 1.176], [151, -0.008, -0.142, 1.119], [167, -0.006, -0.128, 1.052], [184, -0.003, -0.094, 1.02], [201, -0.001, -0.058, 0.992], [217, 0, -0.031, 0.985], [234, 0.001, -0.013, 0.984], [251, 0.001, -0.003, 0.988], [267, 0.001, 0, 0.994], [284, 0.001, 0.001, 0.997], [301, 0, 0, 1]], + /** + * The close button's entrance from the activation animator's start: [ms, + * opacity, scale, x offset pt]. It rides hidden inside the field's + * trailing end. Then it grows from 7.6 pt, fades in and moves out to its + * place, and overshoots to 39.3 pt. SwiftUI plays it with no animation + * object. + * @source measured: `_UIButtonBarButton` frame and effective opacity per + * frame + */ + // prettier-ignore + closeEnterFrames: [[0, 0, 0.2, -16.67], [153, 0, 0.238, -16.67], [170, 0.024, 0.257, -16.26], [187, 0.067, 0.287, -15.55], [203, 0.157, 0.348, -14.05], [220, 0.228, 0.396, -12.86], [237, 0.342, 0.474, -10.96], [253, 0.42, 0.53, -9.67], [270, 0.532, 0.615, -7.81], [287, 0.602, 0.671, -6.64], [303, 0.698, 0.75, -5.04], [320, 0.755, 0.798, -4.09], [337, 0.829, 0.861, -2.85], [353, 0.871, 0.896, -2.15], [370, 0.923, 0.939, -1.28], [387, 0.958, 0.966, -0.7], [403, 0.985, 0.988, -0.25], [420, 1, 1.005, 0.1], [437, 1, 1.015, 0.31], [453, 1, 1.025, 0.52], [470, 1, 1.029, 0.61], [487, 1, 1.033, 0.68], [503, 1, 1.033, 0.7], [520, 1, 1.033, 0.68], [537, 1, 1.031, 0.65], [553, 1, 1.028, 0.59], [570, 1, 1.026, 0.54], [587, 1, 1.022, 0.46], [603, 1, 1.019, 0.41], [620, 1, 1.016, 0.33], [637, 1, 1.013, 0.27], [653, 1, 1.011, 0.22], [670, 1, 1.008, 0.16], [687, 1, 1.006, 0], [703, 1, 1, 0]], + /** + * The close button's exit from the keyboard's start: [ms, opacity, + * scale]. It stays where `cancelNarrowing` put it and swells from the tap. + * Then it shrinks and fades as the field covers it. + * @source measured: `_UIButtonBarButton` frame and effective opacity per + * frame (two runs) + */ + // prettier-ignore + closeExitFrames: [[0, 1, 1.069], [17, 1, 1.387], [34, 0.918, 1.282], [51, 0.76, 1.083], [67, 0.581, 0.861], [84, 0.414, 0.66], [101, 0.274, 0.497], [117, 0.167, 0.376], [134, 0.089, 0.291], [151, 0.037, 0.236], [167, 0.005, 0.201], [184, 0, 0.182]], + }, + clear: { + /** + * The clear button's side inside the field, pt. It appears and disappears + * at once, with no fade. A tap clears the text without moving the + * keyboard. Tapped while the field is not editing, it also makes the field + * first responder. + * @source measured: `_UITextFieldClearButton` frame and alpha per frame, + * and UIKeyboardWillShow 75 ms after a tap while not editing + */ + size: 20, + }, +} as const; From 53c7f495d8eda8922b03962a7924bc7224eef8d3 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 17:04:34 -0800 Subject: [PATCH 3/6] chore(core): Core's internal touch entry re-exports what Lab's TabBar reads, and its strings join the catalog Re-exports only, no new behavior: @astryxdesign/core/internal/touch adds the pan recognizer, the press tracker, the action-group tracker, the finger-scroll reader, the text-selection lock, the touch query, the rubber band and UIKit's gesture values, none under a public factory's name. Core's message catalog gains TabBar's three strings: the More tab's label, the search's Cancel button and a badged tab's name. They land ahead of the Lab component, so the Lab batches change only Lab. The entry stays outside the public API: no docs, no root re-export, and the @astryx/no-core-internal-import rule keeps it to Core and Lab. One type widens: useFingerScroll also takes a ref that holds a document. It already watched a document passed to it directly, so nothing runs differently. A component can then fill the ref from its own element and watch the page of the document that element is in. --- .../core-internal-entries-lab-tabbar.md | 7 ++++++ packages/core/locales/en.json | 12 ++++++++++ .../__tests__/useFingerScroll.test.tsx | 24 +++++++++++++++++++ packages/core/src/gestures/useFingerScroll.ts | 9 +++---- packages/core/src/internal/touch.test.ts | 17 +++++++++++++ packages/core/src/internal/touch.ts | 9 +++++++ 6 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 .changeset/core-internal-entries-lab-tabbar.md diff --git a/.changeset/core-internal-entries-lab-tabbar.md b/.changeset/core-internal-entries-lab-tabbar.md new file mode 100644 index 0000000000000..b20ca51530bae --- /dev/null +++ b/.changeset/core-internal-entries-lab-tabbar.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[experimental] Core's internal `@astryxdesign/core/internal/touch` entry re-exports the pan recognizer, press tracker, action-group tracker, finger-scroll reader, selection lock, touch query, rubber band and UIKit gesture values for Lab components. The finger-scroll reader also takes a ref that holds a document. Three tab-bar strings join Core's message catalog. + +@vjeux diff --git a/packages/core/locales/en.json b/packages/core/locales/en.json index 08bbb507d3663..9984741078baf 100644 --- a/packages/core/locales/en.json +++ b/packages/core/locales/en.json @@ -811,10 +811,22 @@ "defaultMessage": "Maximum value", "description": "Accessible name of the upper thumb of a range Slider (two thumbs), announced together with the Slider's own label, for example \"Price range, Maximum value, 80\". Never shown on screen." }, + "@astryx.tabBar.badgedItem": { + "defaultMessage": "{label}, {badge}", + "description": "Screen-reader name of a bottom tab bar item that shows a badge (\"Library, 3\"). {label} is the tab's visible name; {badge} is the badge's count or its own label (\"3 unread\"). Translators may reorder the two or change the separator." + }, "@astryx.tabList.label": { "defaultMessage": "Tabs", "description": "Screen-reader-only fallback name for a horizontal tab bar. Plural noun; \"Tabs\" here = UI tab panels, not browser tabs or the Tab key." }, + "@astryx.tabBar.more": { + "defaultMessage": "More", + "description": "Label of the tab that collects a phone tab bar's sections past the fourth (iOS's More tab), and the name of the list of those sections it shows. One short word." + }, + "@astryx.tabBar.cancelSearch": { + "defaultMessage": "Cancel", + "description": "Name of the button that ends search in a phone tab bar's search tab. The tab bar draws it over its search circle, beside the active field, as iOS draws its cancel button. It is the tab bar's own button, not the search field's own Cancel button. One short word." + }, "@astryx.table.label": { "defaultMessage": "Table", "description": "Fallback screen-reader-only accessible name for a data-table region when the consumer provides none. Noun (\"a table\"), not the verb \"to table\"." diff --git a/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx b/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx index ea484425d0d02..ba6a1eac1f8c4 100644 --- a/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx +++ b/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx @@ -76,6 +76,20 @@ function Page() { return

row

; } +const pageByRef: {current: Document | null} = {current: null}; +const fillPageByRef = (element: HTMLElement | null) => { + pageByRef.current = element?.ownerDocument ?? null; +}; + +function PageByRef() { + read = useFingerScroll(pageByRef); + return ( +

+ row +

+ ); +} + // A phone: the finger-scroll watch listens only where the primary pointer is // coarse. let restorePointer: () => void; @@ -135,6 +149,16 @@ describe('useFingerScroll', () => { expect(read()).toBe(false); }); + it('reads the page’s own scroll through a ref that an element’s ref fills with its document', () => { + const {getByTestId} = render(); + touch('touchstart', getByTestId('row'), true); + document.dispatchEvent(new Event('scroll')); + expect(read()).toBe(true); + touch('touchend', getByTestId('row'), false); + document.dispatchEvent(new Event('scrollend')); + expect(read()).toBe(false); + }); + it("follows the ref's node, and stops watching on unmount", () => { const {getByTestId, rerender, unmount} = render(); rerender(); diff --git a/packages/core/src/gestures/useFingerScroll.ts b/packages/core/src/gestures/useFingerScroll.ts index 51224e7f89e3e..b83c9189e2454 100644 --- a/packages/core/src/gestures/useFingerScroll.ts +++ b/packages/core/src/gestures/useFingerScroll.ts @@ -45,12 +45,13 @@ function documentOf(node: Element | Document): Document { /** * Watch a scroller's finger scrolls. Pass a ref to a scrolling element, or - * the document for the page's own scroll. Returns a reader, stable across - * renders: `true` while the scroller's scroll is a finger's. - * Internal: not exported from the package. + * the document (or a ref that holds it) for the page's own scroll. Returns a + * reader, stable across renders: `true` while the scroller's scroll is a + * finger's. + * Internal: outside Core, only `@astryxdesign/core/internal/touch` reaches it. */ export function useFingerScroll( - scroller: RefObject | Document, + scroller: RefObject | Document, ): () => boolean { /** The node the watch is on now, for the reader. */ const watchedRef = useRef(null); diff --git a/packages/core/src/internal/touch.test.ts b/packages/core/src/internal/touch.test.ts index 83d362230472e..90923fe4b725a 100644 --- a/packages/core/src/internal/touch.test.ts +++ b/packages/core/src/internal/touch.test.ts @@ -56,6 +56,23 @@ describe('internal/touch', () => { expect(entry).not.toHaveProperty('touchModule'); }); + it('re-exports the pan recognizer, the press tracker, the action-group tracker, the finger-scroll reader, the selection lock, the touch query, the rubber band and UIKit gesture values by name', async () => { + const entry: Record = await import('./touch'); + const modules: Record> = { + PanGestureRecognizer: await import('../gestures/PanGestureRecognizer'), + useFingerScroll: await import('../gestures/useFingerScroll'), + usePressTracker: await import('../gestures/usePressTracker'), + createActionGroupTracker: await import('../hooks/actionGroupTracker'), + lockSelection: await import('../utils/selectionLock'), + TOUCH_QUERY: await import('../utils/touchQueries'), + rubberBand: await import('../motion/rubberBand'), + UIKIT_GESTURES: await import('../motion/uikit/gestures'), + }; + for (const [name, module] of Object.entries(modules)) { + expect(entry[name], name).toBe(module[name]); + } + }); + it('attaches no window or document listeners when it loads', async () => { vi.resetModules(); const onWindow = vi.spyOn(window, 'addEventListener'); diff --git a/packages/core/src/internal/touch.ts b/packages/core/src/internal/touch.ts index 14a05de5ae9b8..75ba72fe77db3 100644 --- a/packages/core/src/internal/touch.ts +++ b/packages/core/src/internal/touch.ts @@ -38,9 +38,14 @@ export { unwrapAngle, } from '../gestures/geometry'; export {LongPressGestureRecognizer} from '../gestures/LongPressGestureRecognizer'; +export {PanGestureRecognizer} from '../gestures/PanGestureRecognizer'; export {TapGestureRecognizer} from '../gestures/TapGestureRecognizer'; export {DEFAULT_ENVIRONMENT} from '../gestures/types'; +export {useFingerScroll} from '../gestures/useFingerScroll'; export {useGestureRecognizer} from '../gestures/useGestureRecognizer'; +export {usePressTracker} from '../gestures/usePressTracker'; +export {createActionGroupTracker} from '../hooks/actionGroupTracker'; +export type {ActionGroupTarget} from '../hooks/actionGroupTracker'; export {isRtlElement} from '../hooks/isRtlElement'; // The animator's internal factory, under a name no public factory has: the // public animator factory is `createAnimator`. @@ -53,6 +58,10 @@ export type { MotionHandle, } from '../motion/animator'; export {cubicBezier} from '../motion/bezier'; +export {rubberBand} from '../motion/rubberBand'; +export {UIKIT_GESTURES} from '../motion/uikit/gestures'; export {timing} from '../motion/uikit/timing'; export {claimFocusMoves} from '../utils/claimFocusMoves'; +export {lockSelection} from '../utils/selectionLock'; export {useTouchPresentation} from '../utils/touchPresentation'; +export {TOUCH_QUERY} from '../utils/touchQueries'; From 30fec75e24a933e04a13cb6be17606132b5bd9bb Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 31 Oct 2026 20:41:38 -0700 Subject: [PATCH 4/6] test(harness): a page freeze, so a proof can act at an instant of the scene's own time A proof that must touch a component mid-animation, or sample an animation from its start, races the test process: on a loaded runner the slide has moved on, or ended, before the next command reaches the page. The page freeze stops the scene instead. Every animation pauses where it stands, and timers and frame callbacks wait in the timer gate. The page's clocks stay at the freeze instant: performance.now(), Date.now(), event times, frame times and the animation timeline (new Date() keeps the real clock). Timeouts the page set before the freeze stop counting too, and come due on the page's clock after the thaw. Only the document timeline's animations are held: a scroll- or view-driven one follows its scroller. Input still arrives. Thawed, the scene plays on from where it stood and the held timers run in the order they came due. - installPageFreeze (before the page loads, then installTimerGate), freezePage, thawPage. - freezePageOn(page, type): the install adds one window capture listener for each pointer, touch, mouse, key, wheel and scroll event before any page script runs, and the call arms it, so the freeze lands in the input's own task, before any page listener for it. - freezePageWhen(page, predicate, arg): freezes on the first real frame the predicate holds, and rejects after its timeout, which runs on the real clock. - advanceFrozenPage(page, ms): the page's clock and every held animation move on together by ms; timers stay held. The timer gate gets freeze() and thaw() methods, which freezeTimers, thawTimers and the page freeze all call, and the real clock: the harness's own waits (sampleRects, armHold, waitForRest, startBusyControl, blockMainThread) time themselves on it, so they still run on a frozen page. The freeze exposes the frozen span. The curve recorder keeps real times, each page time with the span in force when it was recorded, and the harness adds the current span only to live page times, so after a thaw its epoch stamps share the trace's clock. An interval skips its ticks while frozen. A CSS animation the page resumes while frozen is held where it stood at the freeze, and a run the page cancels while frozen (a reversed CSS transition) stays cancelled at the thaw. At the thaw every held animation, one whose start the page wrote while frozen included, resumes from the time it held on the timeline's own time, so it is drawn where it stood in the thaw frame. The frozen span ends at that same frame time, so the page's clock and its runs stay together across freezes. Self-tests in Chromium and WebKit: a frozen scene stands still and thaws from where it stood; an armed freeze lands before the page's own listener (against a control where that listener runs first and a late read finds the run well along); an advance moves the clock and the held animation by its step while timers stay held; a freeze on a condition stops on the first frame it holds and times out otherwise, on a frozen page too; a start the page dates while frozen resumes exactly where it held in the thaw task; a timeout pending across a freeze comes due on the page's clock; a scroll-driven animation follows its scroller while frozen; the harness samples, arms a hold and blocks the main thread on a frozen page; a CSS animation resumed while frozen holds its time; a condition that throws rejects freezePageWhen; a CSS transition reversed while frozen plays its reversal after the thaw; after a thaw blockMainThread, waitForAnimation and sampleRects stamp on the real clock; a CSS animation dated and then removed while frozen stays removed; a run that finished before a freeze is dated where it ran when read after the thaw; the page's clock and its runs stay together across ten freeze and thaw cycles; a page without the freeze refuses one. Opt-in: a page that never calls installPageFreeze keeps its own clock. It lands ahead of its first user, TabBar's interaction proofs, so the Lab batches change only Lab. --- internal/interaction-harness/README.md | 61 ++ internal/interaction-harness/src/busy.ts | 45 +- internal/interaction-harness/src/curves.ts | 59 +- internal/interaction-harness/src/frames.ts | 14 +- internal/interaction-harness/src/freeze.ts | 392 ++++++++++++ .../src/harness.interaction.browser.spec.ts | 575 +++++++++++++++++- internal/interaction-harness/src/index.ts | 1 + internal/interaction-harness/src/story.ts | 104 +++- internal/interaction-harness/src/webInput.ts | 21 +- 9 files changed, 1211 insertions(+), 61 deletions(-) create mode 100644 internal/interaction-harness/src/freeze.ts diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index 9ba7150801f24..c4371c5452114 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -282,6 +282,67 @@ the page's surface right after it changes ("Unable to capture screenshot"); a storyboard takes that photograph again two frames later, up to three attempts in all, and lists each retake under `recaptures` in the trace. +## Page freeze + +A proof that must act on a component mid-animation (a second tap mid-slide, a +finger that lands before the last lift reaches the app) races the test +process: on a loaded runner the motion has moved on, or ended, before the next +command reaches the page. The page freeze (`src/freeze.ts`) stops the scene +instead, and the proof acts at an instant of the scene's own time. + +- `installPageFreeze(page)` installs the page clock, before the page loads + (before `openStory`); then `installTimerGate` as usual. A page that never + calls it keeps its own clock. Code that kept a bound `performance.now` + before the install keeps the real clock. +- `freezePage` and `thawPage` stop and start the scene in one page task. +- `freezePageOn(page, type)` freezes on the next `type` event, in that + event's own task and before any page listener for it: the install adds one + window capture listener for each pointer, touch, mouse, key, wheel and + scroll event before any page script runs, and the call arms it. Any other + type throws. + A freeze armed on `scroll` lands when the scroll event fires, after the + scroll position has already moved. +- `freezePageWhen(page, predicate, arg)` freezes on the first real frame the + predicate holds, and rejects after its timeout. +- `advanceFrozenPage(page, ms)` moves the page's clock and every held + animation on together by `ms`; timers and frame callbacks stay held. + +While frozen, every animation in the document is paused where it stands (Web +Animations, CSS animations and transitions), and so is any the page starts +meanwhile, at its start. The timer gate holds the page's timers and frame +callbacks. These clocks stand still: `performance.now()`, `Date.now()`, +`event.timeStamp`, frame callback times, `document.timeline.currentTime` +and `Animation.startTime`. `new Date()` keeps the real clock. Timeouts the +page set before the freeze stop counting too, and come due on the page's clock +after the thaw. An interval keeps the real clock and skips its ticks while +frozen. The harness's own waits keep the real clock, so they still sample, +hold and time out on a frozen page. Only animations on the document timeline are held: one on a +scroll or view timeline follows its scroller, frozen or not. Input still +arrives, and its handlers run, each reading the frozen instant. An animation +reads as it did before the freeze (`playState` stays `running`). + +At the thaw every held animation plays on from where it stood, dated on the +timeline's own time, so it is drawn where it stood in the thaw frame. That +includes one whose start the page wrote while frozen: the timeline reads the +current frame's time, which trails the page's clock by however far into the +frame the thaw runs, so a start dated from now would sit before its start for +the rest of that frame. After a thaw the page's times run again, behind real +time by every frozen span so far, so the page's clock and its animations on +the compositor agree as they did before the freeze. + +Not held yet: a run the page reverses, or a finished run it plays again, +while frozen. A freeze holds the top document; a frame's content keeps running. +A CSS animation a freeze held no longer follows `animation-play-state` after +the thaw. A freeze in which no frame starts ends at the frame before it, so the page's +clock and its runs come out of it up to a frame ahead. + +The clock lives in script because no engine has a native freeze that holds +animations. Chromium's `Emulation.setVirtualTimePolicy` stops +`performance.now()` and timers but not Web Animations or frame callbacks in +the headless shell (300 ms into a pause the timeline had advanced 300 ms and a +compositor animation 45 px), and its `advance` policy cannot return to real +time: it fast-forwards whenever the page is idle. + ## Engine limits (measured with Playwright 1.61) | | Chromium 149 | WebKit 26.5 (Linux build) | diff --git a/internal/interaction-harness/src/busy.ts b/internal/interaction-harness/src/busy.ts index 9e83fea66f385..b2daaa5a0a525 100644 --- a/internal/interaction-harness/src/busy.ts +++ b/internal/interaction-harness/src/busy.ts @@ -96,15 +96,31 @@ export async function startBusyControl( // The harness's own waits run on the real timers (see blockMainThread). const gate = ( window as unknown as { - __harnessTimerGate?: {frame?: typeof requestAnimationFrame}; + __harnessTimerGate?: { + frame?: typeof requestAnimationFrame; + now?: () => number; + }; } ).__harnessTimerGate; + // A frozen page holds the control's own animation. + if ( + (window as unknown as {__harnessFreeze?: {frozen: boolean}}) + .__harnessFreeze?.frozen === true + ) { + reject( + new Error( + 'startBusyControl: the page is frozen; start the control first', + ), + ); + return; + } const frame = gate?.frame ?? window.requestAnimationFrame.bind(window); - const deadline = performance.now() + 5_000; + const realNow = gate?.now ?? (() => performance.now()); + const deadline = realNow() + 5_000; const tick = () => { if (!animation.pending && Number(animation.currentTime ?? 0) >= 16) { resolve(); - } else if (performance.now() > deadline) { + } else if (realNow() > deadline) { reject(new Error('startBusyControl: the control did not start')); } else { frame(tick); @@ -218,11 +234,19 @@ export async function blockMainThread( // One reading starts the loop and one ends it. A coarsened clock can // step back between two reads, so a separate read would end it early. // The loop tests the span it returns, which a sum rounded down would not. - const start = performance.now(); + // It reads the timer gate's real clock: a page freeze stops the page's + // own, which then trails the frames after the thaw. + const read = + ( + window as unknown as { + __harnessTimerGate?: {now?: () => number}; + } + ).__harnessTimerGate?.now ?? (() => performance.now()); + const start = read(); let now = start; let spins = 0; while (now - start < duration) { - now = performance.now(); + now = read(); spins += 1; } return { @@ -242,13 +266,15 @@ export async function blockMainThread( window as unknown as { __harnessTimerGate?: { frame?: typeof requestAnimationFrame; + now?: () => number; timeout?: typeof setTimeout; }; } ).__harnessTimerGate; const frame = gate?.frame ?? window.requestAnimationFrame.bind(window); const later = gate?.timeout ?? window.setTimeout.bind(window); - const deadline = performance.now() + wait; + const realNow = gate?.now ?? (() => performance.now()); + const deadline = realNow() + wait; // The busy-window control animates on its own, inside a dialog with // `within`: it never stands for the animation under test. const isControl = (a: Animation) => { @@ -273,10 +299,11 @@ export async function blockMainThread( // the animation: it runs once that frame's rendering update has // handed the animation to the compositor. later(() => { - const start = performance.now(); + const read = realNow; + const start = read(); let now = start; while (now - start < duration) { - now = performance.now(); + now = read(); } resolve({ from: performance.timeOrigin + start, @@ -284,7 +311,7 @@ export async function blockMainThread( spanMs: now - start, }); }, 0); - } else if (performance.now() > deadline) { + } else if (realNow() > deadline) { reject(new Error('blockMainThread: no animation started')); } else { frame(tick); diff --git a/internal/interaction-harness/src/curves.ts b/internal/interaction-harness/src/curves.ts index 63aaec1b34daa..4b1569efa79df 100644 --- a/internal/interaction-harness/src/curves.ts +++ b/internal/interaction-harness/src/curves.ts @@ -77,6 +77,12 @@ function animationRecorder(): void { }[] = []; // Each recorded animation's entry. const entries = new WeakMap(); + // The times kept below are real: a page time plus the span a page freeze + // stood still so far. A later freeze then leaves them where they were. + const span = () => + (window as unknown as {__harnessFreeze?: {offset: number}}).__harnessFreeze + ?.offset ?? 0; + const real = (time: number | null) => (time == null ? null : time + span()); // Entries whose start time has not resolved yet (still pending when seen). let unstarted: (typeof log)[number][] = []; let armedAt = performance.now(); @@ -123,13 +129,14 @@ function animationRecorder(): void { .endTime ?? 0, ); const speed = Math.abs(animation.playbackRate || 1); + // The finish event's timeline time is the engine's own, already real. entry.finishStart = - playStart(animation) ?? + real(playStart(animation)) ?? entry.startedAt ?? (at == null ? null : at - end / speed); entry.finishedAt = entry.finishStart == null - ? performance.now() + ? performance.now() + span() : entry.finishStart + end / speed; if (entry.startedAt == null) { // Cancelled before a frame read its start: back from where it ended. @@ -144,7 +151,7 @@ function animationRecorder(): void { // kept is the one it runs on, which its finish falls back to once the // page has cancelled it. if (animation.playState === 'running' && animation.startTime != null) { - recorded.startedAt = playStart(animation); + recorded.startedAt = real(playStart(animation)); } return; } @@ -167,7 +174,7 @@ function animationRecorder(): void { animation, seenAt: performance.now(), finishedAt: null as number | null, - startedAt: playStart(animation), + startedAt: real(playStart(animation)), finishStart: null as number | null, ancestors, }; @@ -190,7 +197,7 @@ function animationRecorder(): void { } unstarted = unstarted.filter(entry => { if (entry.startedAt == null && entry.animation.startTime != null) { - entry.startedAt = playStart(entry.animation); + entry.startedAt = real(playStart(entry.animation)); } return entry.startedAt == null && entry.animation.playState !== 'idle'; }); @@ -709,6 +716,7 @@ export async function armHold( ) => unknown[]; __harnessTimerGate?: { frame?: typeof requestAnimationFrame; + now?: () => number; timeout?: typeof setTimeout; }; __harnessArmed?: Record>; @@ -720,7 +728,9 @@ export async function armHold( window.requestAnimationFrame.bind(window); const later = w.__harnessTimerGate?.timeout ?? window.setTimeout.bind(window); - const deadline = performance.now() + wait; + // The deadline reads the real clock, which a page freeze does not stop. + const realNow = w.__harnessTimerGate?.now ?? (() => performance.now()); + const deadline = realNow() + wait; // With `newOnly`, what the document has when armed neither counts nor // joins the hold: a motion already running goes on, and the page holds // the one that starts after it. @@ -773,7 +783,7 @@ export async function armHold( } catch (error) { reject(error); } - } else if (performance.now() > deadline) { + } else if (realNow() > deadline) { done = true; reject( new Error( @@ -1294,6 +1304,13 @@ export async function waitForAnimation( const curveEnd = w.__harnessCurveEnd; const recorder = w.__harnessAnimations; recorder.record(); + // The recorder keeps real times. A live start time is a page time. + // It trails real time by the span a page freeze stood still, so it + // takes that span as it stands now. The stamps then share the trace's + // clock. + const span = + (window as unknown as {__harnessFreeze?: {offset: number}}) + .__harnessFreeze?.offset ?? 0; const describe = ( a: Animation, finishedAt: number | null, @@ -1316,9 +1333,10 @@ export async function waitForAnimation( ? finishStart : a.startTime == null ? recordedStart - : rate < 0 - ? Number(a.startTime) - endTime / speed - : Number(a.startTime); + : span + + (rate < 0 + ? Number(a.startTime) - endTime / speed + : Number(a.startTime)); const startedAt = performance.timeOrigin + Number(start ?? Number.NaN); const name = @@ -1437,9 +1455,12 @@ export async function waitForAnimation( (a.effect as KeyframeEffect | null)?.getComputedTiming() .endTime ?? 0, ); - return a.playbackRate < 0 - ? Number(a.startTime) - : Number(a.startTime) + end / Math.abs(a.playbackRate || 1); + return ( + span + + (a.playbackRate < 0 + ? Number(a.startTime) + : Number(a.startTime) + end / Math.abs(a.playbackRate || 1)) + ); }; return recorded .filter(e => finishedAt(e) != null) @@ -1591,7 +1612,15 @@ export async function waitForRest( await locator.evaluate( (element, {ms, need}) => new Promise((resolve, reject) => { - const deadline = performance.now() + ms; + // The deadline reads the real clock, which a page freeze does not + // stop. + const realNow = + ( + window as unknown as { + __harnessTimerGate?: {now?: () => number}; + } + ).__harnessTimerGate?.now ?? (() => performance.now()); + const deadline = realNow() + ms; const curveEnd = ( window as unknown as {__harnessCurveEnd: (a: Animation) => number} ).__harnessCurveEnd; @@ -1639,7 +1668,7 @@ export async function waitForRest( } ).__harnessAnimations?.arm(); resolve(); - } else if (performance.now() > deadline) { + } else if (realNow() > deadline) { reject(new Error(`waitForRest: still moving (${now})`)); } else { frame(tick); diff --git a/internal/interaction-harness/src/frames.ts b/internal/interaction-harness/src/frames.ts index 83841d259c17f..b5f6e723870be 100644 --- a/internal/interaction-harness/src/frames.ts +++ b/internal/interaction-harness/src/frames.ts @@ -433,27 +433,35 @@ export async function sampleRects( window as unknown as { __harnessTimerGate?: { frame?: typeof requestAnimationFrame; + now?: () => number; }; } ).__harnessTimerGate; const frame = gate?.frame ?? window.requestAnimationFrame.bind(window); + // The deadline reads the real clock, which a page freeze does not stop. + const realNow = gate?.now ?? (() => performance.now()); const samples: { t: number; box: {x: number; y: number; width: number; height: number} | null; transform: string; opacity: number; }[] = []; - const end = performance.now() + duration; + // A frame's time is the page's. After a page freeze it trails real + // time by the frozen span, which the stamp adds back. + const freeze = ( + window as unknown as {__harnessFreeze?: {offset: number}} + ).__harnessFreeze; + const end = realNow() + duration; const tick = (now: number) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); samples.push({ - t: performance.timeOrigin + now, + t: performance.timeOrigin + now + (freeze?.offset ?? 0), box: {x: rect.x, y: rect.y, width: rect.width, height: rect.height}, transform: style.transform, opacity: Number(style.opacity), }); - if (now < end) { + if (realNow() < end) { frame(tick); } else { resolve(samples); diff --git a/internal/interaction-harness/src/freeze.ts b/internal/interaction-harness/src/freeze.ts new file mode 100644 index 0000000000000..b6dc55144f5ca --- /dev/null +++ b/internal/interaction-harness/src/freeze.ts @@ -0,0 +1,392 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file freeze.ts + * @input A Playwright `Page`, before the page under test loads + * @output `installPageFreeze`, `freezePage`, `thawPage`, `freezePageOn`, + * `freezePageWhen` and `advanceFrozenPage`. Stop a scene (its animations, + * frames, timeouts and every time it reads), step it by its own clock, and + * start it again. An interval skips its ticks while frozen. + * @position Input timed by the scene, not by the test process: a proof freezes + * the scene where it wants to act, acts, and thaws. The README's "Page + * freeze" section says what a freeze holds and why it lives in script. + */ + +import type {Page} from '@playwright/test'; + +const FREEZE_SCRIPT = `(() => { + if (window.__harnessFreeze) return; + if (window.__harnessTimerGate) { + throw new Error('installPageFreeze: install it before installTimerGate (and before the page loads)'); + } + const perf = window.performance; + const nativeNow = Performance.prototype.now; + const rawNow = () => nativeNow.call(perf); + const nativeDateNow = Date.now; + const state = {offset: 0, frozenAt: null, dateFrozenAt: 0, timelineFrozenAt: null, held: new Set(), deferred: new Map(), watching: false, atFreeze: new WeakMap()}; + // Real time minus every frozen span so far; the freeze instant while frozen. + const now = real => (state.frozenAt != null && real >= state.frozenAt ? state.frozenAt : real) - state.offset; + Performance.prototype.now = function () { return now(nativeNow.call(this)); }; + Date.now = () => (state.frozenAt != null ? state.dateFrozenAt : nativeDateNow()) - state.offset; + const define = (proto, name, make) => { + const own = Object.getOwnPropertyDescriptor(proto, name); + if (own) Object.defineProperty(proto, name, Object.assign({}, own, make(own))); + }; + define(Event.prototype, 'timeStamp', d => ({get() { return now(d.get.call(this)); }})); + // Only the document's timeline is re-timed. A scroll or view timeline + // reads its scroller, not time, so it keeps its own getter. + const timelineDescriptor = Object.getOwnPropertyDescriptor(DocumentTimeline.prototype, 'currentTime') || Object.getOwnPropertyDescriptor(AnimationTimeline.prototype, 'currentTime'); + const timelineTime = timelineDescriptor.get; + // The timeline's time is its last frame's: frozen, it stays what it was. + // Never back: after a thaw the timeline's last frame can sit a little + // before the frozen frame on the shifted clock. + let last = -Infinity; + Object.defineProperty(DocumentTimeline.prototype, 'currentTime', { + configurable: true, + enumerable: timelineDescriptor.enumerable, + get() { + if (state.frozenAt != null) { + last = Math.max(last, state.frozenAt - state.offset); + return last; + } + const real = timelineTime.call(this); + if (real == null) return real; + last = Math.max(last, Number(real) - state.offset); + return last; + }, + }); + // A freeze holds the document timeline's animations only: one on a scroll + // or view timeline follows its scroller, frozen or not. + const isTimed = animation => animation.timeline === document.timeline; + const startTime = Object.getOwnPropertyDescriptor(Animation.prototype, 'startTime'); + const currentTime = Object.getOwnPropertyDescriptor(Animation.prototype, 'currentTime'); + const playState = Object.getOwnPropertyDescriptor(Animation.prototype, 'playState'); + const nativePause = Animation.prototype.pause; + const nativePlay = Animation.prototype.play; + const nativeCancel = Animation.prototype.cancel; + const nativeFinish = Animation.prototype.finish; + // Paused exactly where it stands: a pause alone completes on the next frame + // (Chromium advances one more frame first. WebKit lost a time set by script). + const hold = animation => { + if (!isTimed(animation) || state.held.has(animation)) return; + const at = currentTime.get.call(animation); + nativePause.call(animation); + if (at != null) currentTime.set.call(animation, at); + state.held.add(animation); + }; + const release = animation => { state.held.delete(animation); state.deferred.delete(animation); }; + Object.defineProperty(Animation.prototype, 'startTime', Object.assign({}, startTime, { + get() { + if (!isTimed(this)) return startTime.get.call(this); + if (state.deferred.has(this)) return state.deferred.get(this); + if (state.held.has(this)) { + // Held, it reads as running: the start it had, on the page's clock. + const at = currentTime.get.call(this); + return at == null || this.playbackRate === 0 ? null : state.frozenAt - state.offset - Number(at) / this.playbackRate; + } + const real = startTime.get.call(this); + return real == null ? real : Number(real) - state.offset; + }, + set(value) { + if (!isTimed(this)) { startTime.set.call(this, value); return; } + if (value == null) { state.deferred.delete(this); startTime.set.call(this, value); return; } + if (state.frozenAt != null) { + // Held where it would stand at the frozen instant; the thaw starts it. + hold(this); + state.deferred.set(this, Number(value)); + currentTime.set.call(this, (state.frozenAt - state.offset - Number(value)) * this.playbackRate); + return; + } + startTime.set.call(this, Number(value) + state.offset); + }, + })); + Object.defineProperty(Animation.prototype, 'playState', Object.assign({}, playState, { + get() { return state.held.has(this) ? 'running' : playState.get.call(this); }, + })); + Animation.prototype.play = function () { + if (state.frozenAt != null && isTimed(this)) { hold(this); return; } + return nativePlay.call(this); + }; + Animation.prototype.pause = function () { release(this); return nativePause.call(this); }; + Animation.prototype.cancel = function () { release(this); return nativeCancel.call(this); }; + Animation.prototype.finish = function () { release(this); return nativeFinish.call(this); }; + const nativeAnimate = Element.prototype.animate; + Element.prototype.animate = function (...args) { + const animation = nativeAnimate.apply(this, args); + if (state.frozenAt != null) hold(animation); + return animation; + }; + // Frame callbacks get the page's time; the timer gate, installed after + // this, holds them while frozen. + const nativeFrame = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = callback => nativeFrame(time => callback(now(time))); + // A CSS animation or transition the page starts while frozen: held at its + // start on the next frame. One that existed at the freeze and that the page + // resumes is held where it stood at the freeze. + const watch = () => { + if (state.frozenAt == null) { state.watching = false; return; } + for (const animation of document.getAnimations()) { + if (isTimed(animation) && !state.held.has(animation) && playState.get.call(animation) === 'running') { + nativePause.call(animation); + currentTime.set.call(animation, state.atFreeze.get(animation) ?? 0); + state.held.add(animation); + } + } + nativeFrame(watch); + }; + // One capture listener on the window for each input a freeze can be armed on, + // added before any page script runs. An armed freeze then lands before every + // page listener for that input, in the input's own task. + const ARMABLE = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'touchstart', 'touchmove', 'touchend', 'touchcancel', 'mousedown', 'mousemove', 'mouseup', 'click', 'keydown', 'keyup', 'wheel', 'scroll']; + const armed = new Set(); + for (const type of ARMABLE) { + window.addEventListener(type, () => { + if (!armed.delete(type)) return; + window.__harnessFreeze.freeze(); + if (window.__harnessTimerGate) window.__harnessTimerGate.freeze(true); + }, {capture: true, passive: true}); + } + window.__harnessFreeze = { + // The real frames, for the harness's own waits while frozen. + frame: nativeFrame, + // The real clock, for the harness's own deadlines while frozen. + realNow: rawNow, + // Arms a one-shot freeze on the next event of this type. + arm(type) { + if (!ARMABLE.includes(type)) { + throw new Error('freezePageOn: a freeze arms only on ' + ARMABLE.join(', ') + '; got ' + type); + } + armed.add(type); + }, + get frozen() { return state.frozenAt != null; }, + // The frozen span so far. Once thawed, a page time plus it is real time. + get offset() { return state.offset; }, + freeze() { + if (state.frozenAt != null) return; + state.frozenAt = rawNow(); + state.dateFrozenAt = nativeDateNow(); + const timeline = timelineTime.call(document.timeline); + state.timelineFrozenAt = timeline == null ? null : Number(timeline); + // Each running animation is held at the freeze instant, not at the last + // frame's time. The page's clock and its animations then agree exactly + // (the screen moves on by less than a frame as the freeze lands). + const ahead = state.timelineFrozenAt == null ? 0 : state.frozenAt - state.timelineFrozenAt; + state.atFreeze = new WeakMap(document.getAnimations().map(animation => [animation, currentTime.get.call(animation)])); + for (const animation of document.getAnimations()) { + if (!isTimed(animation) || playState.get.call(animation) !== 'running') continue; + const at = currentTime.get.call(animation); + hold(animation); + if (at != null) currentTime.set.call(animation, Number(at) + ahead * animation.playbackRate); + } + if (!state.watching) { state.watching = true; nativeFrame(watch); } + }, + // Moves the frozen scene on by ms: the page's clock and every held + // animation together, timers and frames still held. + advance(ms) { + if (state.frozenAt == null) throw new Error('advanceFrozenPage: the page is not frozen'); + state.offset -= ms; + for (const animation of state.held) { + if (state.deferred.has(animation)) { + currentTime.set.call(animation, (state.frozenAt - state.offset - state.deferred.get(animation)) * animation.playbackRate); + } else { + const at = currentTime.get.call(animation); + if (at != null) currentTime.set.call(animation, Number(at) + ms * animation.playbackRate); + } + } + }, + thaw() { + if (state.frozenAt == null) return; + // The held runs restart from the timeline's last frame time below. The + // frozen span ends at that same instant, so the page's clock and its + // runs stay together. + const frame = timelineTime.call(document.timeline); + state.offset += (frame == null ? rawNow() : Number(frame)) - state.frozenAt; + state.frozenAt = null; + const held = [...state.held]; + state.held.clear(); + for (const animation of held) { + const deferred = state.deferred.has(animation); + const value = state.deferred.get(animation); + state.deferred.delete(animation); + // On from where it stood, dated on the timeline's own time. A plain + // play() resolves its start later, and WebKit then resumed at the start + // it had before the pause. A start the page wrote while frozen resumes + // the same way, from the time it held. The timeline reads the current + // frame's time, which trails the page's clock by however far into the + // frame the thaw runs. So a start dated from now would sit before its + // start for the rest of that frame. + const at = currentTime.get.call(animation); + const timeline = timelineTime.call(document.timeline); + if (playState.get.call(animation) === 'idle') { + // A run the page cancelled while frozen (a CSS transition it + // reversed, a CSS animation it removed) stays cancelled. + } else if (at != null && timeline != null && animation.playbackRate !== 0) { + startTime.set.call(animation, Number(timeline) - Number(at) / animation.playbackRate); + } else if (deferred) { + startTime.set.call(animation, value + state.offset); + } else { + nativePlay.call(animation); + } + } + }, + }; +})()`; + +/** + * Installs the page clock a freeze needs, for this page and every document it + * loads from now on. Opt-in: a page without it is untouched. Call it before + * the page under test loads, and before `installTimerGate`. + */ +export async function installPageFreeze(page: Page): Promise { + await page.addInitScript(FREEZE_SCRIPT); + await page.evaluate(FREEZE_SCRIPT); +} + +/** + * Freezes the scene in one page task. Every animation pauses where it stands, + * the timer gate holds timers and frame callbacks, and every time the page + * reads stands still. Needs `installPageFreeze` before the page loaded and + * `installTimerGate` after. + */ +export async function freezePage(page: Page): Promise { + await page.evaluate(() => { + const w = window as unknown as { + __harnessFreeze?: {freeze(): void}; + __harnessTimerGate?: {freeze(isClockStopped: boolean): void}; + }; + if (w.__harnessFreeze == null || w.__harnessTimerGate == null) { + throw new Error( + 'freezePage: call installPageFreeze before the page loads, then installTimerGate', + ); + } + w.__harnessFreeze.freeze(); + w.__harnessTimerGate.freeze(true); + }); +} + +/** + * Arms a one-shot freeze on the page's next `type` event (a `touchstart`, a + * `touchend`, a `scroll`). The scene freezes in the event's own task, before + * any page listener for it runs. So what the page starts for that input is held + * at its start however late the test process reacts. Pointer, touch, mouse, + * key, wheel and scroll events can arm a freeze; any other type throws. + */ +export async function freezePageOn(page: Page, type: string): Promise { + await page.evaluate(eventType => { + const w = window as unknown as { + __harnessFreeze?: {arm(type: string): void}; + __harnessTimerGate?: {freeze(isClockStopped: boolean): void}; + }; + if (w.__harnessFreeze == null || w.__harnessTimerGate == null) { + throw new Error( + 'freezePageOn: call installPageFreeze before the page loads, then installTimerGate', + ); + } + w.__harnessFreeze.arm(eventType); + }, type); +} + +/** + * Freezes the scene in the first frame where `predicate(arg)` holds in the + * page, checked on every real frame. The scene stops where the proof wants it + * however late the test process reacts. `predicate` runs in the page (it is + * serialized, as for `page.waitForFunction`); it may read layout. Rejects past + * `timeout` ms without freezing. + */ +export async function freezePageWhen( + page: Page, + predicate: (arg: A) => boolean, + arg: A, + {timeout = 5_000}: {timeout?: number} = {}, +): Promise { + await page.evaluate( + ([source, value, wait]) => + new Promise((resolve, reject) => { + const w = window as unknown as { + __harnessFreeze?: { + freeze(): void; + frame: (callback: () => void) => number; + realNow: () => number; + }; + __harnessTimerGate?: {freeze(isClockStopped: boolean): void}; + }; + const freeze = w.__harnessFreeze; + const gate = w.__harnessTimerGate; + if (freeze == null || gate == null) { + reject( + new Error( + 'freezePageWhen: call installPageFreeze before the page loads, then installTimerGate', + ), + ); + return; + } + const check = new Function(`return (${source});`)() as ( + value: unknown, + ) => boolean; + // The real clock: the page's own stands still while it is frozen. + const deadline = freeze.realNow() + wait; + const tick = () => { + let isMet: boolean; + try { + isMet = check(value); + } catch (error) { + reject(error); + return; + } + if (isMet) { + freeze.freeze(); + gate.freeze(true); + resolve(); + } else if (freeze.realNow() > deadline) { + reject( + new Error( + `freezePageWhen: the condition did not hold within ${wait} ms`, + ), + ); + } else { + freeze.frame(tick); + } + }; + freeze.frame(tick); + }), + [predicate.toString(), arg, timeout] as const, + ); +} + +/** + * Moves a frozen scene on by `ms`. The page's clock and every held animation + * advance together, as if `ms` had passed, while timers and frame callbacks + * stay held for the thaw. A proof steps the scene to the instant it wants (a + * lens mid-slide) by the scene's own time, however loaded the runner is. + */ +export async function advanceFrozenPage(page: Page, ms: number): Promise { + await page.evaluate(step => { + const w = window as unknown as { + __harnessFreeze?: {advance(ms: number): void}; + __harnessTimerGate?: {catchUp(): void}; + }; + if (w.__harnessFreeze == null) { + throw new Error('advanceFrozenPage: call installPageFreeze first'); + } + w.__harnessFreeze.advance(step); + // Timeouts the page's clock reached are held for the thaw. + w.__harnessTimerGate?.catchUp(); + }, ms); +} + +/** + * Thaws the scene in one page task. Every animation held by the freeze plays on + * from where it stood, one the page started while frozen from its start. The + * page's time runs again, and the held timers and frame callbacks run in the + * order they came due. + */ +export async function thawPage(page: Page): Promise { + await page.evaluate(() => { + const w = window as unknown as { + __harnessFreeze?: {thaw(): void}; + __harnessTimerGate?: {thaw(): void}; + }; + w.__harnessFreeze?.thaw(); + w.__harnessTimerGate?.thaw(); + }); +} diff --git a/internal/interaction-harness/src/harness.interaction.browser.spec.ts b/internal/interaction-harness/src/harness.interaction.browser.spec.ts index a7997f1d799e6..3beb43d00832e 100644 --- a/internal/interaction-harness/src/harness.interaction.browser.spec.ts +++ b/internal/interaction-harness/src/harness.interaction.browser.spec.ts @@ -31,7 +31,10 @@ * until the filmstrip is done, drops callbacks the page cancels, stamps held * frames with the time they run, and leaves the harness its own frames; a * cast filmstrip keeps exactly the presented frames of its window, labelled - * from its zero, as PNG. + * from its zero, as PNG; a page freeze holds every animation, timer and + * clock the page reads and thaws each animation from where it stood (one the + * page dated while frozen too), arms on an input ahead of the page's own + * listeners, steps by the scene's own clock, and freezes on a condition. * @position The harness's own test. A component spec trusts these * instruments; this file is why it may. */ @@ -42,6 +45,7 @@ import path from 'node:path'; import {expect, test, type Page} from '@playwright/test'; import type {CastFrame, TrackedFrame} from './frames'; import { + advanceFrozenPage, armHold, animationStoryboard, castStoryboard, @@ -55,11 +59,15 @@ import { drag, flick, frames, + freezePage, + freezePageOn, + freezePageWhen, freezeTimers, holdAnimations, installInputLog, type InputRecord, installLongTaskLog, + installPageFreeze, installTimerGate, markElement, merge, @@ -80,6 +88,7 @@ import { startBusyControl, Storyboard, tap, + thawPage, thawTimers, timeline, touchDriver, @@ -4182,3 +4191,567 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { await expect(empty.write(testInfo)).rejects.toThrow(/no frames/); }); }); + +const FREEZE_FIXTURE = ` + +
+`; + +async function freezeFixture(page: Page) { + await page.route('http://freeze.test/', route => + route.fulfill({contentType: 'text/html', body: FREEZE_FIXTURE}), + ); + await page.goto('http://freeze.test/'); +} + +/** The box's run as the page reads it, and the page's clocks. */ +const readScene = (page: Page) => + page.locator('#box').evaluate(el => { + const animation = el.getAnimations()[0]; + return { + x: new DOMMatrix(getComputedStyle(el).transform).m41, + now: performance.now(), + date: Date.now(), + timeline: Number(document.timeline.currentTime), + current: animation == null ? null : Number(animation.currentTime), + state: animation?.playState ?? null, + }; + }); + +/** Starts a 2 s linear run of the box over 400 px. */ +const startRun = (page: Page) => + page.locator('#box').evaluate(el => { + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(400px)'}], + {duration: 2_000, easing: 'linear'}, + ); + }); + +test.describe('page freeze', () => { + test('a frozen scene stands still and reads no time passing; thawed, it plays on from where it stood, and the frozen span counts for neither its clock nor its animation', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + await startRun(page); + await waitForAnimation(page.locator('#box'), {minProgressMs: 100}); + await freezePage(page); + const frozen = await readScene(page); + const frozenAt = Date.now(); + await page.waitForTimeout(300); + const still = await readScene(page); + expect(still).toEqual(frozen); + expect(still.state).toBe('running'); + // Read in the thaw's own task: the run resumes exactly where it stood. + const atThaw = await page.locator('#box').evaluate(el => { + ( + window as unknown as {__harnessFreeze: {thaw(): void}} + ).__harnessFreeze.thaw(); + return Number(el.getAnimations()[0].currentTime); + }); + expect(atThaw).toBeCloseTo(frozen.current ?? NaN, 3); + await thawPage(page); + await page.waitForTimeout(200); + const thawed = await readScene(page); + const elapsed = Date.now() - frozenAt; + const moved = (thawed.current ?? 0) - (frozen.current ?? 0); + expect(moved).toBeGreaterThan(100); + expect(thawed.x).toBeGreaterThan(frozen.x); + // The 300 ms frozen span counts for neither the run nor the page's clock. + expect(elapsed - moved).toBeGreaterThan(250); + expect(elapsed - (thawed.now - frozen.now)).toBeGreaterThan(250); + }); + + test("a freeze armed on an input lands before the page's own listeners for it, so a slow handler's run is held at its start", async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + await freezePageOn(page, 'keydown'); + await page.keyboard.press('a'); + expect( + await page.evaluate( + () => (window as unknown as {frozenAtHandler: unknown}).frozenAtHandler, + ), + ).toBe(true); + const held = await readScene(page); + expect(held.current).toBe(0); + await page.waitForTimeout(250); + expect((await readScene(page)).current).toBe(0); + await thawPage(page); + // Only an input the install listens for can arm a freeze. + await expect(freezePageOn(page, 'focus')).rejects.toThrow(/arms only on/); + }); + + test('without a freeze the same slow handler runs first, and a late read finds its run well along', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + await page.keyboard.press('a'); + expect( + await page.evaluate( + () => (window as unknown as {frozenAtHandler: unknown}).frozenAtHandler, + ), + ).toBe(false); + await page.waitForTimeout(250); + expect((await readScene(page)).current).toBeGreaterThan(150); + }); + + test("an advance moves the page's clock and every held animation on together, while timers stay held", async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + await startRun(page); + await waitForAnimation(page.locator('#box'), {minProgressMs: 50}); + await freezePage(page); + await page.evaluate(() => { + setTimeout(() => { + (window as unknown as {timerRan?: boolean}).timerRan = true; + }, 10); + }); + const before = await readScene(page); + await advanceFrozenPage(page, 120); + const after = await readScene(page); + expect(after.now - before.now).toBeCloseTo(120, 6); + expect(after.timeline - before.timeline).toBeCloseTo(120, 6); + expect((after.current ?? 0) - (before.current ?? 0)).toBeCloseTo(120, 6); + expect(after.x - before.x).toBeCloseTo(24, 1); + expect( + await page.evaluate( + () => (window as unknown as {timerRan?: boolean}).timerRan, + ), + ).toBeUndefined(); + await thawPage(page); + expect( + await page.evaluate( + () => (window as unknown as {timerRan?: boolean}).timerRan, + ), + ).toBe(true); + }); + + test('a freeze on a condition stops the scene on the first frame it holds, and rejects when it never does', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + const reached = freezePageWhen( + page, + (min: number) => { + const box = document.getElementById('box'); + return ( + box != null && + new DOMMatrix(getComputedStyle(box).transform).m41 >= min + ); + }, + 100, + ); + await startRun(page); + await reached; + const frozen = await readScene(page); + expect(frozen.x).toBeGreaterThanOrEqual(100); + expect(frozen.x).toBeLessThan(400); + await page.waitForTimeout(200); + expect((await readScene(page)).x).toBe(frozen.x); + // On a frozen page the page's clock stands still: the timeout runs on + // the real one. + await expect( + freezePageWhen(page, () => false, null, {timeout: 300}), + ).rejects.toThrow(/did not hold within 300 ms/); + await thawPage(page); + await expect( + freezePageWhen(page, () => false, null, {timeout: 300}), + ).rejects.toThrow(/did not hold within 300 ms/); + }); + + test('a start the page dates while frozen resumes at the thaw from where it held, in the thaw task itself', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + await freezePage(page); + await page.locator('#box').evaluate(el => { + const animation = el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(400px)'}], + {duration: 2_000, easing: 'linear'}, + ); + animation.startTime = performance.now(); + }); + await advanceFrozenPage(page, 100); + expect((await readScene(page)).current).toBeCloseTo(100, 6); + // Read in the thaw's own task, before any frame: the timeline still + // reads its last frame's time, which trails the page's clock. + const atThaw = await page.locator('#box').evaluate(el => { + ( + window as unknown as {__harnessFreeze: {thaw(): void}} + ).__harnessFreeze.thaw(); + return Number(el.getAnimations()[0].currentTime); + }); + expect(atThaw).toBeCloseTo(100, 3); + await thawPage(page); + }); + + test('a timeout pending across a freeze comes due on the page’s clock, not early by the frozen span', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + // A 3 s run and a 1 s timeout started together: they share the page's + // clock, so the timeout comes due with the run 1 s in. + await page.locator('#box').evaluate(el => { + const animation = el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(400px)'}], + {duration: 3_000, easing: 'linear'}, + ); + setTimeout(() => { + (window as unknown as {firedAt?: number}).firedAt = Number( + animation.currentTime, + ); + }, 1_000); + }); + await page.waitForTimeout(100); + await freezePage(page); + await page.waitForTimeout(600); + await thawPage(page); + const firedAt = () => + page.evaluate( + () => (window as unknown as {firedAt?: number}).firedAt ?? null, + ); + await expect.poll(firedAt, {timeout: 5_000}).not.toBeNull(); + // On the real clock it would come 600 ms early, the run about 400 ms in. + // The run's time is its last frame's: up to two frames behind. + expect(await firedAt()).toBeGreaterThan(1_000 - 34); + expect(await firedAt()).toBeLessThan(3_000); + }); + + test('a scroll-driven animation is left to its scroller, and the page’s timeline keeps reading time', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + const hasScrollTimeline = await page.evaluate( + () => + typeof (window as {ScrollTimeline?: unknown}).ScrollTimeline === + 'function', + ); + test.skip(!hasScrollTimeline, 'this engine has no ScrollTimeline'); + await page.evaluate(() => { + const spacer = document.createElement('div'); + spacer.style.height = '4000px'; + document.body.append(spacer); + const ScrollTimeline = ( + window as unknown as { + ScrollTimeline: new (options: {source: Element}) => AnimationTimeline; + } + ).ScrollTimeline; + const source = document.scrollingElement ?? document.documentElement; + const timeline = new ScrollTimeline({source}); + document + .getElementById('box') + ?.animate([{opacity: 0}, {opacity: 1}], {timeline}); + // A scroll timeline's time is a percentage, read once here. + void timeline.currentTime; + }); + const pageTime = () => + page.evaluate(() => Number(document.timeline.currentTime)); + const scrollTime = () => + page.evaluate(() => { + const animation = document + .getElementById('box') + ?.getAnimations() + .find(each => each.timeline !== document.timeline); + return animation == null ? null : String(animation.currentTime); + }); + expect(Number.isFinite(await pageTime())).toBe(true); + await freezePage(page); + const before = await scrollTime(); + expect(before).not.toBeNull(); + // Frozen, the scroll-driven run still follows its scroller. + await page.evaluate(() => window.scrollTo(0, 2_000)); + await expect.poll(scrollTime).not.toBe(before); + expect(Number.isFinite(await pageTime())).toBe(true); + await thawPage(page); + expect(Number.isFinite(await pageTime())).toBe(true); + }); + + test('on a frozen page the harness still samples, arms a hold and blocks the main thread, on the real clock', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + await freezePage(page); + const box = page.locator('#box'); + const samples = await sampleRects(box, 200); + expect(samples.length).toBeGreaterThan(3); + const hold = await armHold(page, box, {timeout: 300}); + await expect(hold.held()).rejects.toThrow( + 'armHold: no animation under the scope ran within 300 ms', + ); + const blocked = await blockMainThread(page, 50); + expect(blocked.spanMs).toBeGreaterThanOrEqual(50); + await thawPage(page); + }); + + test('a CSS animation the page resumes while frozen is held where it stands, not at its start', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + // A CSS run paused 500 ms in, before the freeze. + await page.locator('#box').evaluate(el => { + const style = document.createElement('style'); + style.textContent = + '@keyframes slide { to { transform: translateX(400px); } }'; + document.head.append(style); + (el as HTMLElement).style.animation = 'slide 2s linear paused'; + el.getAnimations()[0].currentTime = 500; + }); + const at = () => + page + .locator('#box') + .evaluate(el => Number(el.getAnimations()[0].currentTime)); + expect(await at()).toBeCloseTo(500, 0); + await freezePage(page); + // The page resumes it by CSS while frozen. + await page.locator('#box').evaluate(el => { + (el as HTMLElement).style.animationPlayState = 'running'; + }); + await page.waitForTimeout(100); + expect(await at()).toBeCloseTo(500, 0); + await thawPage(page); + await page.waitForTimeout(100); + expect(await at()).toBeGreaterThan(500); + }); + + test('a CSS transition the page reverses while frozen plays the reversal after the thaw, not the old run from its start', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + const box = page.locator('#box'); + await box.evaluate(el => { + const element = el as HTMLElement; + element.style.transition = 'transform 1s linear'; + void getComputedStyle(element).transform; + element.style.transform = 'translateX(400px)'; + }); + // About 160 px along, frozen; the page sends the box back. + await page.waitForTimeout(400); + await freezePage(page); + await box.evaluate(el => { + (el as HTMLElement).style.transform = 'translateX(0px)'; + }); + const frozenAt = (await box.boundingBox())?.x ?? NaN; + await thawPage(page); + await page.waitForTimeout(100); + const soon = (await box.boundingBox())?.x ?? NaN; + await page.waitForTimeout(500); + const later = (await box.boundingBox())?.x ?? NaN; + // Back toward 0 all along: never past where it stood when frozen. + expect(soon).toBeLessThanOrEqual(frozenAt + 1); + expect(later).toBeLessThanOrEqual(soon + 1); + }); + + test('after a thaw, blockMainThread stamps its window on the real clock', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + await freezePage(page); + await page.waitForTimeout(600); + await thawPage(page); + const before = Date.now(); + const blocked = await blockMainThread(page, 50); + const after = Date.now(); + // The page's own clock trails the real one by the 600 ms frozen span. + expect(blocked.from).toBeGreaterThan(before - 50); + expect(blocked.to).toBeLessThan(after + 50); + }); + + test('after a thaw, waitForAnimation and sampleRects stamp on the real clock', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + await freezePage(page); + await page.waitForTimeout(600); + await thawPage(page); + const box = page.locator('#box'); + const before = Date.now(); + await box.evaluate(el => { + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(100px)'}], + {duration: 400}, + ); + }); + const [run] = await waitForAnimation(box); + const samples = await sampleRects(box, 100); + const after = Date.now(); + // The page's own clock trails the real one by the 600 ms frozen span. + expect(run.from).toBeGreaterThan(before - 50); + expect(run.from).toBeLessThan(after + 50); + expect(samples.length).toBeGreaterThan(0); + for (const sample of samples) { + expect(sample.t).toBeGreaterThan(before - 50); + expect(sample.t).toBeLessThan(after + 50); + } + }); + + test('a CSS animation the page dates and then removes while frozen stays removed after the thaw', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + const box = page.locator('#box'); + await page.evaluate(() => { + const style = document.createElement('style'); + style.textContent = + '@keyframes slide { to { transform: translateX(400px); } }'; + document.head.append(style); + }); + await freezePage(page); + // Started, dated and removed, all while frozen. + await box.evaluate(el => { + (el as HTMLElement).style.animation = 'slide 2s linear'; + }); + await page.waitForTimeout(100); + await box.evaluate(el => { + el.getAnimations()[0].startTime = document.timeline.currentTime; + (el as HTMLElement).style.animation = 'none'; + // Style is read at once, so the run is cancelled before the thaw. + void getComputedStyle(el).animationName; + }); + await page.waitForTimeout(100); + await thawPage(page); + await page.waitForTimeout(300); + expect(await box.evaluate(el => el.getAnimations().length)).toBe(0); + expect(await box.evaluate(el => getComputedStyle(el).transform)).toBe( + 'none', + ); + }); + + test('a run that finished before a freeze is dated where it ran, read after the thaw', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + const box = page.locator('#box'); + const watch = await watchAnimations(box); + const startedAt = Date.now(); + await box.evaluate(el => { + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(100px)'}], + {duration: 200}, + ); + }); + // Over before the freeze, and read only after the thaw. + await page.waitForTimeout(400); + const frozenAt = Date.now(); + await freezePage(page); + await page.waitForTimeout(600); + await thawPage(page); + const [finished] = await watch.started(); + expect(finished.from).toBeGreaterThan(startedAt - 50); + expect(finished.from).toBeLessThan(frozenAt); + expect(finished.finishedAt).not.toBeNull(); + expect(Number(finished.finishedAt)).toBeLessThan(frozenAt); + }); + + test('the page’s clock and its runs stay together across ten freeze and thaw cycles', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + await page.locator('#box').evaluate(el => { + (window as unknown as {run?: Animation}).run = el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(400px)'}], + {duration: 60_000, easing: 'linear'}, + ); + }); + await page.waitForTimeout(100); + // Where the run started, on the page's clock, before any freeze. + const startedAt = await page.evaluate(() => + Number((window as unknown as {run: Animation}).run.startTime), + ); + for (let cycle = 0; cycle < 10; cycle++) { + await freezePage(page); + await page.waitForTimeout(50); + await thawPage(page); + await page.waitForTimeout(30); + } + // In one frame: the run's time against the page's time since it started. + const drift = await page.evaluate( + start => + new Promise(resolve => { + requestAnimationFrame(time => { + const run = (window as unknown as {run: Animation}).run; + resolve(Number(run.currentTime) - (time - start)); + }); + }), + startedAt, + ); + expect(Math.abs(drift)).toBeLessThan(5); + }); + + test('a condition that throws rejects freezePageWhen with its error', async ({ + page, + }) => { + await installPageFreeze(page); + await freezeFixture(page); + await installTimerGate(page); + await expect( + freezePageWhen( + page, + () => { + throw new Error('the condition broke'); + }, + undefined, + {timeout: 2_000}, + ), + ).rejects.toThrow('the condition broke'); + }); + + test('a page without the freeze keeps its own clock, and a freeze there refuses to start', async ({ + page, + }) => { + await freezeFixture(page); + await installTimerGate(page); + expect(await page.evaluate(() => '__harnessFreeze' in window)).toBe(false); + await expect(freezePage(page)).rejects.toThrow(/installPageFreeze/); + await expect(freezePageOn(page, 'keydown')).rejects.toThrow( + /installPageFreeze/, + ); + }); +}); diff --git a/internal/interaction-harness/src/index.ts b/internal/interaction-harness/src/index.ts index d875069dc5979..e4d636021eafe 100644 --- a/internal/interaction-harness/src/index.ts +++ b/internal/interaction-harness/src/index.ts @@ -17,6 +17,7 @@ export * from './busy'; export * from './curves'; export * from './frames'; +export * from './freeze'; export * from './math'; export * from './output'; export * from './scripts'; diff --git a/internal/interaction-harness/src/story.ts b/internal/interaction-harness/src/story.ts index 627969a6637f3..a791579373b12 100644 --- a/internal/interaction-harness/src/story.ts +++ b/internal/interaction-harness/src/story.ts @@ -154,7 +154,10 @@ 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. +// requestAnimationFrame, for the harness's own waits, and `now` the real clock +// for their deadlines, which a page freeze does not stop. The gate's freeze() and +// thaw() are its one way in: freezeTimers and thawTimers call them, and so +// does a page freeze (freeze.ts), which stops the page's clock too. const TIMER_GATE = `(() => { if (window.__harnessTimerGate) return; const realTimeout = window.setTimeout.bind(window); @@ -162,17 +165,81 @@ 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, timeout: realTimeout}; + // The page freeze (freeze.ts) installs first and keeps the real clock. + const realNow = window.__harnessFreeze ? window.__harnessFreeze.realNow : () => performance.now(); + // Timeouts not yet due, by the id the page holds: the real timer that runs + // it, and when it comes due on the page's clock (performance.now, which a + // page freeze stops). + const pending = new Map(); + let isPaused = false; + const fire = (id, entry) => { + pending.delete(id); + if (gate.frozen) gate.queue.push({key: 't' + id, run: entry.run}); else entry.run(); + }; + const arm = (id, entry, ms) => { entry.real = realTimeout(() => fire(id, entry), ms); }; + const gate = { + frozen: false, + queue: [], + frame: realFrame, + now: realNow, + timeout: realTimeout, + // Holds every callback that comes due. With the page's clock stopped as + // well, the timeouts not yet due stop counting. + freeze(isClockStopped = false) { + gate.frozen = true; + if (isClockStopped && !isPaused) { + isPaused = true; + for (const entry of pending.values()) realClearTimeout(entry.real); + } + }, + // Holds the stopped timeouts the page's clock has reached, in the order + // they came due. + catchUp() { + if (!isPaused) return; + const now = performance.now(); + const due = [...pending].filter(([, entry]) => entry.due <= now).sort((a, b) => a[1].due - b[1].due); + for (const [id, entry] of due) { + pending.delete(id); + gate.queue.push({key: 't' + id, run: entry.run}); + } + }, + // Runs the held callbacks in the order they came due, then restarts the + // stopped timeouts for what is left of them on the page's clock. + thaw() { + gate.catchUp(); + gate.frozen = false; + try { + // One at a time from the live queue: a callback may cancel a later + // one. A callback that throws still leaves the clock running. + while (gate.queue.length > 0) gate.queue.shift().run(); + } finally { + if (isPaused) { + isPaused = false; + const now = performance.now(); + for (const [id, entry] of pending) arm(id, entry, Math.max(0, entry.due - now)); + } + } + }, + }; window.__harnessTimerGate = gate; const drop = key => { gate.queue = gate.queue.filter(entry => entry.key !== key); }; window.setTimeout = (fn, ms, ...args) => { - const id = realTimeout(() => { - const run = () => (typeof fn === 'function' ? fn(...args) : 0); - if (gate.frozen) gate.queue.push({key: 't' + id, run}); else run(); - }, ms); + const delay = Math.max(0, Number(ms) || 0); + const entry = {due: performance.now() + delay, real: 0, run: () => (typeof fn === 'function' ? fn(...args) : 0)}; + // The first real timer's id is the one the page holds. A stopped clock + // starts it again at the thaw. + const id = realTimeout(() => fire(id, entry), delay); + entry.real = id; + pending.set(id, entry); + if (isPaused) realClearTimeout(id); return id; }; - window.clearTimeout = id => { realClearTimeout(id); drop('t' + id); }; + window.clearTimeout = id => { + const entry = pending.get(id); + pending.delete(id); + realClearTimeout(entry == null ? id : entry.real); + drop('t' + id); + }; window.setInterval = (fn, ms, ...args) => realInterval(() => { if (!gate.frozen && typeof fn === 'function') fn(...args); }, ms); window.requestAnimationFrame = fn => { const id = realFrame(time => { @@ -202,35 +269,22 @@ export async function installTimerGate(page: Page): Promise { */ export async function freezeTimers(page: Page): Promise { await page.evaluate(() => { - const gate = (window as unknown as {__harnessTimerGate?: {frozen: boolean}}) + const gate = (window as unknown as {__harnessTimerGate?: {freeze(): void}}) .__harnessTimerGate; if (gate == null) { throw new Error( 'freezeTimers: call installTimerGate before the animation starts', ); } - gate.frozen = true; + gate.freeze(); }); } /** Releases the gate: held callbacks run now, in the order they came due. */ export async function thawTimers(page: Page): Promise { await page.evaluate(() => { - const gate = ( - window as unknown as { - __harnessTimerGate?: { - frozen: boolean; - queue: {key: string; run: () => void}[]; - }; - } - ).__harnessTimerGate; - if (gate == null) { - return; - } - gate.frozen = false; - // One at a time from the live queue: a callback may cancel a later one. - while (gate.queue.length > 0) { - gate.queue.shift()?.run(); - } + ( + window as unknown as {__harnessTimerGate?: {thaw(): void}} + ).__harnessTimerGate?.thaw(); }); } diff --git a/internal/interaction-harness/src/webInput.ts b/internal/interaction-harness/src/webInput.ts index 539edb2eef547..369070c9e5c69 100644 --- a/internal/interaction-harness/src/webInput.ts +++ b/internal/interaction-harness/src/webInput.ts @@ -452,18 +452,23 @@ async function untilRunReceived( }>(resolve => { // The harness's own wait runs on the real timer: a frozen timer gate // (story.ts) holds only the page's. - const later = - ( - window as unknown as { - __harnessTimerGate?: {timeout?: typeof setTimeout}; - } - ).__harnessTimerGate?.timeout ?? window.setTimeout.bind(window); - const deadline = performance.now() + ms; + const gate = ( + window as unknown as { + __harnessTimerGate?: { + now?: () => number; + timeout?: typeof setTimeout; + }; + } + ).__harnessTimerGate; + const later = gate?.timeout ?? window.setTimeout.bind(window); + // The deadline reads the real clock, which a page freeze does not stop. + const realNow = gate?.now ?? (() => performance.now()); + const deadline = realNow() + ms; const whole = () => Math.abs(run.dx - total.dx) < 0.01 && Math.abs(run.dy - total.dy) < 0.01; const check = () => { - if (whole() || performance.now() > deadline) { + if (whole() || realNow() > deadline) { run.stop(); resolve({ stamps: run.stamps.slice(), From 6f87bcd484d99140d0122553b0b4e0d2a2395750 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 17:28:38 -0800 Subject: [PATCH 5/6] feat(lab): TabBar, iOS 26's floating tab bar, with its selection lens and press tracking TabBar shows an app's top-level sections as iOS 26's floating capsule above the bottom edge, with a selection lens that slides to the chosen tab. Each TabBarItem has an icon, a label and an optional badge. Under a finger, on iOS and Android alike, the lens moves on the springs measured from UIKit on iOS 26.2: it lifts into glass as a finger lands, follows a finger dragged across the tabs and settles on the tab nearest the lift, while the bar flexes. Under a mouse or a pen a click chooses a tab and the lens follows the theme's motion; a press dragged across the tabs carries the lens on the finger's tracking spring, and its release settles the lens from the drag's own speed. Under every pointer, sliding off the bar cancels: a press lifted off it chooses nothing. Choosing the selected tab again is reported apart, so a screen can return to its root. Core's action-group tracker owns the press: the tab under it, and running that tab at the lift. TabBar gives it the target rule and draws the lens. TabBar's UIKit values live beside it in uikitTabBar.ts, each with its source, and uikitTabBar.test.ts checks that every value names one. Core's gesture and motion pieces come through Core's internal touch entry. --- .changeset/cli-discover-tab-bar.md | 7 + apps/storybook/stories/TabBar.stories.tsx | 296 +++ internal/shadcn-registry/routes.lock.json | 10 + .../assets/discover/official-packages.json | 8 + packages/lab/src/TabBar/TabBar.doc.mjs | 131 ++ packages/lab/src/TabBar/TabBar.test.tsx | 349 +++ packages/lab/src/TabBar/TabBar.tsx | 744 +++++++ packages/lab/src/TabBar/TabBarTouch.ts | 16 + .../TabBar.interaction.browser.spec.ts | 1926 +++++++++++++++++ packages/lab/src/TabBar/index.ts | 13 + packages/lab/src/TabBar/tabBar.stylex.ts | 18 + packages/lab/src/TabBar/tabBarModel.test.ts | 202 ++ packages/lab/src/TabBar/tabBarModel.ts | 136 ++ packages/lab/src/TabBar/uikitTabBar.test.ts | 168 ++ packages/lab/src/TabBar/uikitTabBar.ts | 243 +++ packages/lab/src/TabBar/useTabBarLens.ts | 787 +++++++ packages/lab/src/index.ts | 8 + 17 files changed, 5062 insertions(+) create mode 100644 .changeset/cli-discover-tab-bar.md create mode 100644 apps/storybook/stories/TabBar.stories.tsx create mode 100644 packages/lab/src/TabBar/TabBar.doc.mjs create mode 100644 packages/lab/src/TabBar/TabBar.test.tsx create mode 100644 packages/lab/src/TabBar/TabBar.tsx create mode 100644 packages/lab/src/TabBar/TabBarTouch.ts create mode 100644 packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts create mode 100644 packages/lab/src/TabBar/index.ts create mode 100644 packages/lab/src/TabBar/tabBar.stylex.ts create mode 100644 packages/lab/src/TabBar/tabBarModel.test.ts create mode 100644 packages/lab/src/TabBar/tabBarModel.ts create mode 100644 packages/lab/src/TabBar/uikitTabBar.test.ts create mode 100644 packages/lab/src/TabBar/uikitTabBar.ts create mode 100644 packages/lab/src/TabBar/useTabBarLens.ts diff --git a/.changeset/cli-discover-tab-bar.md b/.changeset/cli-discover-tab-bar.md new file mode 100644 index 0000000000000..367480325b146 --- /dev/null +++ b/.changeset/cli-discover-tab-bar.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx discover` lists the experimental TabBar and TabBarItem components from `@astryxdesign/lab`. + +@vjeux diff --git a/apps/storybook/stories/TabBar.stories.tsx b/apps/storybook/stories/TabBar.stories.tsx new file mode 100644 index 0000000000000..b9ee247e579a9 --- /dev/null +++ b/apps/storybook/stories/TabBar.stories.tsx @@ -0,0 +1,296 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {useEffect, useState} from 'react'; +import {flushSync} from 'react-dom'; +import type {Meta, StoryObj} from '@storybook/react'; +import { + BookOpenIcon as BookOpenOutline, + HomeIcon as HomeOutline, + Squares2X2Icon as GridOutline, + UserCircleIcon as UserOutline, + BellIcon as BellOutline, +} from '@heroicons/react/24/outline'; +import { + BookOpenIcon as BookOpenSolid, + HomeIcon as HomeSolid, + Squares2X2Icon as GridSolid, + UserCircleIcon as UserSolid, + BellIcon as BellSolid, +} from '@heroicons/react/24/solid'; +import {TabBar, TabBarItem} from '@astryxdesign/lab'; +import {Button} from '@astryxdesign/core/Button'; +import {LayerProvider} from '@astryxdesign/core/Layer'; +import {Icon, type IconType} from '@astryxdesign/core/Icon'; + +// ============================================================================= +// Meta +// ============================================================================= + +const meta: Meta = { + // The bar floats over the bottom edge: declared on LayerProvider, menus, + // popovers and toasts clear it. + decorators: [ + Story => ( + + + + ), + ], + title: 'Lab/TabBar', + component: TabBar, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================================================================= +// The sections UIKit's own captures were taken with: four tabs over long +// scrolling screens +// ============================================================================= + +interface Section { + value: string; + label: string; + icon: IconType; + selectedIcon: IconType; +} + +const SECTIONS: Section[] = [ + {value: 'home', label: 'Home', icon: HomeOutline, selectedIcon: HomeSolid}, + { + value: 'library', + label: 'Library', + icon: BookOpenOutline, + selectedIcon: BookOpenSolid, + }, + { + value: 'browse', + label: 'Browse', + icon: GridOutline, + selectedIcon: GridSolid, + }, + { + value: 'profile', + label: 'Profile', + icon: UserOutline, + selectedIcon: UserSolid, + }, +]; + +const INBOX: Section = { + value: 'inbox', + label: 'Inbox', + icon: BellOutline, + selectedIcon: BellSolid, +}; + +/** A screen long enough to scroll under the bar. */ +function Screen({title, reselects}: {title: string; reselects: number}) { + return ( +
+

+ {title || 'No tab selected'} +

+

Reselected {reselects} times

+ {Array.from({length: 40}, (_, i) => ( +

+ {title} row {i + 1} +

+ ))} +
+ ); +} + +function Bar({ + sections = SECTIONS, + initial = 'home', + badges = {library: 3}, + keeps = [], + onChange, +}: { + sections?: Section[]; + initial?: string; + badges?: Record; + /** Tabs the app declines to switch to (it keeps the selection). */ + keeps?: string[]; + /** Also told of every choice. */ + onChange?: (value: string) => void; +}) { + const [value, setValue] = useState(initial); + const [reselects, setReselects] = useState(0); + const title = sections.find(section => section.value === value)?.label ?? ''; + return ( + <> + + { + onChange?.(next); + if (!keeps.includes(next)) { + setValue(next); + } + }} + onReselect={() => setReselects(n => n + 1)} + label="Sections" + data-testid="tab-bar"> + {sections.map(section => ( + } + selectedIcon={} + badge={badges[section.value]} + /> + ))} + + + ); +} + +/** Four tabs, a badge on Library. Choosing Home again counts a reselect. */ +export const Default: Story = { + render: () => , +}; + +/** Badges appear, change and disappear at once, as UIKit's do. */ +function BadgesDemo() { + const [count, setCount] = useState(0); + const badge = count === 0 ? undefined : count > 99 ? '99+' : count; + return ( + <> +
+
+ + + ); +} + +function TabBarHost({badges}: {badges: Record}) { + const [value, setValue] = useState('home'); + return ( + + {[SECTIONS[0], INBOX, SECTIONS[2], SECTIONS[3]].map(section => ( + } + selectedIcon={} + badge={badges[section.value]} + badgeLabel={ + section.value === 'inbox' && badges.inbox !== '' + ? `${badges.inbox} unread` + : undefined + } + /> + ))} + + ); +} + +/** + * The app keeps the selection when Profile is chosen (say it needs a + * sign-in first): the lens settles back onto the selected tab. + */ +export const KeepsSelection: Story = { + render: () => , +}; + +/** No tab selected (the value matches none): a tap chooses one. */ +export const NoSelection: Story = { + render: () => , +}; + +/** + * The app removes the bar as a finger lifts: the lift reports nothing (each + * choice would be listed on `window.__tabBarChanges`). + */ +function RemovedOnLiftDemo() { + const [isShown, setIsShown] = useState(true); + useEffect(() => { + const hide = () => { + const log = window as unknown as {__tabBarChanges?: string[]}; + log.__tabBarChanges = [...(log.__tabBarChanges ?? []), '(removed)']; + flushSync(() => setIsShown(false)); + }; + window.addEventListener('touchend', hide); + return () => window.removeEventListener('touchend', hide); + }, []); + return isShown ? ( + { + const log = window as unknown as {__tabBarChanges?: string[]}; + log.__tabBarChanges = [...(log.__tabBarChanges ?? []), value]; + }} + /> + ) : ( +

Removed

+ ); +} + +export const RemovedOnLift: Story = { + render: () => , +}; + +/** + * The app drops its last tab on the window event `tabbar:drop-last`, sent while + * a finger holds the bar. The press ends without choosing a tab (each choice + * would be listed on `window.__tabBarChanges`). + */ +function DropsLastTabDemo() { + const [sections, setSections] = useState(SECTIONS); + useEffect(() => { + const drop = () => setSections(current => current.slice(0, -1)); + window.addEventListener('tabbar:drop-last', drop); + return () => window.removeEventListener('tabbar:drop-last', drop); + }, []); + return ( + { + const log = window as unknown as {__tabBarChanges?: string[]}; + log.__tabBarChanges = [...(log.__tabBarChanges ?? []), value]; + }} + /> + ); +} + +export const DropsLastTab: Story = { + render: () => , +}; + +export const Badges: Story = { + render: () => , +}; + +/** Five tabs: the items overlap more, as they do on iOS. */ +export const FiveTabs: Story = { + render: () => ( + + ), +}; + +/** Three tabs: the capsule narrows to its natural width and centres. */ +export const ThreeTabs: Story = { + render: () => , +}; + +/** Right to left: the first tab sits on the right and the badge on the icon's top-left. */ +export const RightToLeft: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/internal/shadcn-registry/routes.lock.json b/internal/shadcn-registry/routes.lock.json index 1d561b01054f4..379bba928809c 100644 --- a/internal/shadcn-registry/routes.lock.json +++ b/internal/shadcn-registry/routes.lock.json @@ -657,6 +657,16 @@ "path": "components/lab/svg-icon", "aliases": [] }, + { + "name": "component-lab-tab-bar", + "path": "components/lab/tab-bar", + "aliases": [] + }, + { + "name": "component-lab-tab-bar-item", + "path": "components/lab/tab-bar-item", + "aliases": [] + }, { "name": "component-lab-three-d-bar", "path": "components/lab/three-d-bar", diff --git a/packages/cli/assets/discover/official-packages.json b/packages/cli/assets/discover/official-packages.json index 8615ac6656b73..15548168ff88c 100644 --- a/packages/cli/assets/discover/official-packages.json +++ b/packages/cli/assets/discover/official-packages.json @@ -172,6 +172,14 @@ "kind": "component", "name": "SVGIcon" }, + { + "kind": "component", + "name": "TabBar" + }, + { + "kind": "component", + "name": "TabBarItem" + }, { "kind": "component", "name": "ThreeDBar" diff --git a/packages/lab/src/TabBar/TabBar.doc.mjs b/packages/lab/src/TabBar/TabBar.doc.mjs new file mode 100644 index 0000000000000..36cfc4e76f62c --- /dev/null +++ b/packages/lab/src/TabBar/TabBar.doc.mjs @@ -0,0 +1,131 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */ + +export const docs = { + name: 'TabBar', + displayName: 'Tab Bar', + category: 'Navigation', + keywords: [ + 'tabbar', + 'tab bar', + 'bottom tabs', + 'bottom navigation', + 'bottom bar', + 'tab bar controller', + 'mobile navigation', + 'app sections', + 'badge', + ], + description: + "The bottom tab bar of a phone app, as on iOS 26: the app's top-level sections as a floating capsule above the bottom edge, with a selection lens that slides to the chosen tab. Each tab has an icon, a label and an optional badge. Under a finger, on iOS and Android alike, it behaves like iOS 26's: the lens lifts into glass as a finger lands, follows a finger dragged across the tabs and settles on the tab nearest where it lifts, while the bar flexes; under a mouse or a pen, a click chooses a tab and the lens follows the theme's motion, and a press dragged across the tabs carries the lens and chooses the tab where it is released. Choosing the selected tab again is reported separately, so a screen can return to its root as iOS does. Experimental: lands in lab first.", + components: [ + { + name: 'TabBar', + displayName: 'Tab Bar', + description: + "The bar. It sits fixed at the bottom of the viewport, over the page, so leave 83 px of room at the bottom of the scrolling content, and declare it on LayerProvider (inset={{blockEnd: 83}}) so menus, popovers and toasts clear it. You own the selection: pass the selected tab's value and update it in onChange.", + props: [ + { + name: 'value', + type: 'string', + description: "The selected tab's value.", + required: true, + }, + { + name: 'onChange', + type: '(value: string) => void', + description: + "Called with a tab's value when a different tab is chosen: a finger lifted over it (after a tap or a drag across the bar), a click, or Enter or Space on a focused tab.", + required: true, + }, + { + name: 'onReselect', + type: '(value: string) => void', + description: + "Called with the selected tab's value when it is chosen again. On iOS this returns the tab's navigation stack to its root (NavigationStack's popToRoot); a screen already at its root stays where it is.", + }, + { + name: 'label', + type: 'string', + description: + 'Names the tab bar for assistive technology. Without it, or aria-label or aria-labelledby (which win over it and reach the tab list), the tab list is named "Tabs" in the reader\'s language.', + }, + { + name: 'children', + type: 'ReactNode', + description: + 'The tabs: TabBarItem elements, in order, as direct children.', + required: true, + }, + ], + }, + { + name: 'TabBarItem', + displayName: 'Tab Bar Item', + description: + 'One tab: its value, label, icon and badge. TabBar reads its props and draws it; it renders nothing on its own.', + props: [ + { + name: 'value', + type: 'string', + description: 'Identifies the tab in value, onChange and onReselect.', + required: true, + }, + { + name: 'label', + type: 'string', + description: + "The tab's name, shown under its icon. Keep it to one short word.", + required: true, + }, + { + name: 'icon', + type: 'ReactNode', + description: + "The tab's icon, an Icon at size lg. It is drawn in the current text colour, and in the accent colour while selected.", + required: true, + }, + { + name: 'selectedIcon', + type: 'ReactNode', + description: + 'The icon while the tab is selected, typically its filled variant. Defaults to icon.', + }, + { + name: 'badge', + type: 'string | number', + description: + 'A badge over the icon\'s top-trailing corner: a count or a short string such as "!". It appears, changes and disappears without animating. Omit it or pass an empty string for none.', + }, + { + name: 'badgeLabel', + type: 'string', + description: + 'What assistive technology reads for the badge after the tab\'s label, such as "3 unread". Defaults to the badge itself.', + }, + ], + }, + ], + usage: { + description: + "Use TabBar to switch between an app's three to five top-level sections on a phone, each keeping its own state and navigation stack. Put a NavigationStack in each section and pop it to its root in onReselect.", + bestPractices: [ + { + guidance: true, + description: + 'Keep every tab visible and the set stable; give each a short label and an outline icon with a filled selectedIcon.', + }, + { + guidance: true, + description: + 'Use a badge for something that needs attention in that section, such as unread items, and say what it counts in badgeLabel.', + }, + { + guidance: false, + description: + "Don't use it for actions, for filters within one screen (use SegmentedControl) or for tabs inside a page (use TabList).", + }, + ], + }, +}; diff --git a/packages/lab/src/TabBar/TabBar.test.tsx b/packages/lab/src/TabBar/TabBar.test.tsx new file mode 100644 index 0000000000000..7c8eb6448e063 --- /dev/null +++ b/packages/lab/src/TabBar/TabBar.test.tsx @@ -0,0 +1,349 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file TabBar.test.tsx + * @input vitest, @testing-library/react, TabBar and TabBarItem + * @output jsdom checks of the tab bar's semantics and state: the tablist and + * its keys, choosing and reselecting, badges, the lens at rest and theme + * classes. + * @position Testing; __tests__/TabBar.interaction.browser.spec.ts proves the + * motion in real engines + */ + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {useState} from 'react'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {InternationalizationProvider} from '@astryxdesign/core/i18n'; +import {TOUCH_QUERY} from '@astryxdesign/core/internal/touch'; +import {TabBar, TabBarItem, type TabBarProps} from './TabBar'; +import {focusChosenTab} from './useTabBarLens'; + +function Glyph({name}: {name: string}) { + return ; +} + +function Bar({ + initial = 'home', + onReselect, + onChange, + badges = {}, + ...props +}: Partial & { + initial?: string; + badges?: Record; +}) { + const [value, setValue] = useState(initial); + return ( + { + setValue(next); + onChange?.(next); + }} + onReselect={onReselect} + label="Sections" + data-testid="bar" + {...props}> + } /> + } + badge={badges.library} + /> + } + badge={badges.browse} + badgeLabel="New items" + /> + } + /> + + ); +} + +/** + * The primary pointer is coarse until the restore runs: a phone, as the touch + * presentation's gate reads it. Core's own test helper + * (gestures/__tests__/pointerQuery.ts) is out of Lab's reach: Lab's typecheck + * resolves Core through its dist. + */ +function coarsePrimaryPointer(): () => void { + const matchMedia = window.matchMedia; + const query = { + matches: true, + media: TOUCH_QUERY, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + } as unknown as MediaQueryList; + window.matchMedia = (media: string) => + media.trim() === TOUCH_QUERY ? query : matchMedia(media); + return () => { + window.matchMedia = matchMedia; + }; +} + +// The pointer a test models, put back after it. +const restorePointer: Array<() => void> = []; +afterEach(() => { + for (const restore of restorePointer.splice(0)) { + restore(); + } +}); + +describe('TabBar', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('names its tab list "Tabs" when it is given no name', () => { + render(); + expect(screen.getByRole('tablist', {name: 'Tabs'})).toBeInTheDocument(); + }); + + it('gives aria-label to the tab list, not the root', () => { + render(); + expect( + screen.getByRole('tablist', {name: 'Main sections'}), + ).toBeInTheDocument(); + expect(screen.getByTestId('bar')).not.toHaveAttribute('aria-label'); + }); + + it('names the tab list after the element aria-labelledby points at', () => { + render( + <> +

Sections heading

+ + , + ); + expect( + screen.getByRole('tablist', {name: 'Sections heading'}), + ).toBeInTheDocument(); + expect(screen.getByTestId('bar')).not.toHaveAttribute('aria-labelledby'); + }); + + it('is a named tablist of tabs with the selected one marked', () => { + render(); + const list = screen.getByRole('tablist', {name: 'Sections'}); + expect(list).toBeInTheDocument(); + const tabs = screen.getAllByRole('tab'); + expect(tabs.map(tab => tab.textContent)).toEqual([ + 'Home', + 'Library', + 'Browse', + 'Profile', + ]); + expect(screen.getByRole('tab', {name: 'Library'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByRole('tab', {name: 'Home'})).toHaveAttribute( + 'aria-selected', + 'false', + ); + }); + + it('chooses another tab with onChange and moves the selection', () => { + const onChange = vi.fn(); + const onReselect = vi.fn(); + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Browse'})); + expect(onChange).toHaveBeenCalledWith('browse'); + expect(onReselect).not.toHaveBeenCalled(); + expect(screen.getByRole('tab', {name: 'Browse'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('reports choosing the selected tab again as a reselect, not a change', () => { + const onChange = vi.fn(); + const onReselect = vi.fn(); + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Home'})); + expect(onReselect).toHaveBeenCalledWith('home'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('has one tab stop, on the selected tab, and arrow keys move between tabs', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs.map(tab => tab.tabIndex)).toEqual([-1, 0, -1, -1]); + tabs[1].focus(); + fireEvent.keyDown(tabs[1], {key: 'ArrowRight'}); + expect(document.activeElement).toBe(tabs[2]); + fireEvent.keyDown(tabs[2], {key: 'End'}); + expect(document.activeElement).toBe(tabs[3]); + fireEvent.keyDown(tabs[3], {key: 'Home'}); + expect(document.activeElement).toBe(tabs[0]); + // Moving focus does not choose a tab: Enter or Space does. + expect(tabs[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it('names a badged tab with one catalog message, so a translation can reorder it', () => { + render( + + + , + ); + expect(screen.getByRole('tab', {name: '3 · Library'})).toBeInTheDocument(); + }); + + it('puts a badge in its tab’s name and draws it at once', () => { + const {rerender} = render(); + expect(screen.getByRole('tab', {name: 'Library, 3'})).toBeInTheDocument(); + const badge = () => + document.querySelector('[data-tab-bar-badge="library"]'); + expect(badge()).toHaveTextContent('3'); + // Changing or clearing the badge updates it in the same render. + rerender(); + expect(badge()).toHaveTextContent('12'); + expect(screen.getByRole('tab', {name: 'Library, 12'})).toBeInTheDocument(); + rerender(); + expect(badge()).toBeNull(); + expect(screen.getByRole('tab', {name: 'Library'})).toBeInTheDocument(); + }); + + it('reads a badge’s label instead of the badge when given one', () => { + render(); + expect( + screen.getByRole('tab', {name: 'Browse, New items'}), + ).toBeInTheDocument(); + }); + + it('keeps the badges and the lens out of the accessibility tree, and draws each item once at rest', () => { + render(); + const lens = document.querySelector('[data-tab-bar-lens]'); + expect(lens).toHaveAttribute('aria-hidden', 'true'); + const badge = document.querySelector('[data-tab-bar-badge="library"]'); + expect(badge?.closest('[aria-hidden="true"]')).not.toBeNull(); + // At rest the lens carries no copy of the items: each icon is drawn once. + expect(document.querySelector('[data-tab-bar-copy]')).toBeNull(); + expect(lens?.querySelectorAll('[data-testid^="icon-"]')).toHaveLength(0); + expect(document.querySelectorAll('[data-testid^="icon-"]')).toHaveLength(4); + }); + + it('draws the lens only while a tab is selected', () => { + const {rerender} = render( + {}}> + + + , + ); + expect(document.querySelector('[data-tab-bar-lens]')).toBeNull(); + // With nothing selected the first tab keeps the tab stop. + expect(screen.getAllByRole('tab').map(tab => tab.tabIndex)).toEqual([ + 0, -1, + ]); + rerender( + {}}> + + + , + ); + expect(document.querySelector('[data-tab-bar-lens]')).not.toBeNull(); + }); + + it('draws the selected icon on the selected tab itself when one is given', () => { + render( + {}}> + } + selectedIcon={} + /> + , + ); + const tab = screen.getByRole('tab', {name: 'A'}); + expect(tab.querySelector('[data-testid="icon-filled"]')).not.toBeNull(); + expect(tab.querySelector('[data-testid="icon-outline"]')).toBeNull(); + }); + + it('under a mouse never mounts a copy', () => { + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Browse'})); + expect(screen.getByRole('tab', {name: 'Browse'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(document.querySelector('[data-tab-bar-copy]')).toBeNull(); + }); + + it('under a finger a slide mounts the copy only until the lens rests', async () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + expect(document.querySelector('[data-tab-bar-copy]')).toBeNull(); + fireEvent.click(screen.getByRole('tab', {name: 'Browse'})); + // The slide's commit draws the selected look inside the lens… + expect(document.querySelector('[data-tab-bar-copy]')).not.toBeNull(); + // …and once it rests the tab draws it again and the copy goes. + await waitFor(() => + expect(document.querySelector('[data-tab-bar-copy]')).toBeNull(), + ); + expect( + screen + .getByRole('tab', {name: 'Browse'}) + .querySelector('[data-testid="icon-browse"]'), + ).not.toBeNull(); + }); + + it('carries the stable theme classes and the selected state', () => { + render(); + expect(screen.getByTestId('bar')).toHaveClass('astryx-tab-bar'); + const browse = screen.getByRole('tab', {name: 'Browse'}); + expect(browse).toHaveClass('astryx-tab-bar-item'); + expect(browse).toHaveAttribute('data-selected', 'selected'); + expect(screen.getByRole('tab', {name: 'Home'})).not.toHaveAttribute( + 'data-selected', + ); + }); + + it('ignores children that are not TabBarItem', () => { + render( + {}}> + + {null} + stray + , + ); + expect(screen.getAllByRole('tab')).toHaveLength(1); + expect(screen.queryByText('stray')).toBeNull(); + }); +}); + +describe('focusChosenTab, in another document', () => { + it('moves focus to the chosen tab through the bar’s own document, inside an iframe', () => { + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const doc = iframe.contentDocument; + if (doc == null) { + throw new Error('no iframe document'); + } + const capsule = doc.createElement('div'); + const tabs = ['Home', 'Library', 'Profile'].map(name => { + const tab = doc.createElement('button'); + tab.setAttribute('role', 'tab'); + tab.textContent = name; + capsule.append(tab); + return tab; + }); + doc.body.append(capsule); + tabs[0].focus(); + expect(doc.activeElement).toBe(tabs[0]); + focusChosenTab(capsule, tabs[2]); + expect(doc.activeElement).toBe(tabs[2]); + iframe.remove(); + }); +}); diff --git a/packages/lab/src/TabBar/TabBar.tsx b/packages/lab/src/TabBar/TabBar.tsx new file mode 100644 index 0000000000000..f59e049d33b26 --- /dev/null +++ b/packages/lab/src/TabBar/TabBar.tsx @@ -0,0 +1,744 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file TabBar.tsx + * @input React, StyleX, Core's theme tokens and hooks, TabBar's touch layer + * (TabBarTouch.ts), its geometry (tabBarModel.ts) and its UIKit values + * (uikitTabBar.ts) + * @output TabBar (iOS 26's floating tab bar) and TabBarItem + * @position Lab component: the phone presentation of an app's top-level + * sections, UITabBarController's counterpart + * + * SYNC: When modified, update TabBar.doc.mjs, TabBar.test.tsx and + * __tests__/TabBar.interaction.browser.spec.ts. + */ + +import { + Children, + isValidElement, + useCallback, + useId, + useRef, + type ReactElement, + type ReactNode, +} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import type {BaseProps} from '@astryxdesign/core'; +import {useListFocus, useMergedRefs} from '@astryxdesign/core/hooks'; +import {useTouchPresentation} from '@astryxdesign/core/internal/touch'; +import { + colorVars, + durationVars, + easeVars, + focusVars, + fontWeightVars, + radiusVars, + shadowVars, + typographyVars, +} from '@astryxdesign/core/theme/tokens.stylex'; +import {useTranslator} from '@astryxdesign/core/i18n'; +import {mergeProps, themeProps} from '@astryxdesign/core/utils'; +import {tabBarItemMarker} from './tabBar.stylex'; +import {tabBar} from './uikitTabBar'; +import {useTabBarLens} from './TabBarTouch'; +import {itemWidthCss, naturalWidth} from './tabBarModel'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface TabBarItemProps { + /** Identifies the tab in `value`, `onChange` and `onReselect`. */ + value: string; + /** The tab's name, shown under its icon. */ + label: string; + /** The tab's icon, drawn in the current text colour (an ``). */ + icon: ReactNode; + /** The icon while the tab is selected (a filled variant). Defaults to `icon`. */ + selectedIcon?: ReactNode; + /** + * A badge over the icon's top-trailing corner: a count or a short string. + * Omit it, or pass an empty string, for no badge. + */ + badge?: string | number; + /** + * What assistive technology reads for the badge, after the label + * ("3 unread"). Defaults to the badge itself. + */ + badgeLabel?: string; +} + +export interface TabBarProps extends Omit< + BaseProps, + 'onChange' +> { + /** Ref forwarded to the root element. */ + ref?: React.Ref; + /** The selected tab's `value`. */ + value: string; + /** Called with a tab's `value` when a different tab is chosen. */ + onChange: (value: string) => void; + /** + * Called with the selected tab's `value` when it is chosen again: pop its + * navigation stack to the root, as iOS does. + */ + onReselect?: (value: string) => void; + /** + * Names the tab bar for assistive technology. Without it, or `aria-label` + * or `aria-labelledby`, the tab list is named "Tabs" in the reader's + * language, as TabList's is. + */ + label?: string; + /** Names the tab list, as `label` does; it wins over `label`. */ + 'aria-label'?: string; + /** Names the tab list after another element; it wins over both. */ + 'aria-labelledby'?: string; + /** The tabs: `TabBarItem` elements, in order. */ + children: ReactNode; +} + +/** + * One tab of a TabBar. It renders nothing on its own: TabBar reads its props + * and draws the tab (it must be a direct child of TabBar). + */ +export function TabBarItem(_props: TabBarItemProps): null { + return null; +} + +function readItems(children: ReactNode): TabBarItemProps[] { + return Children.toArray(children).flatMap(child => + isValidElement(child) && child.type === TabBarItem + ? [(child as ReactElement).props] + : [], + ); +} + +function hasBadge(badge: string | number | undefined): boolean { + return badge != null && badge !== ''; +} + +// ============================================================================= +// Geometry, as CSS on the capsule (a size container: 100cqw is its width) +// ============================================================================= + +const px = (value: number) => `${value}px`; +const INSET = px(tabBar.itemInset); + +/** The items' shared width (and the resting lens's), inside a calc(). */ +const size = (count: number) => itemWidthCss(count); + +/** The distance between neighbouring items' centres. */ +function pitch(count: number): string { + return count > 1 + ? `((100cqw - ${px(2 * tabBar.itemInset)} - ${size(count)}) / ${count - 1})` + : '0px'; +} + +/** An item's centre from the capsule's inline start, inside a calc(). */ +function center(index: number, count: number): string { + return count > 1 + ? `${INSET} + ${size(count)} / 2 + ${pitch(count)} * ${index}` + : '50cqw'; +} + +/** + * An item button's column, and where the item starts inside it. The column runs + * from halfway to the previous item to halfway to the next. The first and last + * columns end at the capsule's edges. + */ +function column( + index: number, + count: number, +): {start: string; width: string; itemStart: string} { + if (count <= 1) { + return { + start: '0px', + width: '100cqw', + itemStart: `calc(50cqw - ${size(count)} / 2)`, + }; + } + const half = `${pitch(count)} / 2`; + const edgeWidth = `calc(${INSET} + ${size(count)} / 2 + ${half})`; + if (index === 0) { + return {start: '0px', width: edgeWidth, itemStart: INSET}; + } + return { + start: `calc(${center(index, count)} - ${half})`, + width: index === count - 1 ? edgeWidth : `calc(${pitch(count)})`, + itemStart: `calc(${half} - ${size(count)} / 2)`, + }; +} + +/** How far the lens's resting place is from the first item. */ +function lensOffset(index: number, count: number): string { + return count > 1 ? `calc(${pitch(count)} * ${index})` : '0px'; +} + +// ============================================================================= +// Styles +// ============================================================================= + +const styles = stylex.create({ + root: { + position: 'fixed', + insetInline: 0, + bottom: 0, + // No page-level stacking rank: the lens and the badges order inside the + // bar's own stacking context. + isolation: 'isolate', + display: 'flex', + justifyContent: 'center', + boxSizing: 'border-box', + pointerEvents: 'none', + }, + rootInset: (inset: string) => ({ + paddingInline: inset, + paddingBottom: inset, + }), + capsule: { + position: 'relative', + flexGrow: 1, + minWidth: 0, + containerType: 'inline-size', + pointerEvents: 'auto', + borderRadius: radiusVars['--radius-full'], + backgroundColor: `color-mix(in srgb, ${colorVars['--color-background-popover']} 78%, transparent)`, + backdropFilter: 'blur(16px) saturate(180%)', + boxShadow: shadowVars['--shadow-med'], + touchAction: 'manipulation', + WebkitTapHighlightColor: 'transparent', + }, + capsuleSize: (height: string, maxWidth: string) => ({height, maxWidth}), + item: { + position: 'absolute', + top: 0, + bottom: 0, + margin: 0, + padding: 0, + borderWidth: 0, + backgroundColor: 'transparent', + color: colorVars['--color-text-primary'], + font: 'inherit', + cursor: { + default: 'pointer', + ':is(:disabled,[aria-disabled="true"])': 'default', + }, + outline: 'none', + WebkitTapHighlightColor: 'transparent', + }, + column: (start: string, width: string) => ({ + insetInlineStart: start, + width, + }), + itemBox: { + position: 'absolute', + display: 'block', + borderRadius: radiusVars['--radius-full'], + outlineStyle: { + default: 'none', + [stylex.when.ancestor(':focus-visible', tabBarItemMarker)]: + focusVars['--focus-outline-style'], + }, + outlineWidth: focusVars['--focus-outline-width'], + outlineColor: focusVars['--focus-outline-color'], + outlineOffset: focusVars['--focus-outline-offset'], + }, + box: (start: string, top: string, width: string, height: string) => ({ + insetInlineStart: start, + top, + width, + height, + }), + icon: { + position: 'absolute', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + label: { + position: 'absolute', + insetInline: 0, + overflow: 'hidden', + textAlign: 'center', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontFamily: typographyVars['--font-family-body'], + fontWeight: fontWeightVars['--font-weight-medium'], + }, + labelGeometry: (top: string, height: string, fontSize: string) => ({ + top, + height, + fontSize, + lineHeight: height, + }), + labelSelected: { + fontWeight: fontWeightVars['--font-weight-semibold'], + }, + lens: { + position: 'absolute', + pointerEvents: 'none', + transitionProperty: 'translate', + transitionDuration: { + default: durationVars['--duration-medium'], + // Under a finger the animator moves it (TabBarTouch.ts); under Reduce + // Motion a touch screen cross-fades it there and a mouse jumps. + '@media (pointer: coarse)': '0s', + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s', + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': + '0s', + }, + transitionTimingFunction: easeVars['--ease-standard'], + }, + // At rest the lens is the pill under the selected item, which draws its own + // selected look. While it slides it carries the copy over the items. + lensUnder: { + zIndex: -1, + }, + lensRest: (offset: string) => ({ + translate: { + default: `${offset} 0`, + ':is([dir="rtl"] *)': `calc(-1 * ${offset}) 0`, + }, + }), + lensBody: { + position: 'absolute', + inset: 0, + // The lifted lens's shadow, outside the clip: the animator fades it in. + '::before': { + content: '""', + position: 'absolute', + inset: 0, + borderRadius: radiusVars['--radius-full'], + boxShadow: shadowVars['--shadow-med'], + opacity: 0, + // The animator promotes it while it moves (core's animator.ts). + willChange: 'var(--astryx-motion-will-change-before, auto)', + }, + }, + lensClip: { + position: 'absolute', + inset: 0, + overflow: 'hidden', + borderRadius: radiusVars['--radius-full'], + // Opaque: while it slides it hides the plain items under the copy. + backgroundColor: `color-mix(in srgb, ${colorVars['--color-text-primary']} 9%, ${colorVars['--color-background-popover']})`, + // The lifted lens: the capsule's own surface with a light rim, opaque so + // the plain copy under it stays hidden; the animator fades it in. + '::before': { + content: '""', + position: 'absolute', + inset: 0, + borderRadius: radiusVars['--radius-full'], + backgroundColor: colorVars['--color-background-popover'], + boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${colorVars['--color-text-primary']} 10%, transparent)`, + opacity: 0, + // The animator promotes it while it moves (core's animator.ts). + willChange: 'var(--astryx-motion-will-change-before, auto)', + }, + }, + copyInner: { + position: 'absolute', + inset: 0, + }, + // Mounted only while the lens slides under a finger, where the animator + // moves it: no transition of its own. + copyOuter: { + position: 'absolute', + width: '100cqw', + }, + copyOuterGeometry: (inset: string, height: string) => ({ + insetInlineStart: `calc(-1 * ${inset})`, + top: `calc(-1 * ${inset})`, + height, + }), + copyRest: (offset: string) => ({ + translate: { + default: `calc(-1 * ${offset}) 0`, + ':is([dir="rtl"] *)': `${offset} 0`, + }, + }), + copyItem: { + position: 'absolute', + color: colorVars['--color-accent'], + }, + // The selected tab's own look at rest: the copy's accent. + itemSelectedLook: { + color: colorVars['--color-accent'], + }, + badges: { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + }, + badge: { + position: 'absolute', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + boxSizing: 'border-box', + whiteSpace: 'nowrap', + fontFamily: typographyVars['--font-family-body'], + fontWeight: fontWeightVars['--font-weight-medium'], + fontVariantNumeric: 'tabular-nums', + backgroundColor: colorVars['--color-error'], + color: colorVars['--color-on-error'], + }, + badgeGeometry: ( + start: string, + top: string, + height: string, + paddingInline: string, + fontSize: string, + ) => ({ + insetInlineStart: start, + top, + height, + minWidth: height, + paddingInline, + borderRadius: `calc(${height} / 2)`, + fontSize, + lineHeight: height, + }), +}); + +// ============================================================================= +// Pieces +// ============================================================================= + +const ITEM_TOP = px(tabBar.itemInset); +const ITEM_HEIGHT = px(tabBar.itemHeight); +const ICON_START = `calc(50% - ${px(tabBar.iconSize / 2)})`; +const ICON_TOP = px(tabBar.iconCenterY - tabBar.iconSize / 2); +const ICON_SIZE = px(tabBar.iconSize); +const LABEL_TOP = px(tabBar.labelTop); +const LABEL_HEIGHT = px(tabBar.labelSize * 1.2); +const LABEL_SIZE = px(tabBar.labelSize); + +/** + * An item's icon and label, in the plain or the selected look; the lens's + * copy keeps them out of the accessibility tree. + */ +function ItemFace({ + item, + isSelectedLook, + isCopy = false, +}: { + item: TabBarItemProps; + isSelectedLook: boolean; + isCopy?: boolean; +}) { + return ( + <> + + {isSelectedLook ? (item.selectedIcon ?? item.icon) : item.icon} + + + {item.label} + + + ); +} + +// ============================================================================= +// TabBar +// ============================================================================= + +/** + * The iOS 26 tab bar: the app's top-level sections as a floating capsule at + * the bottom of the screen. A selection lens slides between them. + * + * @example + * ``` + * + * } /> + * } badge={3} /> + * + * ``` + */ +/* + * WHAT IT DRAWS + * + * A capsule floats above the bottom edge: 62 px tall, 21 px from the sides and + * the bottom (uikitTabBar.ts). With two or three tabs it is centred and + * narrower. Its items are 54 px tall and share one width, which UIKit derives + * from the capsule's width and the tab count. They spread evenly inside it, so + * neighbours overlap. The selection lens is a pill under the selected item, and + * the item draws its own selected look (accent colour, semibold label). While + * the lens moves under a finger, as it slides or lifts into glass, the selected + * look rides inside it, as on iOS. That look is a copy of the items, + * counter-translated so it stays over the buttons. So an item half under the + * lens is half tinted. That copy is mounted only while the lens moves; at rest, + * and always under a mouse, every item is drawn once. The lens's moving fill, + * and the glass it lifts into, are opaque and hide the plain items beneath the + * copy. + * + * Layout is CSS: the capsule is a size container and every position is a + * `calc()` of its width. So the bar is right before hydration and through a + * resize. Each button spans the capsule from halfway to its neighbours, so a + * press goes to the nearest item, which is the item UIKit picks. + * + * HOW THE LENS MOVES + * + * Under a finger (the touch presentation: a coarse primary pointer, on iOS and + * Android alike), useTabBarLens.ts moves the lens as UITabBar does. A finger + * landing lifts the lens while the bar flexes: the lens grows into clear glass + * and magnifies the item under it. A dragging finger is chased across the + * items, and the lift chooses the item nearest the finger. A selection made + * with the keys slides the lens on UIKit's spring (`tabBar.lensTravel`). + * Everything runs on the compositor, keeps its speed when retargeted, and plays + * each spring as measured (uikitTabBar.ts). That motion is TabBar's touch layer + * (TabBarTouch.ts), its one import site. Under a mouse (a fine pointer) the + * lens slides on the theme's duration and easing, a CSS transition on + * `translate` that the compositor runs. The selected look moves to the new tab + * at once. A press that drags moves the lens body on `lensTrack`, and its + * release settles it on `lensSettle`. Neither lifts the lens nor draws a copy + * of the items. The release chooses the tab under it. Under + * Reduce Motion nothing travels, and the animator applies it. On a touch screen + * the lens cross-fades: it fades out where it was and in at the new tab, in 200 + * ms. A press scales nothing. Under a mouse the lens moves at once. + * + * SELECTION AND RESELECT + * + * The app owns the selection (`value` + `onChange`). Choosing the selected tab + * again calls `onReselect` instead: on iOS that pops the tab's navigation stack + * to its root (NavigationStack's `popToRoot`). On iOS 26.2 it does not scroll a + * screen already at its root to the top. + * + * Badges appear, change and disappear without animating, as UIKit's do. + * + * Accessibility: a `tablist` of `tab` buttons (`aria-selected`) with one tab + * stop. The arrow keys, Home and End move between tabs, and Enter or Space + * chooses one. A badge is part of its tab's name, one message with both + * ("Library, 3"; `badgeLabel`, or the badge itself). + */ +export function TabBar({ + value, + onChange, + onReselect, + label, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + children, + xstyle, + className, + style, + ref, + ...props +}: TabBarProps) { + const t = useTranslator(); + // The tab list always has a name, and the aria naming props reach it rather + // than the root, as TabList's do. + const tabListLabel = + ariaLabelledBy != null + ? undefined + : (ariaLabel ?? label ?? t('@astryx.tabList.label')); + const items = readItems(children); + const count = items.length; + const selectedIndex = items.findIndex(item => item.value === value); + const baseId = useId(); + + // UIKit's motion belongs to a finger (a coarse primary pointer, iOS and + // Android alike). A mouse keeps the theme's motion, and its drag carries the + // lens on it. + const isTouch = useTouchPresentation().isTouch; + + const capsuleRef = useRef(null); + const {listRef, handleKeyDown, handleFocus} = useListFocus({ + itemSelector: '[role="tab"]', + orientation: 'horizontal', + hasRovingTabIndex: true, + }); + const choose = useCallback( + (item: TabBarItemProps) => { + if (item.value === value) { + onReselect?.(item.value); + } else { + onChange(item.value); + } + }, + [value, onChange, onReselect], + ); + const lens = useTabBarLens({ + capsuleRef, + count, + selectedIndex, + isTouch, + onLift: index => choose(items[index]), + }); + const mergedCapsuleRef = useMergedRefs(capsuleRef, listRef, lens.capsule); + + const restIndex = Math.max(selectedIndex, 0); + const capsuleInset = px(tabBar.itemInset); + const itemSize = `calc(${size(count)})`; + + return ( +
+
+ {items.map((item, index) => { + const isSelected = index === selectedIndex; + const {start, width, itemStart} = column(index, count); + return ( + + ); + })} + {selectedIndex >= 0 ? ( +
+
+
+ {lens.isCopyShown ? ( +
+
+ {items.map((item, index) => ( + + + + ))} +
+
+ ) : null} +
+
+
+ ) : null} +
+ {items.map((item, index) => + hasBadge(item.badge) ? ( + + {item.badge} + + ) : null, + )} +
+
+
+ ); +} diff --git a/packages/lab/src/TabBar/TabBarTouch.ts b/packages/lab/src/TabBar/TabBarTouch.ts new file mode 100644 index 0000000000000..37f1f5f65106f --- /dev/null +++ b/packages/lab/src/TabBar/TabBarTouch.ts @@ -0,0 +1,16 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file TabBarTouch.ts + * @input The touch hooks beside it + * @output TabBar's touch layer, its one import site: every hook that runs + * core's animator or gesture recognizers for TabBar (the exports below) + * @position Internal to the lab TabBar (TabBar.tsx) + * + * Under a fine pointer a drag moves the lens body on `lensTrack` and settles + * it on `lensSettle`. The lift and the copy of the items stay a finger's. + */ + +export {useTabBarLens} from './useTabBarLens'; diff --git a/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts b/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts new file mode 100644 index 0000000000000..ffef514db74e7 --- /dev/null +++ b/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts @@ -0,0 +1,1926 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file TabBar.interaction.browser.spec.ts + * @input The built "Lab/TabBar" stories, real touch and mouse input on the + * harness's phone, and the interaction harness + * @output Proofs that TabBar moves like UITabBar on iOS 26.2: the lens's + * slide, its press tracking and drag. Also the mouse presentation. + * @position Interaction proof for the lab TabBar (TabBar.tsx, + * tabBarModel.ts) + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import type {TouchScript} from '@astryxdesign/interaction-harness'; +import { + advanceFrozenPage, + animationStoryboard, + blockMainThread, + busyWindowProof, + castStoryboard, + CONTROL_RGB, + countBetween, + drag, + freezePage, + freezePageOn, + holdAnimations, + installPageFreeze, + installTimerGate, + markElement, + openStory, + PEN_SKIP, + releaseAnimations, + sampleCurve, + sampleRects, + springStep, + startBusyControl, + startScreencast, + Storyboard, + tap as tapScript, + thawPage, + toTouchEvents, + touchDriver, + touchStoryboard, + traceRendering, + trackMarker, + waitForAnimation, + waitForRest, + webInputDriver, + withStorybook, + writeEvidence, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +/** + * A mouse case's own guard: its context's primary pointer is fine (a context + * without touch can still match a coarse pointer in the interaction config). + */ +async function expectFinePointer(page: Page) { + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + 'the primary pointer is fine', + ).toBe(false); +} +/** + * The opacity of an element's `::before` at each of `times` (ms into the held + * curves), as sampleCurve reads an element's. The lens's lifted glass is its + * clip's `::before`. + */ +async function samplePseudoOpacity(locator: Locator, times: number[]) { + return locator.evaluate((element, ts) => { + const held = + (window as unknown as {__harnessHeld?: Animation[]}).__harnessHeld ?? []; + return ts.map(t => { + for (const animation of held) { + const end = Number( + animation.effect?.getComputedTiming().endTime ?? Infinity, + ); + animation.currentTime = Math.min(t, end - 0.001); + } + return Number(getComputedStyle(element, '::before').opacity); + }); + }, times); +} + +/** UIKit's lens travel on iOS 26.2 (uikitTabBar.ts `lensTravel`). */ +const LENS_TRAVEL = {response: 0.4, dampingFraction: 0.85}; +/** The lift (`lift`), the flex while pressed and after (`flexPress`, `flexRelease`). */ +const LIFT = {response: 0.25, dampingFraction: 1}; +const FLEX_PRESS = {response: 0.314, dampingFraction: 0.625}; +const FLEX_RELEASE = {response: 0.36, dampingFraction: 0.6}; +const FLEX_SCALE = 1.0488; +/** The lifted lens's scale: the resting lens grown by 8 px on every side. */ +async function liftedScale(page: Page) { + const width = await lensBody(page).evaluate( + element => (element as HTMLElement).offsetWidth, + ); + return {x: (width + 16) / width, y: 70 / 54}; +} + +/** + * Opens a TabBar story on the harness's phone: a coarse primary pointer, so + * the touch presentation applies, whatever the engine (one touch model). + */ +/** + * Page side, from before the story loads: every script animation the page makes + * (the animator's) and the document time it was made. It also records when a + * finger last lifted. The reduced-motion checks read a short cross-fade from it + * after the fade ends. They date "since the lift" by the page's own lift, not + * by a later read. + */ +function animationLog(): void { + const w = window as unknown as { + __tabBarMade?: {animation: Animation; at: number}[]; + __tabBarLifted?: number; + }; + if (w.__tabBarMade != null) { + return; + } + const made: {animation: Animation; at: number}[] = []; + w.__tabBarMade = made; + w.__tabBarLifted = 0; + const now = () => Number(document.timeline.currentTime ?? 0); + const animate = Element.prototype.animate; + Element.prototype.animate = function ( + this: Element, + ...args: Parameters + ) { + const animation = animate.apply(this, args); + made.push({animation, at: now()}); + if (made.length > 5000) { + made.splice(0, made.length - 5000); + } + return animation; + }; + window.addEventListener( + 'touchend', + () => { + w.__tabBarLifted = now(); + }, + true, + ); +} + +/** When a finger last lifted on the page, on the document timeline (0: never). */ +const liftedAt = (page: Page) => + page.evaluate( + () => (window as unknown as {__tabBarLifted?: number}).__tabBarLifted ?? 0, + ); + +async function openBar(page: Page, story = 'default') { + await page.addInitScript(animationLog); + await openStory(page, storybook(), `lab-tabbar--${story}`); + const driver = await touchDriver(page); + const bar = page.getByRole('tablist', {name: 'Sections'}); + await expect(bar).toBeVisible(); + return {driver, bar}; +} + +/** + * `openBar` on a page whose scene the test can freeze: the page clock goes in + * before the story loads, the timer gate after. + */ +async function openFreezableBar(page: Page, story = 'default') { + await installPageFreeze(page); + const opened = await openBar(page, story); + await installTimerGate(page); + return opened; +} + +const tab = (page: Page, name: string | RegExp) => + page.getByRole('tab', {name}); + +/** The centre of a tab's 76 × 54 item, where a finger lands on it. */ +async function itemCenter(page: Page, name: string | RegExp) { + const box = await tab(page, name).locator('span').first().boundingBox(); + if (box == null) { + throw new Error(`no tab ${String(name)}`); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +const lensBody = (page: Page) => page.locator('[data-tab-bar-lens-body]'); +const lensCopy = (page: Page) => page.locator('[data-tab-bar-copy]'); + +/** The lens's centre on screen. */ +async function lensCenterX(page: Page) { + const box = await lensBody(page).boundingBox(); + if (box == null) { + throw new Error('no lens'); + } + return box.x + box.width / 2; +} + +/** + * The elements under test are on screen before anything is measured about + * them: `count` of them, each with a nonzero box. + */ +async function expectPainted(locator: Locator, count = 1) { + await expect(locator).toHaveCount(count); + const boxes = await locator.evaluateAll(elements => + elements.map(element => { + const rect = element.getBoundingClientRect(); + return {width: rect.width, height: rect.height}; + }), + ); + for (const box of boxes) { + expect(box.width, JSON.stringify(boxes)).toBeGreaterThan(0); + expect(box.height, JSON.stringify(boxes)).toBeGreaterThan(0); + } +} + +/** A presented translateX, px (the animator drives `translate`). */ +const translateX = async (locator: Locator) => + locator.evaluate(element => { + const value = getComputedStyle(element).translate; + return value === 'none' ? 0 : parseFloat(value); + }); + +/** A presented scale (the animator drives `scale`). */ +const scaleOf = async (locator: Locator) => + locator.evaluate(element => { + const value = getComputedStyle(element).scale; + if (value === 'none') { + return {x: 1, y: 1}; + } + const [x, y = x] = value.split(' ').map(Number); + return {x, y}; + }); + +/** The capsule's left edge on screen, before its flex (its root is unscaled). */ +async function capsuleLeft(page: Page) { + return page.getByRole('tablist').evaluate(element => { + const root = element.parentElement?.getBoundingClientRect(); + return (root?.left ?? 0) + (element as HTMLElement).offsetLeft; + }); +} + +test('a selection made with the keys slides the lens on UIKit’s spring, the selected copy counter-moving', async ({ + page, +}, testInfo) => { + await openFreezableBar(page); + const from = await itemCenter(page, 'Home'); + const to = await itemCenter(page, 'Browse'); + await tab(page, 'Home').focus(); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + // The scene stands still from before the key: the slide it starts is held at + // its start. The copy that rides in the lens only while it slides stays + // mounted, however slowly the test process reads them. + await freezePage(page); + await page.keyboard.press('Enter'); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + const body = lensBody(page); + await waitForAnimation(body, {minProgressMs: 0}); + await holdAnimations(page); + + const times = Array.from({length: 49}, (_, i) => i * 16.667); + const bodySamples = await sampleCurve(body, times); + const copySamples = await sampleCurve(lensCopy(page), times); + // The body starts where the lens was (over Home) relative to its new + // resting place (Browse) and springs to 0 from rest. + const start = from.x - to.x; + const samples = times.map((t, i) => ({ + t: +t.toFixed(1), + body: bodySamples[i].transform.translateX, + copy: copySamples[i].transform.translateX, + analytic: start * (1 - springStep(LENS_TRAVEL, t)), + })); + await releaseAnimations(page, {finish: true}); + await thawPage(page); + await writeEvidence( + 'curves/tabbar-lens-travel', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {start, samples}, + null, + 1, + ), + }, + testInfo, + ); + + for (const sample of samples) { + expect( + Math.abs(sample.body - sample.analytic), + JSON.stringify(sample), + ).toBeLessThan(0.5); + // The copy inside undoes the body's travel, so the items stay put. + expect( + Math.abs(sample.copy + sample.body), + JSON.stringify(sample), + ).toBeLessThan(0.3); + } + // UIKit's lens overshoots a little (ζ 0.85) before it settles. + expect(Math.max(...samples.map(s => s.body))).toBeGreaterThan(0.5); + await waitForRest(body); + expect(Math.abs((await lensCenterX(page)) - to.x)).toBeLessThan(0.5); +}); + +test('a second tap mid-slide turns the lens around from where it is, without a jump', async ({ + page, +}) => { + const {driver} = await openFreezableBar(page); + const home = await itemCenter(page, 'Home'); + const profile = await itemCenter(page, 'Profile'); + const library = await itemCenter(page, 'Library'); + // Records the lens's centre as each finger lands: under a finger the + // lens turns for the second finger the moment it lands. + await page.evaluate(() => { + const lens = document.querySelector('[data-tab-bar-lens-body]'); + const landings: number[] = []; + (window as unknown as {__landings: number[]}).__landings = landings; + window.addEventListener( + 'touchstart', + () => { + const box = lens?.getBoundingClientRect(); + if (box != null) { + landings.push(box.x + box.width / 2); + } + }, + {capture: true}, + ); + }); + // The scene stands still from before the first tap: the slide it starts is + // held at its start. Then the scene's own time steps it on until the lens is + // past Library, however late the test process is. The second tap lands there, + // with most of the slide still ahead. + await freezePage(page); + await driver.tap(profile); + let frozenAt = await lensCenterX(page); + for ( + let stepped = 0; + frozenAt <= library.x + 25 && stepped < 1_000; + stepped += 4 + ) { + await advanceFrozenPage(page, 4); + frozenAt = await lensCenterX(page); + } + // Mid-slide: past Library, short of Profile. + expect(frozenAt).toBeGreaterThan(library.x + 5); + expect(frozenAt).toBeLessThan(profile.x - 20); + await driver.tap(library); + await thawPage(page); + // Every frame's lens position from the thaw on, as it turns. Before it the + // test stepped the scene's time itself, several steps to a frame. + const sampling = sampleRects(lensBody(page), 1300); + await expect(tab(page, 'Library')).toHaveAttribute('aria-selected', 'true'); + const turnedAt = await page.evaluate( + () => (window as unknown as {__landings: number[]}).__landings[1], + ); + // The second finger landed on the lens where the scene stood. + expect(Math.abs(turnedAt - frozenAt)).toBeLessThan(0.5); + const samples = (await sampling).flatMap(sample => + sample.box == null + ? [] + : [{t: sample.t, x: sample.box.x + sample.box.width / 2}], + ); + // It went on toward Profile and came back to Library, never jumping. A jump + // to either end would cover the whole remaining distance (over 100 px) in + // one frame. Only consecutive frames are compared, never samples a stall kept + // apart. + const steps = samples + .slice(1) + .flatMap((sample, i) => + sample.t - samples[i].t <= 34 ? [sample.x - samples[i].x] : [], + ); + // The first frame after the thaw starts where the scene stood. + expect(Math.abs(samples[0].x - frozenAt)).toBeLessThan(90); + expect( + Math.max(...steps.map(Math.abs)), + JSON.stringify(samples), + ).toBeLessThan(90); + expect(Math.min(...samples.map(sample => sample.x))).toBeGreaterThan( + home.x - 1, + ); + await waitForRest(lensBody(page)); + expect(Math.abs((await lensCenterX(page)) - library.x)).toBeLessThan(0.5); +}); + +test('a finger landing before the last lift reaches the app keeps the lens heading for it', async ({ + page, +}) => { + const {driver} = await openFreezableBar(page); + const profile = await itemCenter(page, 'Profile'); + const library = await itemCenter(page, 'Library'); + const sampling = sampleRects(lensBody(page), 1400); + // A tap on Profile, then a finger held on Library from right after that lift. + // Inside the frames before the tap's choice reaches the app, which then moves + // the lens's resting place under the held finger. The scene freezes on the + // first finger's pointerdown, before the page's touchstart work starts any + // motion. A loaded box can spend tens of ms in that work. While frozen, the + // scene moves on by the script's own gaps. So the second finger lands 80 ms + // into the first slide of the scene's time, however a loaded box stretches + // the gaps. It thaws once the second finger is down; the hold is a wait after + // the landing. + const script: TouchScript = [ + {t: 0, fingers: [{id: 0, ...profile}]}, + {t: 60, fingers: []}, + {t: 80, fingers: [{id: 1, ...library}]}, + {t: 580, fingers: []}, + ]; + const events = toTouchEvents(script); + await freezePageOn(page, 'pointerdown'); + await driver.play(script, { + realtime: false, + onEvent: async (step, index) => { + if (index < 2) { + await advanceFrozenPage(page, events[index + 1].t - step.t); + } else if (index === 2) { + await thawPage(page); + await page.waitForTimeout(500); + } + }, + }); + await expect(tab(page, 'Library')).toHaveAttribute('aria-selected', 'true'); + const samples = (await sampling).flatMap(sample => + sample.box == null + ? [] + : [{t: sample.t, x: sample.box.x + sample.box.width / 2}], + ); + const centers = samples.map(sample => sample.x); + // Only consecutive frames are compared, never samples a stall kept apart + // (software-rendered WebKit can present no frame for a while around a + // touch). + const steps = samples + .slice(1) + .flatMap((sample, i) => + sample.t - samples[i].t <= 34 ? [sample.x - samples[i].x] : [], + ); + expect( + Math.max(...steps.map(Math.abs)), + JSON.stringify(samples), + ).toBeLessThan(90); + // The lens turned back for the held finger and never reached Profile. + expect(Math.max(...centers), JSON.stringify(centers)).toBeLessThan( + profile.x - 30, + ); + await waitForRest(lensBody(page)); + expect(Math.abs((await lensCenterX(page)) - library.x)).toBeLessThan(0.5); +}); + +test('a tap on the selected tab reselects it: the lens lifts and settles back', async ({ + page, +}) => { + const {driver} = await openBar(page); + const home = await itemCenter(page, 'Home'); + await driver.tap(home); + await expect(page.getByTestId('reselects')).toHaveText('Reselected 1 times'); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + await waitForRest(page.getByRole('tablist')); + expect(Math.abs((await lensCenterX(page)) - home.x)).toBeLessThan(0.5); + expect(await scaleOf(lensBody(page))).toEqual({x: 1, y: 1}); +}); + +test('with no tab selected, a finger chooses the tab under it', async ({ + page, +}) => { + const {driver} = await openBar(page, 'no-selection'); + await expect(page.getByRole('tab', {selected: true})).toHaveCount(0); + const browse = await itemCenter(page, 'Browse'); + await driver.tap(browse); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); +}); + +test('a held finger never starts text selection on the bar', async ({page}) => { + const {driver} = await openBar(page); + const browse = await itemCenter(page, 'Browse'); + const capsule = page.getByRole('tablist'); + // The lens's hold opts into the gesture host's selection lock (spec:AST-094 + // FR41). Off iOS that lock writes no style. It cancels selectstart on what + // the finger holds, so the proof dispatches one there, during the hold and + // after the lift. + const refusesSelection = () => + tab(page, 'Browse').evaluate(element => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + element.dispatchEvent(event); + return event.defaultPrevented; + }); + let refusedWhileHeld: boolean | null = null; + await driver.play(tapScript(browse, {holdMs: 600}), { + onEvent: async (_step, index) => { + if (index !== 0) { + return; + } + // The hold has begun once the lens lifts. + await waitForAnimation(capsule); + refusedWhileHeld = await refusesSelection(); + }, + }); + expect(refusedWhileHeld).toBe(true); + expect(await refusesSelection()).toBe(false); +}); + +test('a tap reports its choice once', async ({page}) => { + const {driver} = await openBar(page, 'removed-on-lift'); + const read = () => + page.evaluate( + () => (window as unknown as {__tabBarChanges: string[]}).__tabBarChanges, + ); + await page.evaluate(() => { + (window as unknown as {__tabBarChanges: string[]}).__tabBarChanges = []; + }); + // Under a finger the lift reports, and the click the browser sends after + // it must not report again (`spec:AST-094` FR21). + await driver.tap(await itemCenter(page, 'Library')); + await page.waitForTimeout(400); + expect((await read()).filter(entry => entry === 'library')).toEqual([ + 'library', + ]); +}); + +test('a bar removed as the finger lifts reports nothing after it is gone', async ({ + page, +}) => { + const {driver} = await openBar(page, 'removed-on-lift'); + await page.evaluate(() => { + (window as unknown as {__tabBarChanges: string[]}).__tabBarChanges = []; + }); + await driver.tap(await itemCenter(page, 'Library')); + await expect(page.getByText('Removed')).toBeVisible(); + await page.waitForTimeout(400); + // The lift reports from its own event, so the choice may arrive before + // the app removes the bar in that event, never after. + const log = await page.evaluate( + () => (window as unknown as {__tabBarChanges: string[]}).__tabBarChanges, + ); + expect(log.at(-1)).toBe('(removed)'); + expect(log.filter(entry => entry !== '(removed)')).toEqual( + log.length > 1 ? ['library'] : [], + ); +}); + +test('a press ends without a choice when the tab list changes under it', async ({ + page, +}) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + const {driver, bar} = await openBar(page, 'drops-last-tab'); + await page.evaluate(() => { + (window as unknown as {__tabBarChanges: string[]}).__tabBarChanges = []; + }); + const profile = await itemCenter(page, 'Profile'); + await driver.play(tapScript(profile, {holdMs: 600}), { + onEvent: async (_step, index) => { + if (index !== 0) { + return; + } + // The hold has begun once the lens lifts; the app then drops Profile, + // the tab under the finger. + await waitForAnimation(bar); + await page.evaluate(() => + window.dispatchEvent(new Event('tabbar:drop-last')), + ); + await expect(tab(page, 'Profile')).toHaveCount(0); + }, + }); + // Settled before anything is read: the lens comes back onto Home after the + // lift, however long a loaded machine takes. + await waitForRest(lensBody(page)); + expect(errors).toEqual([]); + expect( + await page.evaluate( + () => (window as unknown as {__tabBarChanges: string[]}).__tabBarChanges, + ), + ).toEqual([]); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + // The lens settled back onto Home, in the three-tab bar. + const home = await itemCenter(page, 'Home'); + expect(Math.abs((await lensCenterX(page)) - home.x)).toBeLessThan(1); +}); + +test('the bar sets no page-level stacking rank: its layers order inside it', async ({ + page, +}) => { + await openBar(page); + const root = await page.getByTestId('tab-bar').evaluate(element => { + const style = getComputedStyle(element); + return {zIndex: style.zIndex, isolation: style.isolation}; + }); + expect(root).toEqual({zIndex: 'auto', isolation: 'isolate'}); +}); + +test('badges appear, change and clear without animating', async ({page}) => { + const {driver} = await openBar(page, 'badges'); + const badge = page.locator('[data-tab-bar-badge="inbox"]'); + await expect(badge).toHaveCount(0); + await driver.tap( + await page + .getByRole('button', {name: 'Add one'}) + .boundingBox() + .then(box => { + if (box == null) { + throw new Error('no button'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; + }), + ); + await expect(badge).toHaveText('1'); + expect(await badge.evaluate(element => element.getAnimations().length)).toBe( + 0, + ); + await expect(tab(page, /^Inbox\s*, 1 unread$/)).toBeVisible(); + await page.getByRole('button', {name: 'Add ten'}).click(); + await expect(badge).toHaveText('11'); + // "11" is wider than one digit: the badge grows past its 18.33 px circle. + const box = await badge.boundingBox(); + expect(box?.height).toBeCloseTo(18.33, 1); + expect(box?.width ?? 0).toBeGreaterThan(18.5); + await page.getByRole('button', {name: 'Clear'}).click(); + await expect(badge).toHaveCount(0); +}); + +test('a finger landing lifts the lens and flexes the bar on UIKit’s springs, the lens heading for the finger', async ({ + page, +}, testInfo) => { + const {driver} = await openFreezableBar(page); + const home = await itemCenter(page, 'Home'); + const browse = await itemCenter(page, 'Browse'); + const capsule = page.getByRole('tablist'); + const LIFTED = await liftedScale(page); + const times = Array.from({length: 31}, (_, i) => i * 16.667); + let samples: { + t: number; + flex: number; + liftX: number; + liftY: number; + glass: number; + x: number; + }[] = []; + // The scene freezes as the finger lands. So the lift, the flex and the travel + // it starts are held at their start however slowly the test process reads + // them. + await freezePageOn(page, 'touchstart'); + await driver.play(tapScript(browse, {holdMs: 1200}), { + onEvent: async (_step, index) => { + if (index !== 0) { + return; + } + await waitForAnimation(capsule, {minProgressMs: 0}); + await holdAnimations(page); + const flex = await sampleCurve(capsule, times); + const body = await sampleCurve(lensBody(page), times); + const glass = await samplePseudoOpacity( + page.locator('[data-tab-bar-lens-clip]'), + times, + ); + samples = times.map((t, i) => ({ + t: +t.toFixed(1), + flex: flex[i].transform.scaleX, + liftX: body[i].transform.scaleX, + liftY: body[i].transform.scaleY, + glass: glass[i], + x: body[i].transform.translateX, + })); + await releaseAnimations(page); + await thawPage(page); + }, + }); + await writeEvidence( + 'curves/tabbar-press', + {[`${testInfo.project.name}.json`]: JSON.stringify(samples, null, 1)}, + testInfo, + ); + const travel = browse.x - home.x; + for (const s of samples) { + const lift = springStep(LIFT, s.t); + const where = JSON.stringify(s); + expect( + Math.abs(s.flex - (1 + (FLEX_SCALE - 1) * springStep(FLEX_PRESS, s.t))), + where, + ).toBeLessThan(0.002); + expect(Math.abs(s.liftX - (1 + (LIFTED.x - 1) * lift)), where).toBeLessThan( + 0.005, + ); + expect(Math.abs(s.liftY - (1 + (LIFTED.y - 1) * lift)), where).toBeLessThan( + 0.005, + ); + expect(Math.abs(s.glass - lift), where).toBeLessThan(0.01); + expect( + Math.abs(s.x - travel * springStep(LENS_TRAVEL, s.t)), + where, + ).toBeLessThan(0.5); + } + // The lift commits Browse. + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); +}); + +test('the lift reports its choice from its own event, the settle already started (react-component-runtime INV2)', async ({ + page, +}) => { + const {driver} = await openBar(page); + const browse = await itemCenter(page, 'Browse'); + await page.evaluate(() => { + const w = window as unknown as { + __lift: { + frames: number; + atLift: number; + atChoice: number; + settling: boolean; + }; + }; + w.__lift = {frames: 0, atLift: -1, atChoice: -1, settling: false}; + const tick = () => { + w.__lift.frames += 1; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + window.addEventListener( + 'touchend', + () => { + w.__lift.atLift = w.__lift.frames; + }, + {capture: true}, + ); + const tab = [...document.querySelectorAll('[role="tab"]')].find(element => + element.textContent?.includes('Browse'), + ); + const body = document.querySelector('[data-tab-bar-lens-body]'); + new MutationObserver(() => { + if (tab?.getAttribute('aria-selected') === 'true') { + w.__lift.atChoice = w.__lift.frames; + w.__lift.settling = (body?.getAnimations().length ?? 0) > 0; + } + }).observe(tab as Element, {attributeFilter: ['aria-selected']}); + }); + await driver.play(tapScript(browse, {holdMs: 200})); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + const lift = await page.evaluate( + () => + ( + window as unknown as { + __lift: { + frames: number; + atLift: number; + atChoice: number; + settling: boolean; + }; + } + ).__lift, + ); + // Chosen in the lift's own task: no frame between the lift and the choice. + expect(lift.atLift).toBeGreaterThanOrEqual(0); + expect(lift.atChoice).toBe(lift.atLift); + // The settle was running when the app heard the choice. + expect(lift.settling).toBe(true); +}); + +test('the lift settles the lens and relaxes the lift and the flex on UIKit’s springs', async ({ + page, +}) => { + const {driver} = await openFreezableBar(page); + const browse = await itemCenter(page, 'Browse'); + const capsule = page.getByRole('tablist'); + const LIFTED = await liftedScale(page); + // The scene freezes as the finger lifts, so the settle the lift starts is + // held at its start however slowly the test process reads it. + await freezePageOn(page, 'touchend'); + // Held long enough for the press springs to settle. + await driver.play(tapScript(browse, {holdMs: 1200})); + // The choice reaches the app from the lift itself and restarts the lens's + // travel from its new resting place. Hold the curves after that, so no held + // animation is one the animator has since replaced. + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + await waitForAnimation(capsule, {minProgressMs: 0}); + await holdAnimations(page); + const times = Array.from({length: 37}, (_, i) => i * 16.667); + const flex = await sampleCurve(capsule, times); + const body = await sampleCurve(lensBody(page), times); + await releaseAnimations(page, {finish: true}); + await thawPage(page); + times.forEach((t, i) => { + const where = JSON.stringify({t, flex: flex[i].transform.scaleX}); + expect( + Math.abs( + flex[i].transform.scaleX - + (FLEX_SCALE + (1 - FLEX_SCALE) * springStep(FLEX_RELEASE, t)), + ), + where, + ).toBeLessThan(0.002); + expect( + Math.abs( + body[i].transform.scaleX - + (LIFTED.x + (1 - LIFTED.x) * springStep(LIFT, t)), + ), + where, + ).toBeLessThan(0.005); + }); + await waitForRest(lensBody(page)); + expect(Math.abs((await lensCenterX(page)) - browse.x)).toBeLessThan(0.5); +}); + +test('a drag chases the finger and the lift commits the item nearest it', async ({ + page, +}) => { + const {driver} = await openFreezableBar(page); + const home = await itemCenter(page, 'Home'); + const profile = await itemCenter(page, 'Profile'); + const left = await capsuleLeft(page); + const lags: number[] = []; + let atRest = Infinity; + const script = drag(home, profile, {durationMs: 600, restMs: 400}); + const events = toTouchEvents(script); + // The body's offset is in the capsule's own coordinates, from Home. + const lensX = async () => + (await translateX(lensBody(page))) + (home.x - left); + // The scene's own clock times the drag. Frozen from before the first touch, + // it moves on by each scripted gap. So every lag is read at its scripted + // instant, however late the test process is. + await freezePage(page); + await driver.play(script, { + realtime: false, + onEvent: async (step, index) => { + let stepped = 0; + const finger = step.fingers[0]?.x; + if (finger != null) { + lags.push(finger - left - (await lensX())); + if (index === events.length - 2) { + stepped = 350; + await advanceFrozenPage(page, stepped); + atRest = finger - left - (await lensX()); + } + } + const next = events[index + 1]; + if (next != null) { + await advanceFrozenPage(page, Math.max(0, next.t - step.t - stepped)); + } + }, + }); + await thawPage(page); + // The lens trails a moving finger but keeps up with it, and catches up + // once it rests. + const moving = lags.slice(3, -2); + expect(Math.max(...moving)).toBeGreaterThan(1); + expect(Math.max(...moving.map(Math.abs))).toBeLessThan(40); + expect(Math.abs(atRest)).toBeLessThan(1); + await expect(tab(page, 'Profile')).toHaveAttribute('aria-selected', 'true'); + await waitForRest(page.getByRole('tablist')); + expect(Math.abs((await lensCenterX(page)) - profile.x)).toBeLessThan(0.5); +}); + +test('when the app keeps the old selection, the lens settles back onto it', async ({ + page, +}) => { + const {driver} = await openBar(page, 'keeps-selection'); + const home = await itemCenter(page, 'Home'); + const profile = await itemCenter(page, 'Profile'); + await driver.play(drag(home, profile, {durationMs: 500, restMs: 300})); + // The lift chose Profile; the app kept Home. + await page.waitForTimeout(150); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + await expect(tab(page, 'Profile')).toHaveAttribute('aria-selected', 'false'); + await waitForRest(page.getByRole('tablist')); + expect(Math.abs((await lensCenterX(page)) - home.x)).toBeLessThan(0.5); + expect(await scaleOf(lensBody(page))).toEqual({x: 1, y: 1}); +}); + +test('right to left, a drag runs from the first tab leftward and commits where it lifts', async ({ + page, +}) => { + const {driver} = await openBar(page, 'right-to-left'); + const library = await itemCenter(page, 'Library'); + const profile = await itemCenter(page, 'Profile'); + // The later tab sits to the left. + expect(profile.x).toBeLessThan(library.x); + await driver.play(drag(library, profile, {durationMs: 500, restMs: 300})); + await expect(tab(page, 'Profile')).toHaveAttribute('aria-selected', 'true'); + await waitForRest(page.getByRole('tablist')); + expect(Math.abs((await lensCenterX(page)) - profile.x)).toBeLessThan(0.5); +}); + +test('a lift between two items commits the nearer one (an exact tie goes to the leading one: tabBarModel.test.ts)', async ({ + page, +}) => { + const {driver} = await openBar(page); + const library = await itemCenter(page, 'Library'); + const browse = await itemCenter(page, 'Browse'); + const midpoint = (library.x + browse.x) / 2; + await driver.play(tapScript({x: midpoint + 3, y: library.y}, {holdMs: 120})); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + await driver.play(tapScript({x: midpoint - 3, y: library.y}, {holdMs: 120})); + await expect(tab(page, 'Library')).toHaveAttribute('aria-selected', 'true'); +}); + +test('a finger dragged off the bar chooses nothing at the lift, and the lens shows the selected tab again: sliding off cancels (family:action-groups FR3)', async ({ + page, +}) => { + const {driver} = await openBar(page); + const home = await itemCenter(page, 'Home'); + const library = await itemCenter(page, 'Library'); + const across = [...drag(home, library, {durationMs: 300})]; + // Without the drag's own lift: the finger lifts off the bar instead. + across.pop(); + const last = across[across.length - 1]; + const up = Array.from({length: 10}, (_, i) => ({ + t: last.t + (i + 1) * 16.7, + fingers: [{id: 0, x: library.x, y: library.y - (i + 1) * 8}], + })); + const end = up[up.length - 1].t + 16.7; + await driver.play([ + ...across, + ...up, + // Held off the bar, then lifted there. + {t: end + 300, fingers: up[up.length - 1].fingers}, + {t: end + 316.7, fingers: []}, + ]); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + await waitForRest(lensBody(page)); + expect(Math.abs((await lensCenterX(page)) - home.x)).toBeLessThan(0.5); +}); + +test('past the last item, still on the bar, the lens rubber-bands beyond it and the lift commits the last item; past the bar’s end the lift chooses nothing', async ({ + page, +}) => { + const {driver} = await openBar(page); + const home = await itemCenter(page, 'Home'); + const profile = await itemCenter(page, 'Profile'); + const left = await capsuleLeft(page); + const right = await page + .getByRole('tablist') + .evaluate(element => (element as HTMLElement).offsetWidth); + // Two px inside the bar's trailing end. + const inside = {x: left + right - 2, y: profile.y}; + const script = drag(home, inside, {durationMs: 500, restMs: 900}); + let past = NaN; + await driver.play(script, { + onEvent: async (_step, index) => { + if (index === script.length - 2) { + await page.waitForTimeout(800); + const lens = (await translateX(lensBody(page))) + (home.x - left); + past = lens - (profile.x - left); + } + }, + }); + // Rubber-banded: past the last item, but by less than the finger is. + expect(past).toBeGreaterThan(0.5); + expect(past).toBeLessThan(inside.x - profile.x); + await expect(tab(page, 'Profile')).toHaveAttribute('aria-selected', 'true'); + // Past the bar's end the finger is off the bar: the lift chooses nothing. + await driver.play( + drag( + profile, + {x: left + right + 20, y: profile.y}, + {durationMs: 400, restMs: 300}, + ), + ); + await expect(tab(page, 'Profile')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId('reselects')).toHaveText('Reselected 0 times'); +}); + +test('a vertical drag that starts on the bar never scrolls the page, and lifted off the bar it chooses nothing', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'native scrolling from injected touches is Chromium-only in Playwright', + ); + const {driver} = await openBar(page); + const library = await itemCenter(page, 'Library'); + const before = await page.evaluate(() => window.scrollY); + await driver.play( + drag(library, {x: library.x, y: library.y - 300}, {durationMs: 300}), + ); + await page.waitForTimeout(300); + expect(await page.evaluate(() => window.scrollY)).toBe(before); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); +}); + +test('the slide keeps advancing 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: the iOS simulator)', + ); + const {driver} = await openBar(page); + const body = lensBody(page); + const profile = await itemCenter(page, 'Profile'); + await markElement(body); + // The compositor's own control rides in the same cast. A stall the compositor + // itself makes on a loaded runner stalls it too. So the move is judged + // against what the compositor presented, not against a fixed bound. + await startBusyControl(page); + const cast = await startScreencast(page); + // Armed before the tap, the page blocks itself in the frame that sees the + // slide running. One evaluate sees it and blocks, so no runner latency + // between the two can eat the slide first. The judged part ends where the + // lens settles (settledWithin). + const blocking = blockMainThread(page, 600, {during: body}); + await driver.tap(profile); + const block = await blocking; + await page.waitForTimeout(150); + const castFrames = await cast.stop(); + const tracked = await trackMarker(page, castFrames); + const control = await trackMarker(page, castFrames, {rgb: CONTROL_RGB}); + const proof = busyWindowProof( + tracked, + {from: block.from, to: block.to}, + { + axis: 'x', + settledWithin: 2, + // Screencast throughput, not the animation: no display-rate bound. + maxMedianFrameMs: Infinity, + control, + }, + ); + await writeEvidence( + 'busy-main-thread/tabbar-lens-travel', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {block, proof}, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, castFrames, { + name: 'tabbar-lens-travel-busy-main-thread', + zero: block.from, + from: block.from - 40, + to: proof.judged.to + 40, + columns: 8, + cellScale: 0.35, + data: t => ({x: tracked.find(f => f.t === t)?.box?.x ?? -1}), + }); + await film.write(testInfo, {block}); + // The harness's verdict: no stall past two dropped screencast frames, and the + // frames moving. A loaded machine drops a frame or two; a frozen slide + // presents none. + expect( + proof.verdict.pass, + `${proof.summary}; ${proof.verdict.failures.join('; ')}`, + ).toBe(true); +}); + +/** + * Notes, on the page, when the DOM change an action makes has rendered. That is + * three frames after the first change once armed (with `attribute`, the first + * that changes it). The three frames are the one that styles, lays out and + * paints the change, the one that paints its animations' layers, and a clean + * one. A rendering proof counts per-frame work from there, not from a fixed + * delay a loaded machine stretches. + */ +async function noteSettle(page: Page, attribute?: string) { + await page.evaluate(attribute => { + const w = window as unknown as {__tabBarSettledAt?: number}; + w.__tabBarSettledAt = 0; + const observer = new MutationObserver(records => { + if ( + attribute != null && + !records.some(record => record.attributeName === attribute) + ) { + return; + } + observer.disconnect(); + let frames = 3; + const next = () => { + frames -= 1; + if (frames === 0) { + w.__tabBarSettledAt = performance.timeOrigin + performance.now(); + return; + } + requestAnimationFrame(next); + }; + requestAnimationFrame(next); + }); + observer.observe(document.documentElement, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + }, attribute ?? null); +} + +/** When `noteSettle` noted the render, epoch ms on the page's clock (0: not yet). */ +const settledAt = (page: Page) => + page.evaluate( + () => + (window as unknown as {__tabBarSettledAt?: number}).__tabBarSettledAt ?? + 0, + ); + +test('a press, its lift, flex and travel, does no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {driver} = await openBar(page); + const capsule = page.getByRole('tablist'); + const target = await itemCenter(page, 'Profile'); + let press = {from: 0, to: 0}; + // Held for 700 ms: the lens travels, lifts and the bar flexes with no render + // at all. Counted from three frames after the press's own DOM change (its + // render, then the layers its new animations take), not from a fixed delay. + // The count runs until the finger lifts, by the page's own lift time. Nothing + // renders in between, the curves' ends included. + await noteSettle(page); + const trace = await traceRendering(page, async () => { + const playing = driver.play(tapScript(target, {holdMs: 700})); + [press] = await waitForAnimation(capsule); + await playing; + await waitForRest(capsule); + }); + const from = await settledAt(page); + expect(from, 'the press changed no DOM').toBeGreaterThan(0); + const to = await page.evaluate( + () => + performance.timeOrigin + + ((window as unknown as {__tabBarLifted?: number}).__tabBarLifted ?? 0), + ); + expect(to - from, 'counted window, ms').toBeGreaterThan(100); + const during = countBetween(trace, from, to); + const when = trace.events + .filter(e => e.name === 'Layout' || e.name === 'Paint') + .map(e => `${e.name}@${Math.round(e.t - press.from)}`); + expect(during.Layout ?? 0, JSON.stringify({during, when})).toBe(0); + expect(during.Paint ?? 0, JSON.stringify({during, when})).toBe(0); + await expect(tab(page, 'Profile')).toHaveAttribute('aria-selected', 'true'); +}); + +/** The document's timeline now, to read only the animations a change starts. */ +const timelineNow = (page: Page) => + page.evaluate(() => Number(document.timeline.currentTime ?? 0)); + +/** + * Every animation running in the document (CSS transitions included) that + * started at or after `since`, as the reduced-motion checks read it. For each: + * the properties its keyframes interpolate, and when its curve reaches its end + * value (the animator holds the end after it). A value that only jumps, at one + * offset, moves nothing on screen. The animator's own are read from the page's + * log too, so one that ended before the read (a cross-fade is short) is still + * read. + */ +async function runningAnimations(page: Page, since = 0) { + return page.evaluate(since => { + const meta = ['offset', 'computedOffset', 'easing', 'composite']; + const made = ( + ( + window as unknown as { + __tabBarMade?: {animation: Animation; at: number}[]; + } + ).__tabBarMade ?? [] + ) + .filter(entry => entry.at >= since - 50) + .map(entry => entry.animation); + const running = document + .getAnimations() + .filter( + animation => + animation.startTime == null || + Number(animation.startTime) >= since - 50, + ); + return [...new Set([...running, ...made])].map(animation => { + const effect = animation.effect as KeyframeEffect; + const frames = effect.getKeyframes(); + const keys = [ + ...new Set(frames.flatMap(frame => Object.keys(frame))), + ].filter(key => !meta.includes(key)); + const value = (frame: ComputedKeyframe, key: string) => + String(frame[key]); + const interpolated = keys.filter(key => + frames.some( + (frame, index) => + index > 0 && + frame.computedOffset !== frames[index - 1].computedOffset && + value(frame, key) !== value(frames[index - 1], key), + ), + ); + const last = frames[frames.length - 1]; + let end = frames.length - 1; + while ( + end > 0 && + keys.every(key => value(frames[end - 1], key) === value(last, key)) + ) { + end -= 1; + } + const timing = effect.getTiming(); + return { + properties: interpolated, + endsMs: + Number(timing.delay ?? 0) + + (frames[end]?.computedOffset ?? 0) * Number(timing.duration), + }; + }); + }, since); +} + +/** + * design:reduced-motion DR2, on a touch screen: the change cross-fades, every + * animation it starts changes opacity only and ends within 200 ms. + */ +async function expectCrossFade(page: Page, since = 0) { + await expect + .poll( + async () => + (await runningAnimations(page, since)).some( + animation => + animation.properties.includes('opacity') && animation.endsMs > 0, + ), + {message: 'no cross-fade', timeout: 2000}, + ) + .toBe(true); + assertCrossFade(await runningAnimations(page, since)); +} + +/** design:reduced-motion DR2 on animations already read. */ +function assertCrossFade( + animations: Awaited>, +) { + expect( + animations.some( + animation => + animation.properties.includes('opacity') && animation.endsMs > 0, + ), + `no cross-fade: ${JSON.stringify(animations)}`, + ).toBe(true); + assertOpacityOnly(animations); +} + +/** + * design:reduced-motion DR2 on animations already read, the fade itself not + * required: every one changes opacity only and ends within 200 ms. + */ +function assertOpacityOnly( + animations: Awaited>, +) { + for (const animation of animations) { + expect( + animation.properties.filter(property => property !== 'opacity'), + JSON.stringify(animations), + ).toEqual([]); + expect(animation.endsMs, JSON.stringify(animations)).toBeLessThanOrEqual( + 200, + ); + } +} + +/** design:reduced-motion DR3, under a mouse: nothing animates. */ +async function expectNoMotion(page: Page, since = 0) { + await page.waitForTimeout(150); + const animations = await runningAnimations(page, since); + expect( + animations.filter(animation => animation.properties.length > 0), + JSON.stringify(animations), + ).toEqual([]); +} + +/** A tab's label: its weight tells the selected look from the plain one. */ +const labelWeight = (page: Page, name: string) => + tab(page, name) + .locator(':scope > span > span') + .nth(1) + .evaluate(element => getComputedStyle(element).fontWeight); + +test('at rest the selected tab draws its own look; the copy of the items rides in the lens only while it slides', async ({ + page, +}) => { + const {driver} = await openBar(page); + // At rest each item is drawn once, the selected one in its selected look. + await expect(lensCopy(page)).toHaveCount(0); + expect(await labelWeight(page, 'Home')).toBe('600'); + expect(await labelWeight(page, 'Browse')).not.toBe('600'); + await driver.tap(await itemCenter(page, 'Browse')); + // While the lens slides, the selected look rides inside it… + await expect(lensCopy(page)).toHaveCount(1); + expect(await labelWeight(page, 'Browse')).not.toBe('600'); + // …and once it rests the copy goes and the tab draws it again. + await waitForRest(lensBody(page)); + await expect(lensCopy(page)).toHaveCount(0); + expect(await labelWeight(page, 'Browse')).toBe('600'); + expect(await labelWeight(page, 'Home')).not.toBe('600'); +}); + +test.describe('with Reduce Motion (design:reduced-motion)', () => { + test('on a touch screen a tap cross-fades the lens: opacity only, within 200 ms', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver} = await openBar(page); + const to = await itemCenter(page, 'Browse'); + const since = await timelineNow(page); + await driver.tap(to); + // The fade starts with the touch; the selection follows the lift a few + // frames later. + await expectCrossFade(page, since); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + assertOpacityOnly(await runningAnimations(page, since)); + await waitForRest(lensBody(page)); + expect(Math.abs((await lensCenterX(page)) - to.x)).toBeLessThan(0.5); + }); + + test('a press neither lifts nor flexes, and a drag’s release cross-fades the settle', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'only Chromium’s touches can be read mid-script', + ); + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver} = await openBar(page); + const home = await itemCenter(page, 'Home'); + const library = await itemCenter(page, 'Library'); + let held: unknown = null; + await driver.play(tapScript(home, {holdMs: 200}), { + onEvent: async step => { + if (step.type === 'touchStart') { + await page.waitForTimeout(120); + held = { + lens: await scaleOf(lensBody(page)), + bar: await scaleOf(page.getByRole('tablist')), + }; + } + }, + }); + expect(held).toEqual({lens: {x: 1, y: 1}, bar: {x: 1, y: 1}}); + // The lens follows the finger through the drag (direct manipulation); the + // settle its release starts is a transition, so it cross-fades (DR2). + await driver.play(drag(home, library, {durationMs: 400, restMs: 300})); + // Since the page's own lift, not a read the test makes after it. + const released = await liftedAt(page); + expect(released, 'no lift reached the page').toBeGreaterThan(0); + // The fade starts with the input; what it changes follows a few + // frames later. + await expectCrossFade(page, released); + await expect(tab(page, 'Library')).toHaveAttribute('aria-selected', 'true'); + assertOpacityOnly(await runningAnimations(page, released)); + }); +}); + +/** The lens's CSS slide under a mouse: its duration and running transitions. */ +const cssSlide = async (page: Page) => + page.locator('[data-tab-bar-lens]').evaluate(element => ({ + duration: getComputedStyle(element).transitionDuration, + transitions: element + .getAnimations() + .filter(animation => animation instanceof CSSTransition).length, + })); + +/** + * Counts, on the page from now, the CSS transitions the lens starts (one + * transitionrun per property), for `expectCssSlide`. Call it before the + * action that slides the lens. + */ +async function watchCssSlide(page: Page) { + await page.locator('[data-tab-bar-lens]').evaluate(element => { + const w = window as unknown as {__tabBarSlides?: number}; + w.__tabBarSlides = 0; + element.addEventListener('transitionrun', event => { + if (event.target === element) { + w.__tabBarSlides = (w.__tabBarSlides ?? 0) + 1; + } + }); + }); +} + +/** + * Under a mouse the lens slides in CSS on the theme's motion, and its body, + * which the animator moves under a finger, never animates. + */ +async function expectCssSlide(page: Page, {isRunning = true} = {}) { + expect((await cssSlide(page)).duration).not.toBe('0s'); + if (isRunning) { + // Counted on the page from `watchCssSlide`, so a short slide that ends + // before this looks is still seen. + await expect + .poll(() => + page.evaluate( + () => + (window as unknown as {__tabBarSlides?: number}).__tabBarSlides ?? + 0, + ), + ) + .toBe(1); + } + expect( + await lensBody(page).evaluate(element => element.getAnimations().length), + ).toBe(0); +} + +test.describe('touch work costs nothing under a fine pointer', () => { + test.describe('under a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('a click chooses a tab, and so do the keys', async ({page}) => { + await expectFinePointer(page); + await openBar(page); + // The click's release chooses the tab under it, as a drag's release + // does. The gesture host swallows the browser's own click after it. + await tab(page, 'Library').click(); + await expect(tab(page, 'Library')).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'false'); + // And from the keyboard, the other route a fine pointer leaves. + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + await expect(tab(page, 'Browse')).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + test('a click reports its choice once, and never as a reselect too', async ({ + page, + }) => { + await expectFinePointer(page); + await openBar(page, 'removed-on-lift'); + await page.evaluate(() => { + (window as unknown as {__tabBarChanges: string[]}).__tabBarChanges = []; + }); + await tab(page, 'Library').click(); + await expect(tab(page, 'Library')).toHaveAttribute( + 'aria-selected', + 'true', + ); + // The browser's click comes after the release chose Library. Reported + // again, it would reach the newly selected tab as a reselect. + await page.waitForTimeout(300); + expect( + await page.evaluate( + () => + (window as unknown as {__tabBarChanges: string[]}).__tabBarChanges, + ), + ).toEqual(['library']); + await expect(page.getByTestId('reselects')).toHaveText( + 'Reselected 0 times', + ); + }); + + test('with Reduce Motion a click moves the lens at once', async ({ + page, + }) => { + await expectFinePointer(page); + await page.emulateMedia({reducedMotion: 'reduce'}); + await openStory(page, storybook(), 'lab-tabbar--default'); + await expect(page.getByRole('tablist', {name: 'Sections'})).toBeVisible(); + const since = await timelineNow(page); + await tab(page, 'Browse').click(); + await expect(tab(page, 'Browse')).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expectNoMotion(page, since); + }); + + test('a click draws no copy of the items', async ({page}) => { + await expectFinePointer(page); + await openStory(page, storybook(), 'lab-tabbar--default'); + await expect(page.getByRole('tablist', {name: 'Sections'})).toBeVisible(); + await tab(page, 'Browse').click(); + await expect(tab(page, 'Browse')).toHaveAttribute( + 'aria-selected', + 'true', + ); + // The selected look moves to the tab at once; the lens slides under it. + expect(await labelWeight(page, 'Browse')).toBe('600'); + await expect(lensCopy(page)).toHaveCount(0); + await page.waitForTimeout(400); + await expect(lensCopy(page)).toHaveCount(0); + }); + + test('the bar sets no selection or callout override of its own', async ({ + page, + }) => { + await expectFinePointer(page); + await openStory(page, storybook(), 'lab-tabbar--default'); + const capsule = page.getByRole('tablist', {name: 'Sections'}); + await expect(capsule).toBeVisible(); + // Under a finger the gesture host owns them while a finger is down; + // under a mouse desktop selection is unchanged. + expect( + await capsule.evaluate(element => getComputedStyle(element).userSelect), + ).not.toBe('none'); + }); + + test('a click slides the lens on the theme’s motion', async ({page}) => { + await expectFinePointer(page); + await openStory(page, storybook(), 'lab-tabbar--default'); + await expect(page.getByRole('tablist', {name: 'Sections'})).toBeVisible(); + await watchCssSlide(page); + await tab(page, 'Browse').click(); + await expect(tab(page, 'Browse')).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expectCssSlide(page); + }); + }); +}); + +test.describe('a mouse or a pen drags the lens too (spec:AST-094 FR43)', () => { + test.use({hasTouch: false, isMobile: false}); + + /** Opens the default bar under the context's fine pointer. */ + async function openFine(page: Page) { + await expectFinePointer(page); + await openStory(page, storybook(), 'lab-tabbar--default'); + await expect(page.getByRole('tablist', {name: 'Sections'})).toBeVisible(); + } + + /** + * Presses the mouse on one tab's item and moves it to another's in twelve + * steps, leaving the button down; returns where it stopped. + */ + async function mouseDragTo(page: Page, from: string, to: string) { + const start = await itemCenter(page, from); + const end = await itemCenter(page, to); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + for (let step = 1; step <= 12; step++) { + await page.mouse.move(start.x + ((end.x - start.x) * step) / 12, start.y); + } + return end; + } + + test('a mouse drag moves the lens in the mouse presentation, and the release chooses the tab under it', async ({ + page, + }) => { + await openFine(page); + const browse = await mouseDragTo(page, 'Home', 'Browse'); + // The lens follows the pointer on the finger's tracking spring, with no + // lift and no copy of the items, which stay a finger's. The selected look stays on Home + // until the release. + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - browse.x)) + .toBeLessThan(1); + await expect(lensCopy(page)).toHaveCount(0); + expect(await scaleOf(lensBody(page))).toEqual({x: 1, y: 1}); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + expect(await labelWeight(page, 'Home')).toBe('600'); + await page.mouse.up(); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + expect(await labelWeight(page, 'Browse')).toBe('600'); + await expect(lensCopy(page)).toHaveCount(0); + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - browse.x)) + .toBeLessThan(0.5); + }); + + test('a mouse drag’s release settles the lens on a spring that carries on with the drag’s own speed', async ({ + page, + }) => { + await expectFinePointer(page); + await installPageFreeze(page); + await openStory(page, storybook(), 'lab-tabbar--default'); + await expect(page.getByRole('tablist', {name: 'Sections'})).toBeVisible(); + await installTimerGate(page); + const home = await itemCenter(page, 'Home'); + const browse = await itemCenter(page, 'Browse'); + // The scene's own clock times the drag: frozen from the press, it moves + // on 10 ms per move. So the release speed is the script's, however late + // the test process is. The drag ends past Browse, still nearest it. + await page.mouse.move(home.x, home.y); + await page.mouse.down(); + await freezePage(page); + const stop = browse.x + 40; + for (let step = 1; step <= 12; step++) { + await advanceFrozenPage(page, 10); + await page.mouse.move(home.x + ((stop - home.x) * step) / 12, home.y); + } + await advanceFrozenPage(page, 10); + const atRelease = await lensCenterX(page); + await page.mouse.up(); + // The settle carries on rightward, past Browse, before it comes back, as + // the drag's speed carries it. A transition onto Browse would never pass + // it, and would turn back at once from past it. + let furthest = atRelease; + for (let frame = 0; frame < 20; frame++) { + await advanceFrozenPage(page, 8); + furthest = Math.max(furthest, await lensCenterX(page)); + } + await thawPage(page); + expect(furthest - atRelease).toBeGreaterThan(0.5); + expect(furthest).toBeGreaterThan(browse.x + 0.5); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - browse.x)) + .toBeLessThan(0.5); + }); + + test('after a mouse slide, focus does what a click on the chosen tab does: it never stays on the tab the press left (family:action-groups FR1)', async ({ + page, + }) => { + await openFine(page); + await mouseDragTo(page, 'Home', 'Browse'); + await page.mouse.up(); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + // The press focused Home, as a click does in both engines here. After the + // slide the chosen tab has the focus, never Home, which has left the tab + // order. + await expect(tab(page, 'Browse')).toBeFocused(); + await expect(tab(page, 'Home')).not.toBeFocused(); + }); + + test('a mouse press dragged off the bar shows the selected tab again, since its release will choose nothing', async ({ + page, + }) => { + await openFine(page); + // On screen first: the lens. + await expectPainted(lensBody(page)); + const home = await itemCenter(page, 'Home'); + const browse = await mouseDragTo(page, 'Home', 'Browse'); + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - browse.x)) + .toBeLessThan(1); + // Up off the bar, the button still down. + for (let step = 1; step <= 8; step++) { + await page.mouse.move(browse.x, browse.y - 15 * step); + } + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - home.x)) + .toBeLessThan(1); + await page.mouse.up(); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + }); + + test('a mouse press released off the bar chooses nothing, a tab or a reselect: slide-off cancels (family:action-groups FR3)', async ({ + page, + }) => { + await openFine(page); + const slideOff = async (name: string) => { + const start = await itemCenter(page, name); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + for (let step = 1; step <= 8; step++) { + await page.mouse.move(start.x, start.y - 15 * step); + } + await page.mouse.up(); + await page.waitForTimeout(300); + }; + await slideOff('Library'); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + await slideOff('Home'); + await expect(page.getByTestId('reselects')).toHaveText( + 'Reselected 0 times', + ); + // The lens is back on the selected tab. + const home = await itemCenter(page, 'Home'); + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - home.x)) + .toBeLessThan(0.5); + }); + + test('under a fine primary pointer the capsule keeps its own touch-action, for a touchscreen laptop’s finger (spec:AST-094 FR18)', async ({ + page, + }) => { + await openFine(page); + // TabBar's capsule style gives it a long press's touch-action + // (manipulation) whatever the primary pointer. So a finger's drag on a + // touchscreen laptop goes to the lens, not to the browser's panning. + expect( + await page + .getByRole('tablist', {name: 'Sections'}) + .evaluate(element => getComputedStyle(element).touchAction), + ).toBe('manipulation'); + }); + + test('a pen drag chooses the tab under its release', async ({page}) => { + await openFine(page); + const input = await webInputDriver(page); + test.skip(!input.pens, PEN_SKIP); + const start = await itemCenter(page, 'Home'); + const end = await itemCenter(page, 'Profile'); + await input.pen( + Array.from({length: 13}, (_, step) => ({ + x: start.x + ((end.x - start.x) * step) / 12, + y: start.y, + t: step * 16, + })), + ); + await expect(tab(page, 'Profile')).toHaveAttribute('aria-selected', 'true'); + await expect(lensCopy(page)).toHaveCount(0); + }); + + test('a pen released off the bar chooses nothing: slide-off cancels', async ({ + page, + }) => { + await openFine(page); + const input = await webInputDriver(page); + test.skip(!input.pens, PEN_SKIP); + const start = await itemCenter(page, 'Library'); + await input.pen( + Array.from({length: 9}, (_, step) => ({ + x: start.x, + y: start.y - 15 * step, + t: step * 16, + })), + ); + await page.waitForTimeout(300); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + }); + + test('under a mouse the bar listens on its own element, and on the page only while a press is down', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'listener counts read CDP (Chromium)', + ); + await openFine(page); + const cdp = await page.context().newCDPSession(page); + /** Each listener as its type and its capture and passive flags. */ + const listeners = async (expression: string) => { + const {result} = await cdp.send('Runtime.evaluate', {expression}); + const {listeners: found} = await cdp.send( + 'DOMDebugger.getEventListeners', + {objectId: result.objectId ?? ''}, + ); + return found + .map( + listener => + `${listener.type}${listener.useCapture ? '/capture' : ''}${listener.passive ? '/passive' : ''}`, + ) + .sort(); + }; + const pageLevel = async () => [ + ...(await listeners('document')).map(entry => `document:${entry}`), + ...(await listeners('window')).map(entry => `window:${entry}`), + ]; + /** What `after` adds to `before` and what it drops, counting repeats. */ + const delta = (before: string[], after: string[]) => { + const added = [...after]; + const removed: string[] = []; + for (const entry of before) { + const index = added.indexOf(entry); + if (index < 0) { + removed.push(entry); + } else { + added.splice(index, 1); + } + } + return {added, removed}; + }; + const capsule = 'document.querySelector(\'[role="tablist"]\')'; + // On the bar itself: the passive pointerdown a press starts from, and the + // claiming recognizer's touchmove. The listener budget counts that + // touchmove apart. + expect(await listeners(capsule)).toEqual([ + 'pointerdown/capture/passive', + 'touchmove', + ]); + // openStory returns while Storybook is still completing the story's render. + // Its own decorators' effects (the measure addon's document pointermove + // among them) run when the render finishes, so the baseline waits for that. + await page.waitForFunction( + () => + ( + window as unknown as { + __STORYBOOK_PREVIEW__?: {currentRender?: {phase?: string}}; + } + ).__STORYBOOK_PREVIEW__?.currentRender?.phase === 'finished', + ); + // The tabs are read first: Playwright's own locator reads add its + // listeners to the window, which are the harness's, not the page's. + const library = await itemCenter(page, 'Library'); + const browse = await itemCenter(page, 'Browse'); + const atRest = await pageLevel(); + const press = async ({x, y}: {x: number; y: number}) => { + await page.mouse.move(x, y); + await page.mouse.down(); + const during = await pageLevel(); + await page.mouse.up(); + return during; + }; + // While a press is down the gesture host listens on the page, and the + // release removes all of it. A click guard only the gesture host armed goes + // when it disarms, so the page is back to its listeners at rest. + expect(delta(atRest, await press(library)).added).not.toEqual([]); + await expect + .poll(async () => delta(atRest, await pageLevel())) + .toEqual({added: [], removed: []}); + // A second press leaves nothing more behind. + const afterFirst = await pageLevel(); + expect(delta(afterFirst, await press(browse)).added).not.toEqual([]); + await expect.poll(pageLevel).toEqual(afterFirst); + }); + + test( + 'storyboard: a mouse drag carries the lens on the theme’s motion, and the release chooses', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + await openFine(page); + const storyboard = new Storyboard(page, { + name: 'tabbar-mouse-drag', + clip: page.getByRole('tablist', {name: 'Sections'}), + columns: 2, + cellScale: 1, + }); + const start = await itemCenter(page, 'Home'); + const end = await itemCenter(page, 'Browse'); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + // Each label says where the lens's centre is, from Home's. + const lensAt = async () => + `lens ${Math.round((await lensCenterX(page)) - start.x)} px`; + await storyboard.capture(`pressed on Home, ${await lensAt()}`, 0); + for (let step = 1; step <= 12; step++) { + await page.mouse.move( + start.x + ((end.x - start.x) * step) / 12, + start.y, + ); + if (step % 4 === 0) { + await storyboard.capture( + `dragging ${step}/12, ${await lensAt()}`, + step * 16, + ); + } + } + await page.waitForTimeout(300); + await storyboard.capture(`held over Browse, ${await lensAt()}`, 500); + await page.mouse.up(); + for (const after of [40, 120, 400]) { + await page.waitForTimeout(after === 40 ? 40 : after - 40); + await storyboard.capture( + `released, +${after} ms, ${await lensAt()}`, + 500 + after, + ); + } + await storyboard.write(testInfo); + await expect(tab(page, 'Browse')).toHaveAttribute( + 'aria-selected', + 'true', + ); + }, + ); + + test('with Reduce Motion a mouse drag moves the lens at once', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await openFine(page); + const since = await timelineNow(page); + const browse = await mouseDragTo(page, 'Home', 'Browse'); + expect(Math.abs((await lensCenterX(page)) - browse.x)).toBeLessThan(1); + await page.mouse.up(); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + await expectNoMotion(page, since); + }); +}); + +test( + 'storyboard: a selection from rest slides the lens (a touch first moves it under the finger: see the press storyboard)', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {bar} = await openBar(page); + await tab(page, 'Home').focus(); + for (let i = 0; i < 3; i += 1) { + await page.keyboard.press('ArrowRight'); + } + await page.keyboard.press('Enter'); + await expect(tab(page, 'Profile')).toHaveAttribute('aria-selected', 'true'); + await waitForAnimation(lensBody(page)); + const box = await bar.boundingBox(); + if (box == null) { + throw new Error('no bar'); + } + const {storyboard} = await animationStoryboard(page, { + name: 'tabbar-lens-travel', + clip: { + x: 0, + y: box.y - 12, + width: box.x * 2 + box.width, + height: box.height + 24, + }, + times: Array.from({length: 37}, (_, i) => i * 16.667), + measure: async () => ({x: await translateX(lensBody(page))}), + columns: 6, + cellScale: 0.6, + }); + await storyboard.write(testInfo); + await releaseAnimations(page, {finish: true}); + const xs = storyboard.frames.map(f => (f.data as {x: number}).x); + expect(xs[0]).toBeLessThan(-200); + expect(Math.abs(xs.at(-1) ?? Infinity)).toBeLessThan(2); + }, +); + +test( + 'storyboard: a finger lands, drags across the bar and lifts', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, bar} = await openBar(page); + const home = await itemCenter(page, 'Home'); + const browse = await itemCenter(page, 'Browse'); + const box = await bar.boundingBox(); + if (box == null) { + throw new Error('no bar'); + } + const storyboard = await touchStoryboard( + page, + driver, + drag(home, browse, {durationMs: 400, hz: 20, holdMs: 250, restMs: 250}), + { + name: 'tabbar-press-drag', + clip: { + x: 0, + y: box.y - 16, + width: box.x * 2 + box.width, + height: box.height + 32, + }, + measure: async () => ({ + x: await translateX(lensBody(page)), + lift: (await scaleOf(lensBody(page))).x, + flex: (await scaleOf(bar)).x, + }), + columns: 5, + cellScale: 0.6, + }, + ); + await storyboard.write(testInfo); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + const frames = storyboard.frames.map( + f => f.data as {lift: number; flex: number}, + ); + // Lifted and flexed while the finger is down. + expect(Math.max(...frames.map(f => f.lift))).toBeGreaterThan(1.1); + expect(Math.max(...frames.map(f => f.flex))).toBeGreaterThan(1.03); + }, +); diff --git a/packages/lab/src/TabBar/index.ts b/packages/lab/src/TabBar/index.ts new file mode 100644 index 0000000000000..7441666a5ee7f --- /dev/null +++ b/packages/lab/src/TabBar/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file index.ts + * @input Imports from TabBar.tsx + * @output Re-exports TabBar, TabBarItem and their types + * @position Barrel export; consumed by packages/lab/src/index.ts + */ + +export {TabBar, TabBarItem} from './TabBar'; +export type {TabBarItemProps, TabBarProps} from './TabBar'; diff --git a/packages/lab/src/TabBar/tabBar.stylex.ts b/packages/lab/src/TabBar/tabBar.stylex.ts new file mode 100644 index 0000000000000..eb4b35236f428 --- /dev/null +++ b/packages/lab/src/TabBar/tabBar.stylex.ts @@ -0,0 +1,18 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file tabBar.stylex.ts + * @input StyleX marker system + * @output tabBarItemMarker — a scoped marker on each TabBar item button + * @position Shared StyleX marker consumed by TabBar.tsx + * + * The item buttons span the whole capsule between their neighbours, so a press + * anywhere goes to the nearest item, as on iOS. The focus ring belongs on the + * 76 × 54 item inside. The ring keys off + * `stylex.when.ancestor(':focus-visible', tabBarItemMarker)`. + */ + +import * as stylex from '@stylexjs/stylex'; + +export const tabBarItemMarker: ReturnType = + stylex.defineMarker(); diff --git a/packages/lab/src/TabBar/tabBarModel.test.ts b/packages/lab/src/TabBar/tabBarModel.test.ts new file mode 100644 index 0000000000000..9cbcbd7f2c3f4 --- /dev/null +++ b/packages/lab/src/TabBar/tabBarModel.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file tabBarModel.test.ts + * @input vitest and tabBarModel.ts + * @output Checks TabBar's geometry against UIKit's layouts measured on iOS + * 26.2: the capsule's width, the items' width and centres, and right to + * left. Also the item a lift commits, the rubber band, the lifted lens and + * the copy's counter-transform. + * @position Testing; pure math, no DOM + */ + +import {describe, expect, it} from 'vitest'; +import {tabBar} from './uikitTabBar'; +import { + copyTransform, + itemCenters, + itemWidth, + itemWidthCss, + lensTarget, + liftScale, + naturalWidth, + nearestIndex, +} from './tabBarModel'; + +/** + * UIKit's layouts: capsule width, then the items' screen x and width (the + * capsule's own x subtracted below). + */ +const UIKIT_LAYOUTS = [ + {label: '2 tabs', capsuleX: 107, capsule: 188, width: 94, xs: [111, 197]}, + {label: '3 tabs', capsuleX: 64, capsule: 274, width: 94, xs: [68, 154, 240]}, + { + label: '4 tabs', + capsuleX: 21, + capsule: 360, + width: 98, + xs: [25, 109.67, 194.33, 279], + }, + { + label: '5 tabs', + capsuleX: 21, + capsule: 360, + width: 77, + xs: [25, 93.75, 162.5, 231.25, 300], + }, + { + label: '3 tabs and search', + capsuleX: 21, + capsule: 290, + width: 100.67, + xs: [25, 115.67, 206.33], + }, + { + label: '4 tabs and search', + capsuleX: 21, + capsule: 290, + width: 76, + xs: [25, 93.67, 162.33, 231], + }, +]; + +describe('naturalWidth', () => { + it('is UIKit’s capsule for two and three tabs, and reaches the 360 pt screen with four', () => { + expect(naturalWidth(2)).toBe(188); + expect(naturalWidth(3)).toBe(274); + expect(naturalWidth(4)).toBe(360); + }); +}); + +describe('itemWidth and itemCenters', () => { + for (const layout of UIKIT_LAYOUTS) { + it(`match UIKit within a point: ${layout.label}`, () => { + const n = layout.xs.length; + expect( + Math.abs(itemWidth(layout.capsule, n) - layout.width), + ).toBeLessThan(1); + const centers = itemCenters(layout.capsule, n); + centers.forEach((center, i) => { + const uikit = layout.xs[i] - layout.capsuleX + layout.width / 2; + expect(Math.abs(center - uikit)).toBeLessThan(1); + }); + }); + } + + it('keeps the first and last items one inset from the edges, the rest evenly between', () => { + const centers = itemCenters(360, 5); + const size = itemWidth(360, 5); + expect(centers[0]).toBeCloseTo(4 + size / 2, 9); + expect(centers[4]).toBeCloseTo(360 - 4 - size / 2, 9); + const steps = centers.slice(1).map((center, i) => center - centers[i]); + for (const step of steps) { + expect(step).toBeCloseTo(steps[0], 9); + } + }); + + it('mirrors in a right-to-left capsule (the first item on the right)', () => { + expect(itemCenters(290, 4, true)).toEqual( + itemCenters(290, 4).map(x => 290 - x), + ); + }); + + it('centres a single item and has nothing for none', () => { + expect(itemCenters(200, 1)).toEqual([100]); + expect(itemCenters(200, 0)).toEqual([]); + }); + + it('spells the same width in CSS', () => { + expect(itemWidthCss(4)).toBe( + 'min(1.0659 * 100cqw / 4 + 0.0436 * 100cqw - 14.61px, 100cqw - 8px)', + ); + }); +}); + +describe('nearestIndex: the item a lift commits', () => { + const centers = itemCenters(290, 4); + + it('picks the nearest centre', () => { + expect(nearestIndex(centers[2] + 20, centers)).toBe(2); + expect(nearestIndex(centers[1] - 30, centers)).toBe(1); + }); + + it('gives a tie to the leading (earlier) item, as UIKit did between items 2 and 3', () => { + const midpoint = (centers[1] + centers[2]) / 2; + expect(nearestIndex(midpoint, centers)).toBe(1); + // In a right-to-left capsule the earlier item is on the right. + const rtl = itemCenters(290, 4, true); + expect(nearestIndex((rtl[1] + rtl[2]) / 2, rtl)).toBe(1); + }); + + it('commits the last item for a finger 60 px past it, and the first before it', () => { + expect(nearestIndex(centers[3] + 60, centers)).toBe(3); + expect(nearestIndex(-40, centers)).toBe(0); + }); +}); + +describe('lensTarget', () => { + const centers = itemCenters(290, 4); + + it('follows the finger between the first and last centres', () => { + expect(lensTarget(150, centers)).toBe(150); + }); + + it('rubber-bands past the ends: 60 px past the last item leaves the lens ~8 px past it', () => { + expect(lensTarget(centers[3] + 60, centers) - centers[3]).toBeCloseTo( + 7.98, + 1, + ); + expect(lensTarget(centers[0] - 60, centers) - centers[0]).toBeCloseTo( + -7.98, + 1, + ); + }); + + it('never passes the rubber band’s dimension however far the finger goes', () => { + expect(lensTarget(centers[3] + 10_000, centers) - centers[3]).toBeLessThan( + tabBar.lensEdgeDimension, + ); + }); +}); + +describe('liftScale', () => { + it('grows a 76 × 54 lens to 92 × 70, and a 98 px one to 114 px', () => { + const lifted = liftScale(76); + expect(76 * lifted.x).toBeCloseTo(92, 6); + expect(tabBar.itemHeight * lifted.y).toBeCloseTo(70, 6); + expect(98 * liftScale(98).x).toBeCloseTo(114, 6); + }); +}); + +describe('copyTransform', () => { + it('undoes the lens’s translate and scale about the same origin', () => { + // A point p in the copy maps to the lens as O + T_c + S_c (p − O). It then + // maps to the capsule as O + T + S (q − O), and the composition must be p. + const origin = {x: 38, y: 27}; + const lifted = liftScale(76); + for (const [x, sx, sy] of [ + [0, 1, 1], + [42.5, 1, 1], + [-80, lifted.x, lifted.y], + [13.25, 1.31, 0.87], + ]) { + const copy = copyTransform(x, sx, sy); + for (const p of [ + {x: 0, y: 0}, + {x: 120, y: 31}, + {x: -55.5, y: 9}, + ]) { + const q = { + x: origin.x + copy.x + copy.scaleX * (p.x - origin.x), + y: origin.y + copy.scaleY * (p.y - origin.y), + }; + const onScreen = { + x: origin.x + x + sx * (q.x - origin.x), + y: origin.y + sy * (q.y - origin.y), + }; + expect(onScreen.x).toBeCloseTo(p.x, 9); + expect(onScreen.y).toBeCloseTo(p.y, 9); + } + } + }); +}); diff --git a/packages/lab/src/TabBar/tabBarModel.ts b/packages/lab/src/TabBar/tabBarModel.ts new file mode 100644 index 0000000000000..1ab86f537798f --- /dev/null +++ b/packages/lab/src/TabBar/tabBarModel.ts @@ -0,0 +1,136 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file tabBarModel.ts + * @input TabBar's UIKit values (uikitTabBar.ts) + * @output TabBar's pure geometry: the items' widths and centres, the item a + * lift commits, and the lens's target and scale. + * @position Math of the lab TabBar; no DOM, no React. Coordinates are the + * capsule's own, in px, before its flex scale. x runs from its left edge, + * and items go in logical order. + * + * SYNC: When modified, update tabBarModel.test.ts, TabBar.tsx (its CSS + * spelling of the same geometry) and uikitTabBar.ts. + */ + +import {rubberBand} from '@astryxdesign/core/internal/touch'; +import {tabBar} from './uikitTabBar'; + +/** The widest a capsule of `count` tabs gets without a search tab, px. */ +export function naturalWidth(count: number): number { + return tabBar.naturalWidth.base + tabBar.naturalWidth.perTab * count; +} + +/** + * The width every item (and the resting lens) has in a capsule `width` px + * wide, never wider than the capsule's inside. + */ +export function itemWidth(width: number, count: number): number { + const {perSlot, perBar, offset} = tabBar.itemWidth; + return Math.min( + (perSlot * width) / Math.max(count, 1) + perBar * width + offset, + width - 2 * tabBar.itemInset, + ); +} + +/** + * The same width as a CSS expression of the capsule's width, `100cqw` (the + * capsule is the size container), for use inside `calc()`. + */ +export function itemWidthCss(count: number): string { + const {perSlot, perBar, offset} = tabBar.itemWidth; + const sign = offset < 0 ? '-' : '+'; + const inside = `100cqw - ${2 * tabBar.itemInset}px`; + return `min(${perSlot} * 100cqw / ${Math.max(count, 1)} + ${perBar} * 100cqw ${sign} ${Math.abs(offset)}px, ${inside})`; +} + +/** + * The centres of `count` items in a capsule `width` px wide, in logical order. + * The first and last items sit `itemInset` from the edges and the others are + * spread evenly between them. In a right-to-left capsule the first item is on + * the right. + */ +export function itemCenters( + width: number, + count: number, + isRtl = false, +): number[] { + if (count <= 0) { + return []; + } + const size = itemWidth(width, count); + const first = tabBar.itemInset + size / 2; + const centers = + count === 1 + ? [width / 2] + : Array.from( + {length: count}, + (_, i) => first + ((width - 2 * first) * i) / (count - 1), + ); + return isRtl ? centers.map(center => width - center) : centers; +} + +/** + * The item a finger lifted at `x` commits: the one whose centre is nearest, + * the earlier one on a tie. No projection: a flick commits where it lifts. + */ +export function nearestIndex( + x: number, + centers: ReadonlyArray, +): number { + let best = 0; + let bestDistance = Infinity; + centers.forEach((center, index) => { + const distance = Math.abs(x - center); + // Strictly nearer only: a tie keeps the earlier (leading) item. + if (distance < bestDistance - 1e-9) { + best = index; + bestDistance = distance; + } + }); + return best; +} + +/** + * Where the lens heads for a finger at `x`: the finger itself between the + * first and last centres, rubber-banded past them. + */ +export function lensTarget(x: number, centers: ReadonlyArray): number { + if (centers.length === 0) { + return x; + } + const min = Math.min(...centers); + const max = Math.max(...centers); + if (x < min) { + return min + rubberBand(x - min, tabBar.lensEdgeDimension); + } + if (x > max) { + return max + rubberBand(x - max, tabBar.lensEdgeDimension); + } + return x; +} + +/** + * The lifted lens's scale on each axis for items `width` px wide: the item + * grown by `liftOutset` on every side. + */ +export function liftScale(width: number): {x: number; y: number} { + return { + x: (width + 2 * tabBar.liftOutset) / width, + y: (tabBar.itemHeight + 2 * tabBar.liftOutset) / tabBar.itemHeight, + }; +} + +/** + * The selected copy of the items lives inside the lens, which moves by `x` and + * scales by (`scaleX`, `scaleY`) about its centre. Applied to the copy about + * the same point, this transform puts the copy back where the items are. So the + * lens shows the selected look of whatever is under it. + */ +export function copyTransform( + x: number, + scaleX: number, + scaleY: number, +): {x: number; scaleX: number; scaleY: number} { + return {x: -x / scaleX, scaleX: 1 / scaleX, scaleY: 1 / scaleY}; +} diff --git a/packages/lab/src/TabBar/uikitTabBar.test.ts b/packages/lab/src/TabBar/uikitTabBar.test.ts new file mode 100644 index 0000000000000..b86e77c27f0f0 --- /dev/null +++ b/packages/lab/src/TabBar/uikitTabBar.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file uikitTabBar.test.ts + * @input vitest, uikitTabBar.ts and its source text + * @output Checks that every value of TabBar's UIKit table names an allowed + * source. A measurement names its iOS version and date, and a guess names + * an open measurement task. No value ships its source. + * @position Testing; validates uikitTabBar.ts without a browser, by the rules + * Core's UIKit table follows + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; +import {tabBar} from './uikitTabBar'; + +const FILE = 'uikitTabBar.ts'; +const TEXT = fs.readFileSync(path.resolve(__dirname, FILE), 'utf8'); +// Where a number comes from is documentation: a value that carried it would +// ship it in every bundle that reads the value. +const PROVENANCE_KEYS = new Set(['source', 'guess', 'measure']); +/** A measurement names its subject, the iOS version it ran on and its date. */ +const MEASURED = + /^@source measured: .*iOS \d.*(\b20\d\d-\d\d-\d\d\b|\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* \d{1,2},? 20\d\d\b)/; +const SOURCE_KINDS = + /^@source (apple-docs https:\/\/developer\.apple\.com\/|wwdc \d{4}-\d+ |webkit Source\/|measured: .*iOS \d|GUESS — )/; + +interface Leaf { + key: string; + doc: string; +} + +/** A JSDoc line without its comment markers. */ +const bare = (line: string) => line.trim().replace(/^\/?\*+\/?\s?/, ''); + +/** + * Every leaf of the exported table with the JSDoc block right above it. A + * line ending in `{` opens a group (no source of its own); any other + * `key: value,` line is a leaf. + */ +function leaves(): Leaf[] { + const lines = TEXT.split('\n'); + const start = lines.findIndex(line => + /^export const tabBar = \{$/.test(line), + ); + expect(start, 'the table').toBeGreaterThan(-1); + const found: Leaf[] = []; + let doc = ''; + let inDoc = false; + const groupLeafCount: number[] = []; + for (const line of lines.slice(start + 1)) { + const trimmed = line.trim(); + if (trimmed.startsWith('/**')) { + inDoc = true; + doc = ''; + } + if (inDoc) { + doc += bare(trimmed) + '\n'; + if (trimmed.endsWith('*/')) { + inDoc = false; + } + continue; + } + if (trimmed === '' || trimmed.startsWith('//')) { + continue; + } + if (/^(\w+): \{$/.test(trimmed)) { + groupLeafCount.push(0); + doc = ''; + continue; + } + if (/^\},?$/.test(trimmed)) { + expect(groupLeafCount.pop(), 'an empty group').toBeGreaterThan(0); + continue; + } + if (trimmed.startsWith('} as const')) { + break; + } + const leaf = /^(\w+): .+,$/.exec(trimmed); + expect(leaf, `unexpected line ${JSON.stringify(trimmed)}`).not.toBeNull(); + if (leaf) { + found.push({key: leaf[1], doc}); + groupLeafCount.forEach((count, index) => { + groupLeafCount[index] = count + 1; + }); + } + doc = ''; + } + return found; +} + +/** The open measurement tasks the file lists above the table. */ +const OPEN = new Set( + TEXT.slice(0, TEXT.indexOf('export const tabBar')) + .split('\n') + .map(line => /^\s*\*\s+- (M-[A-Z0-9]+),/.exec(line)?.[1]) + .filter((id): id is string => id != null), +); + +/** The JSDoc rules every sourced value follows. */ +function expectSourced(key: string, doc: string) { + // Each tag with its continuation lines folded onto it. + const tags = doc + .split(/\n(?=@)/) + .map(tag => tag.replace(/\s*\n\s*/g, ' ').trim()) + .filter(tag => tag.startsWith('@')); + const sources = tags.filter(tag => tag.startsWith('@source ')); + expect(sources.length, `${key} has no @source`).toBeGreaterThan(0); + for (const source of sources) { + expect(source, `${key}: unknown source kind`).toMatch(SOURCE_KINDS); + if (source.startsWith('@source measured: ')) { + expect(source, `${key}: a measurement needs its date`).toMatch(MEASURED); + } + } + const measures = tags.filter(tag => tag.startsWith('@measure ')); + if (sources.some(source => /^@source (GUESS|webkit)/.test(source))) { + expect( + measures.length, + `${key} is unconfirmed but names no @measure task`, + ).toBeGreaterThan(0); + } + for (const measure of measures) { + const id = /^@measure (M-[A-Z0-9]+)$/.exec(measure)?.[1]; + expect(id, `${key}: malformed ${measure}`).toBeDefined(); + expect( + OPEN.has(id ?? ''), + `${id} is not listed as an open measurement`, + ).toBe(true); + } +} + +describe('TabBar UIKit table: every value is sourced', () => { + const found = leaves(); + + it('finds the table entries', () => { + expect(found.length).toBeGreaterThan(20); + expect(found.map(leaf => leaf.key)).toEqual( + expect.arrayContaining(['barHeight', 'lensTravel', 'lensTrack']), + ); + expect(Object.keys(tabBar)).toEqual( + expect.arrayContaining(['barHeight', 'lensTravel', 'lensTrack']), + ); + }); + + it('lists only open measurements a value names', () => { + for (const id of OPEN) { + expect(TEXT, `${id} names no value`).toMatch( + new RegExp(`@measure ${id}\\n`), + ); + } + }); + + for (const {key, doc} of found) { + it(key, () => { + expect( + PROVENANCE_KEYS.has(key), + `${key}: a source belongs in the JSDoc, not in the value`, + ).toBe(false); + expectSourced(key, doc); + }); + } + + it('ships no source in a value', () => { + const code = TEXT.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); + expect(code).not.toMatch(/\b(source|guess|measure)\s*:/); + }); +}); diff --git a/packages/lab/src/TabBar/uikitTabBar.ts b/packages/lab/src/TabBar/uikitTabBar.ts new file mode 100644 index 0000000000000..e2eccde8daed6 --- /dev/null +++ b/packages/lab/src/TabBar/uikitTabBar.ts @@ -0,0 +1,243 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file uikitTabBar.ts + * @input Nothing; data only + * @output Exports `tabBar`: the iOS 26 floating tab bar's geometry, its + * badge, and the selection lens's springs and values. + * @position TabBar's own UIKit values, beside the component that reads them; + * read by its touch presentation and tabBarModel.ts + * + * SYNC: When modified, update tabBarModel.ts and uikitTabBar.test.ts. + */ + +/** + * Every value was read from a stock UITabBarController on the iOS 26.2 + * simulator, iPhone 17 Pro (402 × 874 pt, bottom safe area 34 pt). The + * controller held four UITabs and a UISearchTab, each a UINavigationController + * over a long table. The sources were the bar's view tree, its presentation + * frames every frame, the springs UIKit attached, and HID touches. Geometry is + * in points, in the bar's own coordinates unless a value says otherwise. + * Springs keep UIKit's terms (response in seconds, damping ratio). No value is + * a motion role: each plays as measured. + */ + +/** + * UITabBar on iOS 26 (floating, Liquid Glass). + */ +export const tabBar = { + /** + * The capsule that holds the items: 62 pt tall. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `_UITabBarPlatterView` frame 290 × 62 at (21, 791), + * Oct 23 2026 + */ + barHeight: 62, + /** + * The capsule's distance from the screen's leading and trailing edges, and + * from its bottom edge. The capsule sits 13 pt inside the 34 pt home + * indicator area. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, `_UITabBarPlatterView` at x 21 and y 791 + 62 = 853 on an 874 pt + * screen, Oct 23 2026 + */ + barInset: 21, + /** + * Without a search tab the capsule is no wider than 16 + 86 pt per tab. It is + * centred when that is narrower than the screen allows: 188 pt with two tabs, + * 274 with three. Four tabs reach the 360 pt an iPhone 17 Pro allows. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, `_UITabBarPlatterView` 188 pt at x 107 (two tabs) and 274 pt at x + * 64 (three tabs), 360 pt at x 21 (four and five tabs), Oct 24 2026 + */ + naturalWidth: {base: 16, perTab: 86}, + /** + * Every item, and the resting lens over the selected one, has one width. For + * n tabs in a capsule P pt wide it is 1.0659 × P ÷ n + 0.0436 × P − 14.61. + * That is 98 for four tabs in 360 pt, and 76 with the search tab's 290 pt. + * The first and last items sit `itemInset` from the capsule's edges and the + * others are spread evenly between them, so neighbours overlap. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, `_UITabButton` frames in eight layouts (two to five tabs, with + * and without the search tab): least-squares fit, within 1 pt of every row; + * one-letter labels give the same widths, a label wider than ~60 pt widens + * every item (not modelled), Oct 24 2026 + */ + itemWidth: {perSlot: 1.0659, perBar: 0.0436, offset: -14.61}, + /** + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `_UITabButton` 54 pt tall at y 795, Oct 23 2026 + */ + itemHeight: 54, + /** + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, items at y 795 in the capsule at y 791, the first at + * x 25 in the capsule at x 21, Oct 23 2026 + */ + itemInset: 4, + /** + * The icon's box, centred in the item 20 pt below its top. SF Symbols render + * 25 to 29 pt tall and 25 to 33 pt wide in the stock bar. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, `UIImageView` frames (33.3 × 28, 27 × 25, 33.3 × 29.3, 25.3 × + * 24.7, centres 19.7 to 20 pt below the item's top), Oct 23 2026 + */ + iconSize: 28, + /** + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, icon centres at y 814.7 to 815 for items at y 795, + * Oct 23 2026 + */ + iconCenterY: 20, + /** + * The label: 10 pt, medium; the selected look is semibold. Its 12 pt box + * starts 35 pt below the item's top. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `.SFUI-Medium 10.0` / `.SFUI-Semibold 10.0`, label + * frames 12 pt tall at y 830, Oct 23 2026 + */ + labelSize: 10, + /** + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, label frames at y 830 for items at y 795, Oct 23 2026 + */ + labelTop: 35, + + badge: { + /** + * The badge's height (a 55 ÷ 3 pt capsule), and its minimum width: a + * one-character value is a circle, "12" is 21.33 wide. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `_UIBarBadgeView` 18.33 × 18.33 for "1", "3" and "!", + * 21.33 × 18.33 for "12", corner radius 9.17, Oct 23 2026 + */ + height: 18.333, + /** + * Horizontal padding around the text (the label sits 4 pt in, 2 pt down). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, badge at x 140.67, its label at x 144.67, Oct 23 2026 + */ + paddingInline: 4, + /** + * The text: 12 pt, medium, white on the system red. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, badge label `SF Medium 12` (weight 0.23), white, fill + * systemRed (1, 0.220, 0.235) light / (1, 0.259, 0.271) dark, + * Oct 24 2026 + */ + fontSize: 12, + /** + * Where it sits: its leading edge 9 pt after the item's centre, its top + * 2 pt below the item's top (over the icon's top-trailing corner). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, badge at (140.67, 797) on the item centred at 131.67 + * with its top at 795, Oct 23 2026 + */ + offsetX: 9, + /** + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, badge top 797, item top 795, Oct 23 2026 + */ + offsetY: 2, + /** + * Setting, changing or clearing the value never animates. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `UITab.badgeValue` nil → "1" → "12" → "!" → nil at + * 1 s intervals, per-frame badge frames and opacity, Oct 23 2026 + */ + animates: false, + }, + + /** + * The lens travelling to a landing finger, and the slide of a selection set + * without a finger (keys, the app). Response 0.40 s, damping ratio 0.85, the + * same spring as the segmented control's selection. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, UIViewSpringAnimationBehavior (dampingRatio 0.85, response 0.4) + * attached at touch-down, Oct 23 2026 + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, least-squares fit of the lens's per-frame position in the bar's + * coordinates: a press travelling 137 pt (rms 0.84 pt) and three + * programmatic changes (rms 0.28 to 0.77 pt), Oct 23 2026 + */ + lensTravel: {response: 0.4, dampingRatio: 0.85}, + /** + * The lens chasing a dragging finger. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, UIViewSpringAnimationBehavior tracking response 0.15, + * damping ratio 1, and a spring-chasing-the-finger fit of the lens over + * a 600 ms drag (rms 0.83 pt) and a 150 ms drag (rms 1.6 pt), + * Oct 23 2026 + */ + lensTrack: {response: 0.15, dampingRatio: 1}, + /** + * The lens settling onto the committed item after the finger lifts, + * starting from its velocity on screen. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, UIViewSpringAnimationBehavior (dampingRatio 1, + * response 0.5) attached at the lift, and a fit of the settle after a + * drag released at 1500 pt/s (rms 0.67 pt), Oct 23 2026 + */ + lensSettle: {response: 0.5, dampingRatio: 1}, + /** + * The lift, as one progress from rest (0) to lifted (1). The lens's outset, + * its resting fill fading out and the magnification all follow it, on the + * press and on the release alike. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, UIViewSpringAnimationBehavior (dampingRatio 1, response 0.25) + * attached at touch-down and at the lift; the fill's opacity fits it with + * rms 0.0004 (press) and 0.0013 (release), the outset with rms 0.2 pt, Oct + * 23 2026 + */ + lift: {response: 0.25, dampingRatio: 1}, + /** + * How far the lifted lens grows past the item on every side, in the bar's + * own coordinates: 76 × 54 becomes 92 × 70. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the lens held still for 1 s: 91.9 × 70.15 in the + * bar's coordinates (96.4 × 73.6 on screen under the flex), Oct 23 2026 + */ + liftOutset: 8, + /** + * The selected copy of the items under the lifted lens grows by this + * factor about each item's centre (the copy outside the lens does not). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the selected `_UITabButton`'s scale reads 1.16 while + * held and follows 1 + 0.16 × the lift progress (1.0990 at a progress + * of 0.619, 1.1356 at 0.848), Oct 23 2026 + */ + liftMagnification: 1.16, + /** + * While a finger is on the bar the whole capsule grows about its centre + * by this factor (290 × 62 becomes 304.1 × 65.0). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, `_UITabBarPlatterView` 304.14 × 65.02 while held, + * Oct 23 2026 + */ + flexScale: 1.0488, + /** + * The flex growing while the finger is down. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, UIViewSpringAnimationBehavior tracking damping ratio + * 0.625, response 0.314; the capsule's scale fits it with rms 0.00009, + * Oct 23 2026 + */ + flexPress: {response: 0.314, dampingRatio: 0.625}, + /** + * The flex relaxing after the lift. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, UIViewSpringAnimationBehavior (dampingRatio 0.6, + * response 0.36); the capsule's scale fits it with rms 0.0002, + * Oct 23 2026 + */ + flexRelease: {response: 0.36, dampingRatio: 0.6}, + /** + * Past the first or last item's centre the lens rubber-bands with UIKit's + * formula (c = 0.55) over this dimension. A finger 60 pt past the last item + * leaves the lens 8 pt past it. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, a drag held 60 pt past the last item: the lens rests 7.98 pt past + * its centre, Oct 23 2026 + */ + lensEdgeDimension: 10.5, +} as const; diff --git a/packages/lab/src/TabBar/useTabBarLens.ts b/packages/lab/src/TabBar/useTabBarLens.ts new file mode 100644 index 0000000000000..dfe3d110be52b --- /dev/null +++ b/packages/lab/src/TabBar/useTabBarLens.ts @@ -0,0 +1,787 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useTabBarLens.ts + * @input The capsule, the item count, the selected index, the presentation, + * and the lift's handler. Core's animator and long-press + * recognizer (internal/touch), uikitTabBar.ts and tabBarModel.ts. + * @output useTabBarLens: the selection lens's motion and press tracking, and + * the refs TabBar puts on the capsule and the lens's layers + * @position Part of TabBar's touch layer (TabBarTouch.ts) + * + * SYNC: When modified, update TabBar.tsx, tabBarModel.ts and + * __tests__/TabBar.interaction.browser.spec.ts. + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type RefObject, +} from 'react'; +import {flushSync} from 'react-dom'; +import { + createActionGroupTracker, + createMotionAnimator, + isRtlElement, + lockSelection, + LongPressGestureRecognizer, + type MotionAnimator, + type MotionCurve, + UIKIT_GESTURES, + useGestureRecognizer, +} from '@astryxdesign/core/internal/touch'; +import { + copyTransform, + itemCenters, + itemWidth, + lensTarget, + liftScale, + nearestIndex, +} from './tabBarModel'; +import {useMotionAnimator} from '../internal/useMotionAnimator'; +import {tabBar} from './uikitTabBar'; + +/** A finger that has moved this far from where it landed is dragging. */ +const DRAG_SLOP = UIKIT_GESTURES.longPress.allowableMovement.value; + +/** How long the selected copy's curves run: past any lens spring's settle. */ +const COPY_HORIZON_S = 1.5; + +/** Seconds between the samples a derived curve's velocity is read from. */ +const VELOCITY_STEP_S = 0.001; + +interface Press { + /** The items' centres in the capsule, physical px, before the flex. */ + readonly centers: number[]; + /** The items' (and the resting lens's) width, px. */ + readonly size: number; + /** The capsule's left edge in client px, before the flex. */ + readonly left: number; + readonly startX: number; + lastX: number; + isDragging: boolean; + /** A tab was selected when the finger landed: the lens follows it. */ + hasLens: boolean; + /** A fine pointer's press: a drag moves the lens body, with no lift. */ + readonly isFine: boolean; + /** The capsule's box in client px, before the flex. */ + readonly box: {left: number; right: number; top: number; bottom: number}; + readonly count: number; +} + +/** + * After a fine pointer's slide, focus does what a click on the chosen tab + * does. Where the press focused a tab in the bar, the chosen tab takes the + * focus. It reads the bar's own document, so it also works in an iframe or + * another window. + */ +export function focusChosenTab( + capsule: HTMLElement, + chosen: HTMLElement, +): void { + const active = capsule.ownerDocument.activeElement; + if (active != null && active !== chosen && capsule.contains(active)) { + chosen.focus({preventScroll: true}); + } +} + +export interface UseTabBarLensOptions { + readonly capsuleRef: RefObject; + readonly count: number; + readonly selectedIndex: number; + /** + * The touch presentation (a coarse primary pointer): a finger lifts the + * lens on springs. Otherwise a press drags it in the mouse presentation. + */ + readonly isTouch: boolean; + /** A press released over the item at `index`: a lift, or a drag's release. */ + readonly onLift: (index: number) => void; +} + +export interface TabBarLensRefs { + /** The lens's resting place: TabBar's CSS slides it after a click. */ + readonly lens: (element: HTMLElement | null) => void; + /** The capsule. Its scale is the press flex. */ + readonly capsule: (element: HTMLElement | null) => void; + /** + * The lens body: its offset and lift, and its `::before`, the lifted lens's + * shadow (outside the clip). + */ + readonly body: (element: HTMLElement | null) => void; + /** The lens's clip: its `::before` is the lifted lens's glass. */ + readonly clip: (element: HTMLElement | null) => void; + /** The selected copy of the items. */ + readonly copy: (element: HTMLElement | null) => void; + /** One copied item, by index: the magnification. */ + readonly copyItem: (index: number) => (element: HTMLElement | null) => void; + /** + * The copy is drawn: the lens is lifted, or moving under a finger. At rest + * the selected tab draws its own look and the copy is not mounted. + */ + readonly isCopyShown: boolean; +} + +/** One animator per copied item, stable across renders. */ +function useAnimatorList(count: number) { + const listRef = useRef<{ + animators: MotionAnimator[]; + refs: Array<(element: HTMLElement | null) => void>; + }>({animators: [], refs: []}); + const list = listRef.current; + while (list.animators.length < count) { + const animator = createMotionAnimator(null); + list.animators.push(animator); + list.refs.push(element => animator.attach(element)); + } + return list; +} + +/** + * WHAT MOVES (uikitTabBar.ts, all measured on iOS 26.2) + * + * The lens is a CSS resting place over the selected item (TabBar.tsx) with a + * body inside it that this hook moves. `x` is its offset from the resting + * place, `scaleX` / `scaleY` its lift, and its `::before` the shadow that fades + * in as it lifts. Inside the body, a clip holds the selected copy of the items + * while the lens moves. The clip's background is the opaque fill, and its + * `::before` is the glass that fades in as the lens lifts. The copy is + * counter-transformed so the items stay put while the lens moves over them. Its + * curves derive from the body's (tabBarModel's `copyTransform`). Each copied + * item grows by the lift's magnification about its own centre. The copy mounts + * when a finger lands or a slide starts and goes once everything rests. At rest + * the selected tab draws its own look (TabBar.tsx). + * + * Under a finger, iOS and Android alike (a long press with no delay, so it + * begins on the touch, as UIKit's selection tracker does): + * - landing: the lens travels toward the finger (`lensTravel`) and lifts + * (`lift`: outset, glass, magnification), and the capsule flexes + * (`flexPress`); + * - dragging (past the long press's allowable movement): the lens chases the + * finger (`lensTrack`), rubber-banded past the first and last items; + * - lifting: the item nearest the finger is chosen, the lens settles onto it + * (`lensSettle`), the lift and the flex relax (`lift`, `flexRelease`). A + * cancelled touch settles back onto the selected item. + * - off the bar: the lens shows the selected item again, and a lift there + * chooses nothing. Sliding off cancels, under every pointer. + * The choice reaches the app (`onLift`) from the lift itself, right after the + * settle has started in the same task. The settle's first frame waits for the + * browser's commit, so the app rendering the new tab delays its start rather + * than freezing it midway. The report renders synchronously, and if the app + * keeps the old selection the lens settles back onto it. The finger is read in + * the capsule's own coordinates, before its flex scale, as UIKit reads it. + * Under the flex the lens drifts slightly away from the finger toward the ends, + * as on iOS. + * + * A selection change slides the body from where the lens was on screen to its + * new resting place. On `lensTravel` from rest (a key, the app), or continuing + * the settle a lift started. Every animation runs on the compositor; a finger's + * moves retarget them, so the lens keeps moving through a busy main thread. + * Under Reduce Motion the animator cross-fades instead of moving and a press + * scales nothing. Each spring plays as measured, from the UIKit table. + * + * Under a fine pointer the same press drags the lens in the mouse presentation. + * A fine pointer is a mouse, a pen, or a finger on a screen whose primary + * pointer is fine. A click stays a plain click. Once the press drags past the + * allowable movement, the lens follows it on the finger's tracking spring + * (`lensTrack`). There is no lift and no copy of the items. The release chooses + * the item nearest the press, as a lift does, and the lens settles onto it on + * `lensSettle`, from the drag's own speed. A click chooses the same way: the + * gesture host swallows the browser's click for a press a recognizer took. + * TabBar's CSS then slides the lens. Released off the bar, the press chooses + * nothing. + */ +export function useTabBarLens({ + capsuleRef, + count, + selectedIndex, + isTouch, + onLift, +}: UseTabBarLensOptions): TabBarLensRefs { + const capsule = useMotionAnimator(); + const body = useMotionAnimator(); + // The lifted look, the body's and the clip's `::before`, starts hidden (an + // animator's values start at identity). + const shadow = useMotionAnimator({ + pseudoElement: '::before', + initial: {opacity: 0}, + }); + const glass = useMotionAnimator({ + pseudoElement: '::before', + initial: {opacity: 0}, + }); + const bodyRef = useCallback( + (element: HTMLElement | null) => { + body.ref(element); + shadow.ref(element); + }, + [body, shadow], + ); + const copy = useMotionAnimator(); + const copyItems = useAnimatorList(count); + + const pressRef = useRef(null); + // The lens's resting place. Under a mouse TabBar's CSS transition on + // `translate` slides it to a new selection. After a fine pointer's drag the + // settle carries the lens instead, so the resting place jumps. + const lensRef = useRef(null); + const lensElementRef = useCallback((element: HTMLElement | null) => { + lensRef.current = element; + }, []); + /** The resting place's next move is a jump: its transition is off. */ + const holdRest = () => { + if (lensRef.current != null) { + lensRef.current.style.transition = 'none'; + } + }; + /** + * Its transition back on. Style is read first with the transition off, so + * the jump it already took never transitions. + */ + const releaseRest = () => { + const element = lensRef.current; + if (element != null && element.style.transition !== '') { + void element.ownerDocument.defaultView?.getComputedStyle(element) + .translate; + element.style.transition = ''; + } + }; + // The settle a lift started toward a newly chosen item, until the new + // selection renders (or the app keeps the old one). + const settleRef = useRef(null); + const selectedRef = useRef(selectedIndex); + selectedRef.current = selectedIndex; + const onLiftRef = useRef(onLift); + onLiftRef.current = onLift; + const tabsOf = (capsule: HTMLElement) => + [...capsule.children].filter( + (child): child is HTMLElement => child.getAttribute('role') === 'tab', + ); + // The tab a press at viewport (x, y) would choose: the tab nearest it. Off + // the bar nothing is chosen, under every pointer, so sliding off cancels. + const pickRef = useRef<(x: number, y: number) => HTMLElement | null>( + () => null, + ); + pickRef.current = (x, y) => { + const press = pressRef.current; + const element = capsuleRef.current; + if (press == null || element == null) { + return null; + } + const {box} = press; + if (x < box.left || x > box.right || y < box.top || y > box.bottom) { + return null; + } + const index = nearestIndex(x - press.left, press.centers); + return tabsOf(element)[index] ?? null; + }; + // What the lift does with the tab it chose: set by the lift's own handler. + const runRef = useRef<((tab: HTMLElement) => void) | null>(null); + // Core's action-group tracker owns the press: the tab under it, and running + // that tab at the lift. TabBar picks the tab and draws the lens. + const [tracker] = useState(() => + createActionGroupTracker(() => ({ + roots: () => [capsuleRef.current], + itemSelector: '[role="tab"]', + highlightExpansion: 0, + paintsHighlight: false, + resolveTarget: (x, y) => pickRef.current(x, y), + onActivate: tab => runRef.current?.(tab), + })), + ); + /** + * Where the lens heads for a press at `x`: after it, rubber-banded past + * the end items. Off the bar, where the lift would choose nothing, the lens + * shows the selected tab again. + */ + const chaseTarget = (x: number, centers: number[]) => + tracker.highlighted == null + ? centers[Math.max(selectedRef.current, 0)] + : lensTarget(x, centers); + /** Runs the tab the tracker resolved, through `run`; null if none. */ + const runLift = (run: (tab: HTMLElement) => void) => { + runRef.current = run; + try { + return tracker.end(); + } finally { + runRef.current = null; + } + }; + + // The copy is drawn from a finger landing, or a slide starting, until + // everything the lens moves rests. A new selection under a finger is + // seen during render, so the frame that starts the slide already draws it. + const [isCopyShown, setIsCopyShown] = useState(false); + const [seenIndex, setSeenIndex] = useState(selectedIndex); + if (seenIndex !== selectedIndex) { + setSeenIndex(selectedIndex); + if ( + isTouch && + seenIndex >= 0 && + seenIndex < count && + selectedIndex >= 0 && + body.animator.reducedMotion({}) === 'off' + ) { + setIsCopyShown(true); + } + } + const motionRef = useRef(0); + /** Drops the copy once this motion, or a later one, rests, with no finger down. */ + const dropCopyAtRest = () => { + const motion = ++motionRef.current; + void Promise.all([ + body.animator.atRest(), + glass.animator.atRest(), + shadow.animator.atRest(), + ...copyItems.animators.slice(0, count).map(item => item.atRest()), + ]).then(() => { + if (motionRef.current === motion && pressRef.current == null) { + setIsCopyShown(false); + } + }); + }; + + /** + * Plays the selected copy's counter-transform from `at`, derived from the + * body's curves as they now stand. Called after every change to the body. + */ + const playCopy = (at: number) => { + const sample = (t: number) => { + const time = at + t * 1000; + return copyTransform( + body.animator.get('x', time), + body.animator.get('scaleX', time), + body.animator.get('scaleY', time), + ); + }; + const curve = (key: 'x' | 'scaleX' | 'scaleY'): MotionCurve => ({ + duration: COPY_HORIZON_S, + at: t => sample(t)[key], + velocity: t => + (sample(t + VELOCITY_STEP_S)[key] - + sample(Math.max(0, t - VELOCITY_STEP_S))[key]) / + (t >= VELOCITY_STEP_S ? 2 * VELOCITY_STEP_S : VELOCITY_STEP_S), + }); + copy.animator.play( + {x: curve('x'), scaleX: curve('scaleX'), scaleY: curve('scaleY')}, + {at}, + ); + }; + + /** + * Lifts (true) or lowers (false) a lens `size` px wide and flexes the + * capsule. Under Reduce Motion a press scales nothing: the glass and its + * shadow only fade. + */ + const lift = ( + isLifted: boolean, + at: number, + size: number, + touchRelease = false, + ) => { + const options = {at, touchRelease}; + const spring = {spring: tabBar.lift}; + shadow.animator.animate({opacity: isLifted ? 1 : 0}, spring, options); + glass.animator.animate({opacity: isLifted ? 1 : 0}, spring, options); + if (body.animator.reducedMotion({touchRelease}) !== 'off') { + return; + } + const lifted = liftScale(size); + body.animator.animate( + isLifted ? {scaleX: lifted.x, scaleY: lifted.y} : {scaleX: 1, scaleY: 1}, + spring, + options, + ); + for (const item of copyItems.animators.slice(0, count)) { + item.animate( + {scale: isLifted ? tabBar.liftMagnification : 1}, + spring, + options, + ); + } + capsule.animator.animate( + {scale: isLifted ? tabBar.flexScale : 1}, + {spring: isLifted ? tabBar.flexPress : tabBar.flexRelease}, + options, + ); + }; + + const begin = (recognizer: LongPressGestureRecognizer) => { + const element = capsuleRef.current; + const selected = selectedRef.current; + const root = element?.parentElement; + if (element == null || root == null) { + return; + } + // Read before anything moves: the capsule's layout box (its flex is a + // transform, which layout ignores) placed by its unscaled parent. + const rootBox = root.getBoundingClientRect(); + const left = rootBox.left + element.offsetLeft; + const isRtl = isRtlElement(element); + const centers = itemCenters(element.offsetWidth, count, isRtl); + const size = itemWidth(element.offsetWidth, count); + const x = recognizer.location().x - left; + pressRef.current = { + centers, + size, + left, + startX: x, + lastX: x, + isDragging: false, + hasLens: selected >= 0, + isFine: !isTouch, + box: { + left, + right: left + element.offsetWidth, + top: rootBox.top + element.offsetTop, + bottom: rootBox.top + element.offsetTop + element.offsetHeight, + }, + count, + }; + const location = recognizer.location(); + tracker.begin(location.x, location.y); + // A fine pointer: nothing moves until the press drags. + if (!isTouch) { + return; + } + // No tab selected: there is no lens to move; the lift chooses the tab + // nearest the finger. + if (selected < 0) { + return; + } + // The lifted lens magnifies the selected look: its copy mounts now, in + // this event (its animators attach to it with their curves running). + // Under Reduce Motion nothing lifts or travels, so no copy mounts. + motionRef.current += 1; + if (body.animator.reducedMotion({}) === 'off') { + flushSync(() => setIsCopyShown(true)); + } + const at = performance.now(); + body.animator.animate( + {x: chaseTarget(x, centers) - centers[selected]}, + {spring: tabBar.lensTravel}, + {at}, + ); + lift(true, at, size); + playCopy(at); + }; + + const move = (recognizer: LongPressGestureRecognizer) => { + const press = pressRef.current; + const selected = selectedRef.current; + if (press == null) { + return; + } + const location = recognizer.location(); + tracker.move(location.x, location.y); + const x = location.x - press.left; + press.lastX = x; + press.isDragging ||= Math.abs(x - press.startX) > DRAG_SLOP; + if (press.isFine) { + // A fine pointer's drag carries the lens on the finger's tracking + // spring, with no lift and no copy of the items. Where the release would + // choose nothing, the lens shows the selected tab again. + if (!press.isDragging) { + return; + } + if (press.hasLens && selected >= 0) { + const target = chaseTarget(x, press.centers) - press.centers[selected]; + if (body.animator.reducedMotion({}) === 'off') { + body.animator.animate( + {x: target}, + {spring: tabBar.lensTrack}, + {at: performance.now()}, + ); + } else { + // Under Reduce Motion it follows the pointer at once. + body.animator.set({x: target}); + } + } + return; + } + if (!press.hasLens || selected < 0) { + return; + } + const at = performance.now(); + body.animator.animate( + {x: chaseTarget(x, press.centers) - press.centers[selected]}, + {spring: press.isDragging ? tabBar.lensTrack : tabBar.lensTravel}, + {at}, + ); + playCopy(at); + }; + + const end = (wasCancelled: boolean) => { + const press = pressRef.current; + pressRef.current = null; + const selected = selectedRef.current; + const element = capsuleRef.current; + if (press == null || element == null) { + tracker.cancel(); + return; + } + // The tab list changed under the press. Its centres name the old list, so + // it ends as a cancel and the lens settles to its resting place. + const isStale = press.count !== count; + // The tab the tracker resolved for the lift, if any. + const lifted = wasCancelled || isStale ? null : tracker.highlighted; + const chosen = lifted == null ? -1 : tabsOf(element).indexOf(lifted); + if (chosen < 0) { + tracker.cancel(); + } + if (press.isFine) { + // The release runs the tab under the press, a click's too: the host + // swallows the browser's click for a press a recognizer took. After a + // drag the lens settles on the finger's settle spring, from the drag's + // own speed (the animator keeps the tracking spring's velocity). It + // settles onto the chosen tab, or back onto the selected one. After a + // click TabBar's CSS slides it. + const isSlide = press.isDragging && press.hasLens && selected >= 0; + if (isSlide && body.animator.reducedMotion({}) !== 'off') { + // Under Reduce Motion the resting place takes it there at once. + body.animator.set({x: 0}); + } else if (isSlide) { + const target = chosen >= 0 ? chosen : selected; + body.animator.animate( + {x: press.centers[target] - press.centers[selected]}, + {spring: tabBar.lensSettle}, + {at: performance.now()}, + ); + if (chosen >= 0 && chosen !== selected) { + settleRef.current = chosen; + holdRest(); + } + } + if (chosen >= 0) { + runLift(tab => { + flushSync(() => onLiftRef.current(chosen)); + focusChosenTab(element, tab); + }); + } + if ( + chosen !== selected && + settleRef.current === chosen && + selectedRef.current === selected + ) { + // The app kept the old selection: the lens settles back. + settleRef.current = null; + releaseRest(); + body.animator.animate( + {x: 0}, + {spring: tabBar.lensSettle}, + {at: performance.now()}, + ); + } + return; + } + // The app's answer to this lift, as rendered now: the tab the finger + // chose is the one under it at the lift, whatever renders after. + const deliver = onLiftRef.current; + if (!press.hasLens || selected < 0) { + // No lens moved: the tab the tracker resolved runs at once. + if (chosen >= 0) { + runLift(() => flushSync(() => deliver(chosen))); + } + return; + } + const target = chosen >= 0 ? chosen : selected; + const at = performance.now(); + body.animator.animate( + {x: isStale ? 0 : press.centers[target] - press.centers[selected]}, + {spring: tabBar.lensSettle}, + {at, touchRelease: true}, + ); + lift(false, at, press.size, true); + playCopy(at); + dropCopyAtRest(); + if (chosen < 0) { + return; + } + if (chosen !== selected) { + settleRef.current = chosen; + } + // The lift reports its choice from this handler, the settle already + // started. Its first frame waits for the browser's commit, so the app + // rendering the chosen tab delays it rather than freezing it midway. + // Rendered at once, so the selection reads the app's answer right after. + runLift(() => flushSync(() => deliver(chosen))); + if ( + chosen !== selected && + settleRef.current === chosen && + selectedRef.current === selected + ) { + // The app kept the old selection: the lens settles back. + settleRef.current = null; + const now = performance.now(); + body.animator.animate( + {x: 0}, + {spring: tabBar.lensSettle}, + {at: now, touchRelease: true}, + ); + playCopy(now); + dropCopyAtRest(); + } + }; + + // The presentation each press reads, for the selection lock below: the + // recognizer is made once and outlives a switch between presentations. + const isTouchRef = useRef(isTouch); + isTouchRef.current = isTouch; + useGestureRecognizer( + capsuleRef, + () => + new LongPressGestureRecognizer({ + name: 'tab-bar-selection', + minimumPressDuration: 0, + // Every pointer: a finger in the touch presentation, a mouse or a + // pen dragging in the mouse one. + allowedPointerTypes: ['touch', 'pen', 'mouse'], + // In the touch presentation the hold lifts the lens and drags it, so no + // selection or callout starts under the finger. A press at 0 ms brings + // no lock of its own. In the mouse presentation a mouse or a pen keeps + // desktop selection: what its drag does to text is open. + selectionLock: (elements, target) => + isTouchRef.current + ? lockSelection(elements, target) + : () => undefined, + }), + recognizer => { + switch (recognizer.state) { + case 'began': + begin(recognizer); + break; + case 'changed': + move(recognizer); + break; + case 'ended': + end(false); + break; + case 'cancelled': + case 'failed': + end(true); + break; + } + }, + { + // Under a mouse the host writes no touch styles: desktop selection + // is unchanged. + manageStyle: isTouch, + }, + ); + + // Synchronizes the recognizer's press with the presentation: when the + // presentation changes under a press, the press ends here, as a cancel. A + // cancel chooses nothing, so the app hears nothing. + const endRef = useRef(end); + endRef.current = end; + useEffect(() => { + const press = pressRef.current; + if (press != null && press.isFine === isTouch) { + endRef.current(true); + } + }, [isTouch]); + + // Synchronizes the recognizer's press and the lens's animations with the tab + // list. When it changes under a held finger, the press ends here, as a + // cancel, rather than at the lift. So no move chases the old centres. + useLayoutEffect(() => { + if (pressRef.current != null && pressRef.current.count !== count) { + endRef.current(true); + } + }, [count]); + + // Synchronizes the lens's compositor animations (the animator's Web + // Animations on its body and copy) with the selected tab. + // A new selection: the resting place jumped to the new item (CSS, this + // frame); the body starts where the lens was on screen. After a lift it + // continues the settle the lift started; otherwise it slides from rest. + // Under a mouse TabBar's CSS slides the lens, except after a drag, whose + // settle the body continues here. + const previousIndexRef = useRef(selectedIndex); + useLayoutEffect(() => { + const from = previousIndexRef.current; + previousIndexRef.current = selectedIndex; + const element = capsuleRef.current; + const fromLift = settleRef.current === selectedIndex; + settleRef.current = null; + if ( + (!isTouch && !fromLift) || + element == null || + from === selectedIndex || + from < 0 || + selectedIndex < 0 || + from >= count + ) { + return; + } + const isRtl = isRtlElement(element); + const centers = itemCenters(element.offsetWidth, count, isRtl); + const now = performance.now(); + const shift = centers[from] - centers[selectedIndex]; + const offset = body.animator.get('x', now) + shift; + const velocity = body.animator.getVelocity('x', now); + body.animator.set({x: offset}); + releaseRest(); + const press = pressRef.current; + if (press != null) { + // A finger is down (the selection changed under it): the lens keeps + // heading for the finger, now measured from its new resting place. + body.animator.animate( + { + x: + chaseTarget(press.lastX, press.centers) - + press.centers[selectedIndex], + }, + { + spring: press.isDragging ? tabBar.lensTrack : tabBar.lensTravel, + velocity: {x: velocity}, + }, + {at: now}, + ); + playCopy(now); + return; + } + if (fromLift || velocity !== 0) { + // Motion on screen: continue it from now, the copy derived from it. + body.animator.animate( + {x: 0}, + { + spring: fromLift ? tabBar.lensSettle : tabBar.lensTravel, + velocity: {x: velocity}, + }, + {at: now, touchRelease: fromLift && isTouch}, + ); + playCopy(now); + dropCopyAtRest(); + return; + } + // From rest (a key, the app), the body starts when the browser commits the + // frame, with the lens's jump to its new place. The copy plays the body's + // own curve on the body's clock. Two calls on two animators would each take + // their own start, which WebKit's late-start correction can part under a + // busy main thread. + copy.animator.set({x: -offset}); + const call = body.animator.animate({x: 0}, {spring: tabBar.lensTravel}); + dropCopyAtRest(); + const motion = motionRef.current; + void call.started.then(start => { + if (motionRef.current === motion) { + playCopy(start); + } + }); + }, [selectedIndex, count, isTouch, capsuleRef, body, copy]); + + return { + lens: lensElementRef, + capsule: capsule.ref, + body: bodyRef, + clip: glass.ref, + copy: copy.ref, + copyItem: index => copyItems.refs[index], + isCopyShown, + }; +} diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index 8476272ba2707..215132299f8f2 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -340,6 +340,14 @@ export { type SearchFieldProps, } from './SearchField'; +// TabBar: the floating bottom tab bar of iOS 26's UITabBarController. +export { + TabBar, + TabBarItem, + type TabBarItemProps, + type TabBarProps, +} from './TabBar'; + // RichTextEditor (RFC meta/astryx#3899) moved out of @astryxdesign/lab into // its own package, @astryxdesign/richtext. Import it from there: // import {RichTextEditor, RichTextView} from '@astryxdesign/richtext'; From 52ecd8bbdfe559b0c27fac3b4741104934a23942 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 17:29:20 -0800 Subject: [PATCH 6/6] feat(lab): TabBar minimizes on scroll, carries a bottom accessory, and holds a search tab and a More tab With minimizing on, the bar collapses into a small circle as the page scrolls down under a finger and expands at the top or on a tap, and a bottom accessory moves from above the bar to inline beside the circle. A disabled tab takes no choice, and a finger landing on one moves nothing until it drags. A search tab sits apart at the trailing end: chosen, it turns into a search field that rises above the keyboard, measured from the visual viewport, with a Cancel button that ends search. Past four tabs a More tab holds the rest, which TabBarMoreList lists. Each motion plays on the springs measured from UIKit on iOS 26.2, from TabBar's own table. --- .../cli-discover-tab-bar-more-search.md | 7 + apps/storybook/stories/TabBar.stories.tsx | 299 +- internal/shadcn-registry/routes.lock.json | 10 + .../assets/discover/official-packages.json | 8 + packages/lab/src/TabBar/TabBar.doc.mjs | 147 +- packages/lab/src/TabBar/TabBar.test.tsx | 808 +++++- packages/lab/src/TabBar/TabBar.tsx | 1362 ++++++++- packages/lab/src/TabBar/TabBarMoreList.tsx | 134 + packages/lab/src/TabBar/TabBarTouch.ts | 2 + .../TabBar.interaction.browser.spec.ts | 2566 ++++++++++++++++- packages/lab/src/TabBar/index.ts | 16 +- packages/lab/src/TabBar/tabBarModel.test.ts | 79 + packages/lab/src/TabBar/tabBarModel.ts | 102 +- packages/lab/src/TabBar/uikitTabBar.ts | 179 +- packages/lab/src/TabBar/useTabBarLens.ts | 222 +- packages/lab/src/TabBar/useTabBarMinimize.ts | 528 ++++ packages/lab/src/TabBar/useTabBarSearch.ts | 321 +++ packages/lab/src/index.ts | 5 + 18 files changed, 6600 insertions(+), 195 deletions(-) create mode 100644 .changeset/cli-discover-tab-bar-more-search.md create mode 100644 packages/lab/src/TabBar/TabBarMoreList.tsx create mode 100644 packages/lab/src/TabBar/useTabBarMinimize.ts create mode 100644 packages/lab/src/TabBar/useTabBarSearch.ts diff --git a/.changeset/cli-discover-tab-bar-more-search.md b/.changeset/cli-discover-tab-bar-more-search.md new file mode 100644 index 0000000000000..66301aae5b7dc --- /dev/null +++ b/.changeset/cli-discover-tab-bar-more-search.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx discover` lists the experimental TabBarMoreList and TabBarSearchTab components from `@astryxdesign/lab`. + +@vjeux diff --git a/apps/storybook/stories/TabBar.stories.tsx b/apps/storybook/stories/TabBar.stories.tsx index b9ee247e579a9..505f42dafc377 100644 --- a/apps/storybook/stories/TabBar.stories.tsx +++ b/apps/storybook/stories/TabBar.stories.tsx @@ -1,6 +1,6 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. -import {useEffect, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import {flushSync} from 'react-dom'; import type {Meta, StoryObj} from '@storybook/react'; import { @@ -9,6 +9,8 @@ import { Squares2X2Icon as GridOutline, UserCircleIcon as UserOutline, BellIcon as BellOutline, + Cog6ToothIcon as CogOutline, + QuestionMarkCircleIcon as HelpOutline, } from '@heroicons/react/24/outline'; import { BookOpenIcon as BookOpenSolid, @@ -16,8 +18,20 @@ import { Squares2X2Icon as GridSolid, UserCircleIcon as UserSolid, BellIcon as BellSolid, + Cog6ToothIcon as CogSolid, + QuestionMarkCircleIcon as HelpSolid, } from '@heroicons/react/24/solid'; -import {TabBar, TabBarItem} from '@astryxdesign/lab'; +import {ForwardIcon, PauseIcon, PlayIcon} from '@heroicons/react/24/solid'; +import {MagnifyingGlassIcon} from '@heroicons/react/24/outline'; +import { + TabBar, + TabBarItem, + TabBarMoreList, + TabBarSearchTab, + SearchField, + type SearchFieldHandle, + type TabBarAccessoryPlacement, +} from '@astryxdesign/lab'; import {Button} from '@astryxdesign/core/Button'; import {LayerProvider} from '@astryxdesign/core/Layer'; import {Icon, type IconType} from '@astryxdesign/core/Icon'; @@ -110,6 +124,11 @@ function Bar({ badges = {library: 3}, keeps = [], onChange, + minimizeBehavior, + bottomAccessory, + hasSearch = false, + searchActivates, + disabled = [], }: { sections?: Section[]; initial?: string; @@ -118,10 +137,32 @@ function Bar({ keeps?: string[]; /** Also told of every choice. */ onChange?: (value: string) => void; + minimizeBehavior?: 'never' | 'onScrollDown'; + bottomAccessory?: (placement: TabBarAccessoryPlacement) => React.ReactNode; + hasSearch?: boolean; + /** The search tab's `automaticallyActivates`. */ + searchActivates?: boolean; + disabled?: string[]; }) { const [value, setValue] = useState(initial); const [reselects, setReselects] = useState(0); - const title = sections.find(section => section.value === value)?.label ?? ''; + const [isSearchActive, setIsSearchActive] = useState(false); + const fieldRef = useRef(null); + const [query, setQuery] = useState(''); + // Cancelling search returns to the tab it was chosen from. + const lastTabRef = useRef(initial); + if (value !== 'search') { + lastTabRef.current = value; + } + const setSearchActive = (isActive: boolean) => { + setIsSearchActive(isActive); + if (!isActive) { + setValue(lastTabRef.current); + } + }; + const title = + sections.find(section => section.value === value)?.label ?? + (value === 'search' ? 'Search' : ''); return ( <> @@ -135,6 +176,8 @@ function Bar({ }} onReselect={() => setReselects(n => n + 1)} label="Sections" + minimizeBehavior={minimizeBehavior} + bottomAccessory={bottomAccessory} data-testid="tab-bar"> {sections.map(section => ( } selectedIcon={} badge={badges[section.value]} + isDisabled={disabled.includes(section.value)} /> ))} + {hasSearch ? ( + } + isActive={isSearchActive} + onActiveChange={setSearchActive} + automaticallyActivates={searchActivates} + fieldRef={fieldRef}> + + + ) : null} ); @@ -156,6 +221,119 @@ export const Default: Story = { render: () => , }; +/** + * Scroll the page down under a finger: the bar collapses into a circle with + * the selected icon. Scroll back to the top, or tap the circle, to expand it. + */ +export const MinimizeOnScroll: Story = { + render: () => , +}; + +/** A now-playing bar for the bottom accessory; inline, it drops "Next". */ +function NowPlaying({placement}: {placement: TabBarAccessoryPlacement}) { + const [isPlaying, setIsPlaying] = useState(false); + return ( +
+
+
+ Midnight City +
+
+ ); +} + +/** + * A bottom accessory above the bar. Scroll the page down under a finger: it + * moves down inline beside the circle, and back up at the top. The stack is + * 139 px tall (the bar's 83 and the accessory's 56), declared on + * LayerProvider so menus, popovers and toasts clear it. + */ +export const BottomAccessory: Story = { + render: () => ( + + } + /> + + ), +}; + +/** + * A search tab: a circle at the trailing end, apart from the tabs, which share + * a narrower capsule. Choosing it collapses the bar to a circle with the tab it + * was chosen from, and raises its field above the keyboard. The search circle + * beside it is the cancel button, which returns to that tab. Scroll down under + * a finger: both circles stay. + */ +export const SearchTab: Story = { + render: () => ( + } + /> + ), +}; + +/** + * A search tab that waits inline (`automaticallyActivates={false}`): + * choosing it shows the field in the bar; a tap on the field activates it. + */ +export const SearchTabWaitsInline: Story = { + render: () => , +}; + +/** + * The app has disabled the selected tab (Home) and search is chosen from it. + * Leaving search returns to the enabled tab nearest it (Library), never to the + * disabled one. + */ +export const SearchFromDisabledTab: Story = { + render: () => , +}; + /** Badges appear, change and disappear at once, as UIKit's do. */ function BadgesDemo() { const [count, setCount] = useState(0); @@ -274,6 +452,121 @@ export const Badges: Story = { render: () => , }; +/** Browse is disabled: dimmed, and a tap, the keys or the lens leave it unchosen. */ +export const DisabledTab: Story = { + render: () => , +}; + +/** Six sections: the four the bar holds, then two that overflow into More. */ +const SIX: Section[] = [ + ...SECTIONS, + { + value: 'settings', + label: 'Settings', + icon: CogOutline, + selectedIcon: CogSolid, + }, + {value: 'help', label: 'Help', icon: HelpOutline, selectedIcon: HelpSolid}, +]; + +function MoreDemo({hasSearch = false}: {hasSearch?: boolean}) { + const [value, setValue] = useState('home'); + const [reselects, setReselects] = useState(0); + const [isSearchActive, setIsSearchActive] = useState(false); + const fieldRef = useRef(null); + const [query, setQuery] = useState(''); + const tabs = SIX.map(section => ( + } + selectedIcon={} + badge={section.value === 'help' ? 2 : undefined} + /> + )); + const inMore = SIX.slice(4).find(section => section.value === value); + return ( + <> + {value === 'more' ? ( +
+

More

+ + {tabs} + +
+ ) : inMore != null ? ( +
+
+ ) : ( + section.value === value)?.label ?? ''} + reselects={reselects} + /> + )} + { + // More again returns to its list, as iOS pops More's stack. + if (reselected === 'more') { + setValue('more'); + } else { + setReselects(n => n + 1); + } + }} + label="Sections" + data-testid="tab-bar"> + {tabs} + {hasSearch ? ( + } + isActive={isSearchActive} + onActiveChange={setIsSearchActive} + automaticallyActivates={false} + fieldRef={fieldRef}> + + + ) : null} + + + ); +} + +/** + * Six tabs: the bar shows the first four and More, whose list holds the + * rest. A section chosen there opens inside More, which stays selected; + * More again returns to the list. + */ +export const MoreTab: Story = { + render: () => , +}; + +/** + * Six tabs and a search tab: a section opened inside More stays in the + * search tab's leading circle (as More) while searching. + */ +export const MoreTabWithSearch: Story = { + render: () => , +}; + /** Five tabs: the items overlap more, as they do on iOS. */ export const FiveTabs: Story = { render: () => ( diff --git a/internal/shadcn-registry/routes.lock.json b/internal/shadcn-registry/routes.lock.json index 379bba928809c..16ecee3422ade 100644 --- a/internal/shadcn-registry/routes.lock.json +++ b/internal/shadcn-registry/routes.lock.json @@ -667,6 +667,16 @@ "path": "components/lab/tab-bar-item", "aliases": [] }, + { + "name": "component-lab-tab-bar-more-list", + "path": "components/lab/tab-bar-more-list", + "aliases": [] + }, + { + "name": "component-lab-tab-bar-search-tab", + "path": "components/lab/tab-bar-search-tab", + "aliases": [] + }, { "name": "component-lab-three-d-bar", "path": "components/lab/three-d-bar", diff --git a/packages/cli/assets/discover/official-packages.json b/packages/cli/assets/discover/official-packages.json index 15548168ff88c..98a294fb9b54c 100644 --- a/packages/cli/assets/discover/official-packages.json +++ b/packages/cli/assets/discover/official-packages.json @@ -180,6 +180,14 @@ "kind": "component", "name": "TabBarItem" }, + { + "kind": "component", + "name": "TabBarMoreList" + }, + { + "kind": "component", + "name": "TabBarSearchTab" + }, { "kind": "component", "name": "ThreeDBar" diff --git a/packages/lab/src/TabBar/TabBar.doc.mjs b/packages/lab/src/TabBar/TabBar.doc.mjs index 36cfc4e76f62c..be44119f54ff8 100644 --- a/packages/lab/src/TabBar/TabBar.doc.mjs +++ b/packages/lab/src/TabBar/TabBar.doc.mjs @@ -16,9 +16,15 @@ export const docs = { 'mobile navigation', 'app sections', 'badge', + 'minimize on scroll', + 'bottom accessory', + 'mini player', + 'search tab', + 'more tab', + 'overflow tabs', ], description: - "The bottom tab bar of a phone app, as on iOS 26: the app's top-level sections as a floating capsule above the bottom edge, with a selection lens that slides to the chosen tab. Each tab has an icon, a label and an optional badge. Under a finger, on iOS and Android alike, it behaves like iOS 26's: the lens lifts into glass as a finger lands, follows a finger dragged across the tabs and settles on the tab nearest where it lifts, while the bar flexes; under a mouse or a pen, a click chooses a tab and the lens follows the theme's motion, and a press dragged across the tabs carries the lens and chooses the tab where it is released. Choosing the selected tab again is reported separately, so a screen can return to its root as iOS does. Experimental: lands in lab first.", + "The bottom tab bar of a phone app, as on iOS 26: the app's top-level sections as a floating capsule above the bottom edge, with a selection lens that slides to the chosen tab. Each tab has an icon, a label and an optional badge. Under a finger, on iOS and Android alike, it behaves like iOS 26's: the lens lifts into glass as a finger lands, follows a finger dragged across the tabs and settles on the tab nearest where it lifts, while the bar flexes; under a mouse or a pen, a click chooses a tab and the lens follows the theme's motion, and a press dragged across the tabs carries the lens and chooses the tab where it is released. Under a finger it can also collapse into a small circle while the page scrolls down. It can hold a search tab apart at its trailing end. Choosing the selected tab again is reported separately, so a screen can return to its root as iOS does. Experimental: lands in lab first.", components: [ { name: 'TabBar', @@ -51,13 +57,33 @@ export const docs = { description: 'Names the tab bar for assistive technology. Without it, or aria-label or aria-labelledby (which win over it and reach the tab list), the tab list is named "Tabs" in the reader\'s language.', }, + { + name: 'minimizeBehavior', + type: "'never' | 'onScrollDown'", + description: + "With 'onScrollDown', scrolling the page down collapses the bar into a small circle at its leading end that holds the selected tab's icon, keeping the content in view, as iOS's tab bar does. Scrolling back up leaves it collapsed; reaching the top of the page or activating the circle expands it. Collapsed, the circle is the bar's one element for assistive technology, named after the selected tab and collapsed, as on iOS; with a search tab, that tab stays in reach beside the circle, and focusing it expands the bar. Only under a finger: a mouse-driven bar stays expanded.", + default: "'never'", + }, + { + name: 'bottomAccessory', + type: "ReactNode | ((placement: 'expanded' | 'inline') => ReactNode)", + description: + "A bottom accessory, such as a music player's now-playing bar: a 48 px capsule as wide as the bar, 8 px above it. When the bar minimizes it moves down inline beside the circle and gets narrower; pass a function to adapt its content to where it is (placement is 'inline' there). Leave 56 px more room at the bottom of the scrolling content, and declare the whole stack on LayerProvider (inset={{blockEnd: 139}}: the bar's 83 px and the accessory's 56) so menus, popovers and toasts clear the accessory too.", + }, { name: 'children', type: 'ReactNode', description: - 'The tabs: TabBarItem elements, in order, as direct children.', + 'The tabs: TabBarItem elements, in order, as direct children, and at most one TabBarSearchTab. With more than five, the bar shows the first four and a More tab that holds the rest.', required: true, }, + { + name: 'moreValue', + type: 'string', + description: + "The More tab's value, with more than five tabs. Choosing More calls onChange with it (and onReselect while More holds a tab, to return to its list); More reads as selected while value is moreValue or any tab past the fourth. Render TabBarMoreList while value is moreValue, and a section chosen there inside More's own navigation stack.", + default: "'more'", + }, ], }, { @@ -104,6 +130,98 @@ export const docs = { description: 'What assistive technology reads for the badge after the tab\'s label, such as "3 unread". Defaults to the badge itself.', }, + { + name: 'isDisabled', + type: 'boolean', + description: + 'Shows the tab dimmed and keeps it from being chosen: a tap, the arrow keys and a lift of the lens over it leave the selection as it is.', + default: 'false', + }, + ], + }, + { + name: 'TabBarMoreList', + displayName: 'Tab Bar More List', + description: + "The More tab's screen, as on iOS: one row per tab past the fourth, with its icon, its label and a disclosure chevron. Give it the same TabBarItem children as TabBar. Rearranging the tabs (iOS's Edit) is not available yet.", + props: [ + { + name: 'children', + type: 'ReactNode', + description: 'The same TabBarItem elements given to TabBar.', + required: true, + }, + { + name: 'onChoose', + type: '(value: string) => void', + description: + "Called with a row's value when it is chosen: show that section inside More, which stays selected in the bar.", + required: true, + }, + { + name: 'value', + type: 'string', + description: "The selected tab's value: its row reads as selected.", + }, + ], + }, + { + name: 'TabBarSearchTab', + displayName: 'Tab Bar Search Tab', + description: + 'The search tab, as on iOS 26: a circle as tall as the bar at its trailing end, apart from the other tabs, which share a narrower capsule. Choosing it selects it like any tab, collapses the bar to a circle holding the tab it was chosen from (a tap on that circle returns there) and shows its field in the bar; by default the same tap activates the field and focuses it, which raises the keyboard on a phone. Active, the field spans the screen but for a Cancel button drawn over the search circle (the circle stays a tab, out of reach while the field is active); on a phone both sit above the keyboard. Under a mouse it selects in place: the bar stays expanded and the field shows at once above it, focused, where the bottom accessory sits; Escape returns to the tab it was chosen from. TabBar reads its props and draws it; it renders nothing on its own.', + props: [ + { + name: 'value', + type: 'string', + description: + 'Identifies the search tab in value, onChange and onReselect.', + required: true, + }, + { + name: 'label', + type: 'string', + description: + 'The tab\'s name for assistive technology, such as "Search".', + required: true, + }, + { + name: 'icon', + type: 'ReactNode', + description: 'The search glyph, an Icon at size lg.', + required: true, + }, + { + name: 'children', + type: 'ReactNode', + description: + 'The search field: a SearchField with placement="tab", which draws the search landmark. It stays mounted, hidden and inert until the search tab is chosen, so the tap can focus it.', + }, + { + name: 'isActive', + type: 'boolean', + description: + 'Whether the field is active: widened to span the screen, with the keyboard up on a phone.', + }, + { + name: 'onActiveChange', + type: '(isActive: boolean) => void', + description: + 'Called with true when the tap that chooses the search tab activates its field, and with false when another tab is chosen while the search tab is selected (a tap on it, the leading circle, or Escape under a mouse) and when Cancel ends search for a field without a cancel method. Under a mouse, a Cancel button you draw sets isActive false itself.', + }, + { + name: 'fieldRef', + type: 'RefObject<{focus(options?: FocusOptions): void; blur(): void; cancel?(): void} | null>', + description: + "The field's handle, focused by the tap that activates it (with preventScroll: the field rises above the keyboard itself), and cancelled by the cancel button when it has a cancel method (SearchField's handle does); without one the cancel button calls onActiveChange(false). An input element works.", + }, + { + name: 'automaticallyActivates', + type: 'boolean', + description: + 'Whether choosing the search tab activates its field at once. When false, the field waits inline in the bar until it is tapped.', + default: 'true', + }, ], }, ], @@ -121,6 +239,31 @@ export const docs = { description: 'Use a badge for something that needs attention in that section, such as unread items, and say what it counts in badgeLabel.', }, + { + guidance: true, + description: + 'Use minimizeBehavior="onScrollDown" on screens people read or scroll through at length, such as feeds and articles, so the content gets the space.', + }, + { + guidance: true, + description: + 'Use bottomAccessory for one persistent control that belongs to the whole app, such as what is playing; keep it to a line, and drop secondary controls when its placement is inline.', + }, + { + guidance: true, + description: + 'Add a TabBarSearchTab when search is a section of the app on its own; cancel should return to the tab it was chosen from.', + }, + { + guidance: true, + description: + 'Keep to five tabs where you can; with more, put the sections people use most first, since the rest move into More.', + }, + { + guidance: false, + description: + "Don't disable a tab to say its section is empty; show the empty section instead, and keep disabled tabs rare.", + }, { guidance: false, description: diff --git a/packages/lab/src/TabBar/TabBar.test.tsx b/packages/lab/src/TabBar/TabBar.test.tsx index 7c8eb6448e063..69b497d391d56 100644 --- a/packages/lab/src/TabBar/TabBar.test.tsx +++ b/packages/lab/src/TabBar/TabBar.test.tsx @@ -5,17 +5,18 @@ * @input vitest, @testing-library/react, TabBar and TabBarItem * @output jsdom checks of the tab bar's semantics and state: the tablist and * its keys, choosing and reselecting, badges, the lens at rest and theme - * classes. + * classes. Also minimizing on scroll and the bottom accessory's placement. * @position Testing; __tests__/TabBar.interaction.browser.spec.ts proves the * motion in real engines */ import {afterEach, describe, expect, it, vi} from 'vitest'; -import {useState} from 'react'; -import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {useRef, useState} from 'react'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {InternationalizationProvider} from '@astryxdesign/core/i18n'; import {TOUCH_QUERY} from '@astryxdesign/core/internal/touch'; -import {TabBar, TabBarItem, type TabBarProps} from './TabBar'; +import {TabBar, TabBarItem, TabBarSearchTab, type TabBarProps} from './TabBar'; +import {TabBarMoreList} from './TabBarMoreList'; import {focusChosenTab} from './useTabBarLens'; function Glyph({name}: {name: string}) { @@ -231,7 +232,11 @@ describe('TabBar', () => { // At rest the lens carries no copy of the items: each icon is drawn once. expect(document.querySelector('[data-tab-bar-copy]')).toBeNull(); expect(lens?.querySelectorAll('[data-testid^="icon-"]')).toHaveLength(0); - expect(document.querySelectorAll('[data-testid^="icon-"]')).toHaveLength(4); + expect( + screen + .getByRole('tablist') + .querySelectorAll('[role="tab"] [data-testid^="icon-"]'), + ).toHaveLength(4); }); it('draws the lens only while a tab is selected', () => { @@ -299,6 +304,32 @@ describe('TabBar', () => { ).not.toBeNull(); }); + it('with minimizing off the tab list stands alone: no bar box, platter or tab layer (INV7)', () => { + const {container} = render(); + const root = container.querySelector('.astryx-tab-bar'); + const list = screen.getByRole('tablist'); + expect(list.parentElement).toBe(root); + expect(container.querySelector('[data-tab-bar-platter]')).toBeNull(); + expect(container.querySelector('[data-tab-bar-items]')).toBeNull(); + expect( + [...list.children].filter(child => child.getAttribute('role') === 'tab'), + ).toHaveLength(4); + }); + + it('mounts the circle and the split glass only when minimizing is on', () => { + restorePointer.push(coarsePrimaryPointer()); + const {unmount} = render(); + expect(document.querySelector('[data-tab-bar-circle]')).toBeNull(); + // The tab list draws its own glass: no platter. + expect(document.querySelector('[data-tab-bar-platter]')).toBeNull(); + unmount(); + render(); + expect(document.querySelector('[data-tab-bar-circle]')).not.toBeNull(); + expect( + document.querySelector('[data-tab-bar-platter]')?.children, + ).toHaveLength(3); + }); + it('carries the stable theme classes and the selected state', () => { render(); expect(screen.getByTestId('bar')).toHaveClass('astryx-tab-bar'); @@ -323,6 +354,773 @@ describe('TabBar', () => { }); }); +describe('TabBar minimize on scroll', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + Object.defineProperty(window, 'scrollY', {value: 0, configurable: true}); + }); + + function scrollTo(y: number) { + Object.defineProperty(window, 'scrollY', {value: y, configurable: true}); + act(() => { + window.dispatchEvent(new Event('scroll')); + }); + } + + /** One finger on the page, as the gesture host reads a touch. */ + function touch(type: 'touchstart' | 'touchmove' | 'touchend', y: number) { + const event = new Event(type, { + bubbles: true, + cancelable: true, + composed: true, + }); + const point = { + identifier: 1, + clientX: 200, + clientY: y, + target: document.body, + }; + Object.defineProperty(event, 'changedTouches', {value: [point]}); + Object.defineProperty(event, 'touches', { + value: type === 'touchend' ? [] : [point], + }); + Object.defineProperty(event, 'timeStamp', {value: performance.now()}); + document.body.dispatchEvent(event); + } + + /** A finger dragging the page through `ys`. */ + function dragPage(ys: number[]) { + act(() => { + touch('touchstart', 600); + // Past the pan's 10 px: the person is scrolling the page. + touch('touchmove', 580); + }); + for (const y of ys) { + scrollTo(y); + } + act(() => { + touch('touchend', 580); + // The page comes to rest, as the browser reports it. + window.dispatchEvent(new Event('scrollend')); + }); + } + + const isMinimized = () => + screen + .getByRole('tablist', {hidden: true}) + .hasAttribute('data-tab-bar-minimized'); + + it('never minimizes by default', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + dragPage([40, 80, 160]); + expect(isMinimized()).toBe(false); + }); + + it('minimizes 66 px into a downward scroll, not before', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + // The page restored to 300 by a script: not the person's scroll. + scrollTo(300); + dragPage([320, 365]); + expect(isMinimized()).toBe(false); + dragPage([366]); + expect(isMinimized()).toBe(true); + }); + + it('stays minimized while scrolling back up mid-page, and expands at the top', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + dragPage([100]); + expect(isMinimized()).toBe(true); + dragPage([60, 20]); + expect(isMinimized()).toBe(true); + dragPage([0]); + expect(isMinimized()).toBe(false); + }); + + it('stays minimized when a script scrolls the page to the top', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + dragPage([200]); + expect(isMinimized()).toBe(true); + // Long after the finger: not the person's scroll. + vi.spyOn(performance, 'now').mockReturnValue(performance.now() + 10_000); + scrollTo(0); + expect(isMinimized()).toBe(true); + }); + + it('minimized, exposes only the circle: the selected tab, collapsed', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + const circle = document.querySelector('[data-tab-bar-circle]'); + expect(circle).toHaveAttribute('aria-hidden', 'true'); + dragPage([200]); + // The tabs are out of reach until the bar expands, as on iOS. + for (const tab of screen.getAllByRole('tab', {hidden: true})) { + expect(tab).toHaveAttribute('inert'); + } + const button = screen.getByRole('button', {name: 'Library'}); + expect(button).toBe(circle); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(button).toHaveAttribute('tabindex', '0'); + }); + + it('minimized with no tab selected, the circle takes the bar’s name and the keys expand onto the first tab', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + dragPage([200]); + const button = screen.getByRole('button', {name: 'Sections'}); + button.focus(); + fireEvent.click(button); + expect(isMinimized()).toBe(false); + expect(screen.getByRole('tab', {name: 'Home'})).toHaveFocus(); + }); + + it('activating the circle from the keys expands the bar and focuses the selected tab', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + dragPage([200]); + const button = screen.getByRole('button', {name: 'Library'}); + button.focus(); + fireEvent.click(button); + expect(isMinimized()).toBe(false); + const library = screen.getByRole('tab', {name: 'Library'}); + expect(library).not.toHaveAttribute('inert'); + expect(library).toHaveFocus(); + }); + + it('expands on a tap on the circle, keeping the selection', () => { + restorePointer.push(coarsePrimaryPointer()); + const onChange = vi.fn(); + render( + , + ); + dragPage([200]); + const circle = document.querySelector('[data-tab-bar-circle]'); + expect(circle?.querySelector('[data-testid="icon-browse"]')).not.toBeNull(); + fireEvent.click(circle as Element); + expect(isMinimized()).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole('tab', {name: 'Browse'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('expands when a tab receives focus', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + dragPage([200]); + act(() => { + screen.getByRole('tab', {name: 'Home'}).focus(); + }); + expect(isMinimized()).toBe(false); + }); + + it('moves the bottom accessory inline while minimized, telling its content where it is', () => { + restorePointer.push(coarsePrimaryPointer()); + const placements: string[] = []; + render( + { + placements.push(placement); + return ; + }} + />, + ); + const accessory = () => document.querySelector('[data-tab-bar-accessory]'); + expect(accessory()).toHaveAttribute('data-tab-bar-accessory', 'expanded'); + dragPage([200]); + expect(accessory()).toHaveAttribute('data-tab-bar-accessory', 'inline'); + expect(placements.at(-1)).toBe('inline'); + // The accessory stays usable while the bar is minimized. + expect(screen.getByRole('button', {name: 'Now playing'})).toBeVisible(); + dragPage([0]); + expect(accessory()).toHaveAttribute('data-tab-bar-accessory', 'expanded'); + }); + + it('stays expanded under a mouse', () => { + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + render(); + dragPage([200]); + expect(isMinimized()).toBe(false); + }); + + it('minimizes under any finger, Android included (one touch model)', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + dragPage([200]); + expect(isMinimized()).toBe(true); + }); +}); + +describe('TabBar bottom accessory', () => { + it('draws no accessory when its content renders nothing', () => { + for (const bottomAccessory of [false, '', () => null]) { + const {container, unmount} = render( + , + ); + expect(container.querySelector('[data-tab-bar-accessory]')).toBeNull(); + unmount(); + } + }); + + it('renders its content above the bar, outside the tablist', () => { + render(Mini player} />); + const content = screen.getByText('Mini player'); + expect( + content.closest('[data-tab-bar-accessory="expanded"]'), + ).not.toBeNull(); + expect(content.closest('[role="tablist"]')).toBeNull(); + }); + + it('renders nothing without one', () => { + render(); + expect(document.querySelector('[data-tab-bar-accessory]')).toBeNull(); + }); +}); + +describe('TabBar search tab', () => { + function SearchBar({onChange}: {onChange?: (value: string) => void}) { + const [value, setValue] = useState('home'); + return ( + { + setValue(next); + onChange?.(next); + }} + label="Sections"> + } /> + } + /> + } + /> + + ); + } + + it('is the last tab of the tablist, named by its label', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs.at(-1)).toHaveAccessibleName('Search'); + expect(tabs.at(-1)).toHaveAttribute('aria-selected', 'false'); + expect(tabs.at(-1)).toHaveAttribute('tabindex', '-1'); + }); + + it('selects the search tab like any other, taking the tab stop', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + expect(onChange).toHaveBeenCalledWith('search'); + const search = screen.getByRole('tab', {name: 'Search'}); + expect(search).toHaveAttribute('aria-selected', 'true'); + expect(search).toHaveAttribute('tabindex', '0'); + expect(screen.getByRole('tab', {name: 'Home'})).toHaveAttribute( + 'tabindex', + '-1', + ); + }); + + it('reaches the search tab with the arrow keys', () => { + render(); + const home = screen.getByRole('tab', {name: 'Home'}); + home.focus(); + fireEvent.keyDown(home, {key: 'End'}); + expect(screen.getByRole('tab', {name: 'Search'})).toHaveFocus(); + }); +}); + +describe('TabBar search tab, selected', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function Searching({ + automaticallyActivates, + onActiveChange, + bottomAccessory, + initial = 'browse', + }: { + automaticallyActivates?: boolean; + onActiveChange?: (isActive: boolean) => void; + bottomAccessory?: React.ReactNode; + initial?: string; + }) { + const [value, setValue] = useState(initial); + const [isActive, setIsActive] = useState(false); + const fieldRef = useRef(null); + return ( + + } /> + } + selectedIcon={} + /> + } + isActive={isActive} + onActiveChange={next => { + setIsActive(next); + onActiveChange?.(next); + }} + fieldRef={fieldRef} + automaticallyActivates={automaticallyActivates}> + + + + ); + } + + it('mounted on its search tab, the leading circle returns to the tab it shows', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + const circle = document.querySelector('[data-tab-bar-circle]'); + // No tab was chosen before: the circle shows, and is named after, the + // first tab, and returns there. + expect(circle).toHaveAccessibleName('Home'); + fireEvent.click(circle as Element); + expect(screen.getByRole('tab', {name: 'Home'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('leaving search from the circle reports the field inactive', () => { + restorePointer.push(coarsePrimaryPointer()); + const onActiveChange = vi.fn(); + // The field waits inline, so the circle is in reach (an active field + // covers it, and Cancel leaves search then). + render( + , + ); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + expect(onActiveChange).not.toHaveBeenCalled(); + const circle = document.querySelector('[data-tab-bar-circle]') as Element; + expect(circle.closest('[inert]')).toBeNull(); + fireEvent.click(circle); + // The field goes with the tab. The app's `isActive` stays in step, so the + // next visit to the search tab opens with no stale active field. + expect(onActiveChange).toHaveBeenLastCalledWith(false); + expect(onActiveChange).toHaveBeenCalledTimes(1); + expect(screen.getByRole('tab', {name: 'Browse'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('choosing another tab while searching reports the field inactive once', () => { + // Under a mouse the search tab is selected in place and the other tabs + // stay in reach. + const onActiveChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + expect(onActiveChange).toHaveBeenLastCalledWith(true); + fireEvent.click(screen.getByRole('tab', {name: 'Home'})); + expect(onActiveChange).toHaveBeenLastCalledWith(false); + expect(onActiveChange).toHaveBeenCalledTimes(2); + expect(screen.getByRole('tab', {name: 'Home'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('leaving search from the circle with the keys moves focus on to the tab it returns to', () => { + restorePointer.push(coarsePrimaryPointer()); + // The field waits inline, so the circle is in reach. + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + const circle = document.querySelector( + '[data-tab-bar-circle]', + ) as HTMLElement; + expect(circle.closest('[inert]')).toBeNull(); + act(() => { + circle.focus(); + }); + fireEvent.click(circle); + // Expanded, the circle is hidden and holds no tab stop: focus cannot + // stay on it (WCAG 2.4.3). + const browse = screen.getByRole('tab', {name: 'Browse'}); + expect(browse).toHaveAttribute('aria-selected', 'true'); + expect(document.activeElement).toBe(browse); + }); + + it('keeps the field mounted but inert until the search tab is chosen', () => { + render(); + const field = screen.getByLabelText('Search query', {selector: 'input'}); + expect(field.closest('[data-tab-bar-search-field]')).toHaveAttribute( + 'inert', + ); + }); + + it('activates and focuses the field in the tap that chooses the search tab', () => { + restorePointer.push(coarsePrimaryPointer()); + const onActiveChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + expect(onActiveChange).toHaveBeenCalledWith(true); + const field = screen.getByLabelText('Search query', {selector: 'input'}); + expect(field).toHaveFocus(); + const host = field.closest('[data-tab-bar-search-field]'); + expect(host).not.toHaveAttribute('inert'); + expect(host).toHaveAttribute('data-tab-bar-search-field', 'active'); + }); + + it('with automaticallyActivates={false}, the field waits inline', () => { + restorePointer.push(coarsePrimaryPointer()); + const onActiveChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + expect(onActiveChange).not.toHaveBeenCalled(); + const field = screen.getByLabelText('Search query', {selector: 'input'}); + expect(field).not.toHaveFocus(); + expect(field.closest('[data-tab-bar-search-field]')).toHaveAttribute( + 'data-tab-bar-search-field', + 'inline', + ); + }); + + it('collapses the bar to a circle holding the tab it came from, which returns to it', () => { + restorePointer.push(coarsePrimaryPointer()); + const {container} = render(); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + expect(screen.getByRole('tablist')).toHaveAttribute( + 'data-tab-bar-minimized', + ); + const circle = container.querySelector('[data-tab-bar-circle]'); + expect( + circle?.querySelector('[data-testid="icon-browse-filled"]'), + ).not.toBeNull(); + fireEvent.click(circle as Element); + expect(screen.getByRole('tab', {name: 'Browse'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByRole('tablist')).not.toHaveAttribute( + 'data-tab-bar-minimized', + ); + }); + + it('under a finger the active field gets a Cancel button outside the tab list: without a cancel on the handle it blurs the field and reports inactive', () => { + restorePointer.push(coarsePrimaryPointer()); + const onActiveChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + const field = screen.getByLabelText('Search query', {selector: 'input'}); + expect(field).toHaveFocus(); + const cancel = screen.getByRole('button', {name: 'Cancel'}); + expect(cancel).toHaveAttribute('data-tab-bar-search-cancel'); + // Not inside the tab list, whose tabs (the search tab's circle included) + // stay inert while searching. + expect(cancel.closest('[role="tablist"]')).toBeNull(); + expect( + document.querySelector('[data-tab-bar-search]')?.closest('[inert]'), + ).not.toBeNull(); + fireEvent.click(cancel); + // The field moves back into the bar: it gives up its focus, so the + // keyboard goes with it (spec:AST-099 FR5). + expect(field).not.toHaveFocus(); + expect(onActiveChange).toHaveBeenLastCalledWith(false); + }); + + it('the focus Cancel hands on never pans the page', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + const cancel = screen.getByRole('button', {name: 'Cancel'}); + act(() => { + cancel.focus(); + }); + const circle = document.querySelector( + '[data-tab-bar-circle]', + ) as HTMLElement; + const focus = vi.spyOn(circle, 'focus'); + fireEvent.click(cancel); + // The bar is fixed over the page: a focus move inside it never scrolls. + expect(focus).toHaveBeenCalledWith({preventScroll: true}); + }); + + it('activated from the keys, Cancel hands its focus on to the collapsed bar’s circle when the search tab stays selected', () => { + restorePointer.push(coarsePrimaryPointer()); + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + const cancel = screen.getByRole('button', {name: 'Cancel'}); + act(() => { + cancel.focus(); + }); + fireEvent.click(cancel); + // Cancel is gone; focus did not fall to the page (WCAG 2.4.3). + expect(screen.queryByRole('button', {name: 'Cancel'})).toBeNull(); + expect(document.activeElement).toBe( + document.querySelector('[data-tab-bar-circle]'), + ); + }); + + it("the cancel button cancels through the field's handle", () => { + restorePointer.push(coarsePrimaryPointer()); + const cancel = vi.fn(); + function WithHandle() { + const [value, setValue] = useState('home'); + const [isActive, setIsActive] = useState(false); + const handle = useRef({focus: () => {}, blur: () => {}, cancel}); + return ( + + } /> + } + isActive={isActive} + onActiveChange={setIsActive} + fieldRef={handle} + /> + + ); + } + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('under a mouse the search circle stays a tab while its field is active', () => { + render(); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + expect(screen.getByRole('tab', {name: 'Search'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.queryByRole('button', {name: 'Cancel'})).toBeNull(); + }); + + it('sets the bottom accessory aside while searching', () => { + restorePointer.push(coarsePrimaryPointer()); + const {container} = render( + Play} + />, + ); + const accessory = container.querySelector('[data-tab-bar-accessory]'); + expect(accessory).not.toHaveAttribute('inert'); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + expect(accessory).toHaveAttribute('inert'); + }); + + it('under a mouse selects in place: the bar stays expanded and the field takes the accessory’s place, focused', () => { + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + render(Mini player} />); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + const field = screen.getByLabelText('Search query', {selector: 'input'}); + expect(field).toHaveFocus(); + expect(field.closest('[data-tab-bar-search-field]')).toHaveAttribute( + 'data-tab-bar-search-field', + 'above', + ); + expect(screen.getByRole('tablist')).not.toHaveAttribute( + 'data-tab-bar-minimized', + ); + expect( + screen.getByText('Mini player').closest('[data-tab-bar-accessory]'), + ).toHaveAttribute('inert'); + }); + + it('under a mouse Escape returns to the tab search was chosen from, and the accessory with it', () => { + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + render(Mini player} />); + fireEvent.click(screen.getByRole('tab', {name: 'Search'})); + fireEvent.keyDown( + screen.getByLabelText('Search query', {selector: 'input'}), + {key: 'Escape'}, + ); + const browse = screen.getByRole('tab', {name: 'Browse'}); + expect(browse).toHaveAttribute('aria-selected', 'true'); + expect(browse).toHaveFocus(); + expect( + screen.getByText('Mini player').closest('[data-tab-bar-accessory]'), + ).not.toHaveAttribute('inert'); + }); +}); + +describe('TabBar disabled tab', () => { + function Disabled({onChange}: {onChange?: (value: string) => void}) { + const [value, setValue] = useState('home'); + return ( + { + setValue(next); + onChange?.(next); + }} + label="Sections"> + } /> + } + isDisabled + /> + } + /> + + ); + } + + it('is marked disabled and ignores a tap', () => { + const onChange = vi.fn(); + render(); + const browse = screen.getByRole('tab', {name: 'Browse'}); + expect(browse).toHaveAttribute('aria-disabled', 'true'); + fireEvent.click(browse); + expect(onChange).not.toHaveBeenCalled(); + expect(browse).toHaveAttribute('aria-selected', 'false'); + }); + + it('is skipped by the arrow keys', () => { + render(); + const home = screen.getByRole('tab', {name: 'Home'}); + home.focus(); + fireEvent.keyDown(home, {key: 'ArrowRight'}); + expect(screen.getByRole('tab', {name: 'Profile'})).toHaveFocus(); + }); +}); + +describe('TabBar More tab', () => { + const SIX = ['home', 'library', 'browse', 'profile', 'settings', 'help']; + const tabs = SIX.map(value => ( + } + /> + )); + + it('shows the first four tabs and More when there are more than five', () => { + render( + {}} label="Sections"> + {tabs} + , + ); + expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual([ + 'Home', + 'Library', + 'Browse', + 'Profile', + 'More', + ]); + }); + + it('selects More for a tab it holds, and chooses More with its value', () => { + const onChange = vi.fn(); + const {rerender} = render( + + {tabs} + , + ); + expect(screen.getByRole('tab', {name: 'More'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + rerender( + + {tabs} + , + ); + fireEvent.click(screen.getByRole('tab', {name: 'More'})); + expect(onChange).toHaveBeenCalledWith('more'); + }); + + it('More again while it holds a tab is a reselect, back to its list', () => { + const onChange = vi.fn(); + const onReselect = vi.fn(); + render( + + {tabs} + , + ); + fireEvent.click(screen.getByRole('tab', {name: 'More'})); + expect(onReselect).toHaveBeenCalledWith('more'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('shows a tab’s badge in its More row, spoken with the row', () => { + render( + {}}> + {[ + ...tabs.slice(0, 4), + } + />, + } + badge={2} + />, + ]} + , + ); + expect(screen.getByRole('button', {name: /Help, 2/})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: /Help/})).toHaveTextContent('2'); + }); + + it('lists the tabs past the fourth, and a row chooses its tab', () => { + const onChoose = vi.fn(); + render( + + {tabs} + , + ); + const list = screen.getByRole('list', {name: 'More'}); + expect(list).toHaveTextContent('Settings'); + expect(list).toHaveTextContent('Help'); + expect(list).not.toHaveTextContent('Profile'); + fireEvent.click(screen.getByRole('button', {name: /Settings/})); + expect(onChoose).toHaveBeenCalledWith('settings'); + }); +}); + describe('focusChosenTab, in another document', () => { it('moves focus to the chosen tab through the bar’s own document, inside an iframe', () => { const iframe = document.createElement('iframe'); diff --git a/packages/lab/src/TabBar/TabBar.tsx b/packages/lab/src/TabBar/TabBar.tsx index f59e049d33b26..f36482b200a67 100644 --- a/packages/lab/src/TabBar/TabBar.tsx +++ b/packages/lab/src/TabBar/TabBar.tsx @@ -7,7 +7,8 @@ * @input React, StyleX, Core's theme tokens and hooks, TabBar's touch layer * (TabBarTouch.ts), its geometry (tabBarModel.ts) and its UIKit values * (uikitTabBar.ts) - * @output TabBar (iOS 26's floating tab bar) and TabBarItem + * @output TabBar (iOS 26's floating tab bar), TabBarItem, TabBarSearchTab and + * overflowItems (the tabs a More tab holds, read by TabBarMoreList.tsx) * @position Lab component: the phone presentation of an app's top-level * sections, UITabBarController's counterpart * @@ -21,12 +22,18 @@ import { useCallback, useId, useRef, + useState, type ReactElement, type ReactNode, + type RefObject, + type ComponentPropsWithRef, } from 'react'; +import {flushSync} from 'react-dom'; import * as stylex from '@stylexjs/stylex'; import type {BaseProps} from '@astryxdesign/core'; import {useListFocus, useMergedRefs} from '@astryxdesign/core/hooks'; +import {useTranslator} from '@astryxdesign/core/i18n'; +import {Icon} from '@astryxdesign/core/Icon'; import {useTouchPresentation} from '@astryxdesign/core/internal/touch'; import { colorVars, @@ -38,12 +45,17 @@ import { shadowVars, typographyVars, } from '@astryxdesign/core/theme/tokens.stylex'; -import {useTranslator} from '@astryxdesign/core/i18n'; import {mergeProps, themeProps} from '@astryxdesign/core/utils'; import {tabBarItemMarker} from './tabBar.stylex'; +import { + accessoryBox, + accessoryEnd, + itemWidthCss, + naturalWidth, + searchFieldBox, +} from './tabBarModel'; import {tabBar} from './uikitTabBar'; -import {useTabBarLens} from './TabBarTouch'; -import {itemWidthCss, naturalWidth} from './tabBarModel'; +import {useTabBarLens, useTabBarMinimize, useTabBarSearch} from './TabBarTouch'; // ============================================================================= // Types @@ -68,6 +80,11 @@ export interface TabBarItemProps { * ("3 unread"). Defaults to the badge itself. */ badgeLabel?: string; + /** + * Shows the tab dimmed and keeps it from being chosen. A tap, the keys and a + * lift of the lens over it leave the selection as it is. + */ + isDisabled?: boolean; } export interface TabBarProps extends Omit< @@ -95,10 +112,36 @@ export interface TabBarProps extends Omit< 'aria-label'?: string; /** Names the tab list after another element; it wins over both. */ 'aria-labelledby'?: string; + /** + * Whether scrolling the page down collapses the bar into a circle holding the + * selected tab's icon (UIKit's `tabBarMinimizeBehavior`). Reaching the top of + * the page or activating the circle (a tap, or the keys) expands it. With a + * search tab, so does focusing it, since it stays in reach beside the circle. + * Under a finger only; under a mouse the bar stays expanded. + * @default 'never' + */ + minimizeBehavior?: 'never' | 'onScrollDown'; + /** + * A bottom accessory, such as a music player's now-playing bar: a capsule + * above the bar that moves down inline beside the minimized circle (UIKit's + * `bottomAccessory`). Pass a function to adapt the content to where it is. + */ + bottomAccessory?: + ReactNode | ((placement: TabBarAccessoryPlacement) => ReactNode); + /** + * The More tab's value, when there are more than five tabs. The bar shows the + * first four and a More tab, which `value` selects as this or as any tab past + * the fourth (UIKit's `moreNavigationController`). + * @default 'more' + */ + moreValue?: string; /** The tabs: `TabBarItem` elements, in order. */ children: ReactNode; } +/** Where the bottom accessory is: above the bar, or inline beside its circle. */ +export type TabBarAccessoryPlacement = 'expanded' | 'inline'; + /** * One tab of a TabBar. It renders nothing on its own: TabBar reads its props * and draws the tab (it must be a direct child of TabBar). @@ -107,6 +150,66 @@ export function TabBarItem(_props: TabBarItemProps): null { return null; } +export interface TabBarSearchTabProps { + /** Identifies the search tab in `value`, `onChange` and `onReselect`. */ + value: string; + /** The tab's name for assistive technology ("Search"). */ + label: string; + /** The search glyph, drawn in the current text colour (an ``). */ + icon: ReactNode; + /** + * The search field, shown in the bar while the search tab is selected: a + * SearchField with `placement="tab"`, which draws the search landmark. It + * stays mounted (hidden and inert otherwise), so a tap can focus it. + */ + children?: ReactNode; + /** Whether the field is active: widened to span the screen. */ + isActive?: boolean; + /** + * Called with `true` when the search tab's tap activates the field. Called + * with `false` when another tab is chosen during search: a tap on it, the + * leading circle, or Escape under a mouse. Also called with `false` when + * Cancel ends search for a field without `cancel` (see `fieldRef`). Under a + * mouse, a Cancel button the app draws sets `isActive` itself. + */ + onActiveChange?: (isActive: boolean) => void; + /** + * The field's handle: focused by the tap that activates it, and cancelled + * (`cancel`, when it has one) by the search circle. The search circle is the + * active field's cancel button under a finger. Without `cancel`, the circle + * reports `onActiveChange(false)`. + */ + fieldRef?: RefObject<{ + focus(options?: FocusOptions): void; + blur(): void; + cancel?(): void; + } | null>; + /** + * Whether choosing the search tab activates its field at once (UIKit's + * `automaticallyActivatesSearch`); otherwise the field waits inline. + * @default true + */ + automaticallyActivates?: boolean; +} + +/** + * The search tab of a TabBar (UIKit's `UISearchTab`): a circle as tall as the + * bar at its trailing end, apart from the other tabs. Like TabBarItem it + * renders nothing on its own and must be a direct child of TabBar. + */ +export function TabBarSearchTab(_props: TabBarSearchTabProps): null { + return null; +} + +function readSearch(children: ReactNode): TabBarSearchTabProps | null { + for (const child of Children.toArray(children)) { + if (isValidElement(child) && child.type === TabBarSearchTab) { + return (child as ReactElement).props; + } + } + return null; +} + function readItems(children: ReactNode): TabBarItemProps[] { return Children.toArray(children).flatMap(child => isValidElement(child) && child.type === TabBarItem @@ -115,10 +218,63 @@ function readItems(children: ReactNode): TabBarItemProps[] { ); } -function hasBadge(badge: string | number | undefined): boolean { +/** More than this many tabs and the bar shows the first four and More. */ +const MAX_TABS = 5; + +/** + * The tabs past the fourth when there are more than five: the ones a More + * tab holds. + */ +export function overflowItems(children: ReactNode): TabBarItemProps[] { + const items = readItems(children); + return items.length > MAX_TABS ? items.slice(MAX_TABS - 1) : []; +} + +export function hasBadge(badge: string | number | undefined): boolean { return badge != null && badge !== ''; } +/** + * A badged item's accessible name: one message with its label and badge, so + * a tab and its More row read the same. Undefined without a badge. + */ +export function badgedName( + item: TabBarItemProps, + t: ReturnType, +): string | undefined { + return hasBadge(item.badge) + ? t('@astryx.tabBar.badgedItem', { + label: item.label, + badge: item.badgeLabel ?? String(item.badge), + }) + : undefined; +} + +/** + * The tab that leaving search returns to: `from`, the tab search was chosen + * from. When the app has disabled `from`, it is the enabled tab nearest it, the + * leading one on a tie. So leaving search never chooses a disabled tab. -1 when + * no tab is enabled. + */ +function returnIndex( + items: ReadonlyArray<{isDisabled?: boolean}>, + from: number, +): number { + const start = Math.min(Math.max(from, 0), items.length - 1); + for (let distance = 0; distance < items.length; distance++) { + for (const index of [start - distance, start + distance]) { + if ( + index >= 0 && + index < items.length && + items[index].isDisabled !== true + ) { + return index; + } + } + } + return -1; +} + // ============================================================================= // Geometry, as CSS on the capsule (a size container: 100cqw is its width) // ============================================================================= @@ -197,20 +353,296 @@ const styles = stylex.create({ paddingInline: inset, paddingBottom: inset, }), - capsule: { + // The bar's box: the capsule fills it; the accessory is placed from it. + bar: { position: 'relative', flexGrow: 1, minWidth: 0, + pointerEvents: 'none', + }, + barSize: (height: string, maxWidth: string) => ({height, maxWidth}), + capsule: { + position: 'relative', + width: '100%', + height: '100%', containerType: 'inline-size', pointerEvents: 'auto', + touchAction: 'manipulation', + WebkitTapHighlightColor: 'transparent', + }, + // The tab list standing alone (no bar box): it takes the box's flex. + capsuleAlone: { + flexGrow: 1, + minWidth: 0, + width: 'auto', + }, + // The tab list draws its own glass (no platter). The bar never collapses, + // so the glass is one piece. + capsuleGlass: (half: string) => ({ + borderRadius: half, + backgroundColor: `color-mix(in srgb, ${colorVars['--color-background-popover']} 78%, transparent)`, + backdropFilter: 'blur(16px) saturate(180%)', + boxShadow: shadowVars['--shadow-med'], + }), + // Minimized, only the circle takes touches; the page gets the rest. + capsuleMinimized: { + pointerEvents: 'none', + }, + // Beside a search tab: the capsule leaves it room at the trailing end. + capsuleBesideSearch: (room: string) => ({ + width: `calc(100% - ${room})`, + }), + search: { + position: 'absolute', + top: 0, + margin: 0, + padding: 0, + borderWidth: 0, + backgroundColor: 'transparent', + color: colorVars['--color-text-primary'], + font: 'inherit', + pointerEvents: 'auto', + cursor: { + default: 'pointer', + ':is(:disabled,[aria-disabled="true"])': 'default', + }, + outline: 'none', + WebkitTapHighlightColor: 'transparent', + }, + searchBox: (start: string, size: string) => ({ + insetInlineStart: start, + width: size, + height: size, + }), + searchGlass: { + position: 'absolute', + inset: 0, + borderRadius: radiusVars['--radius-full'], + outlineStyle: { + default: 'none', + [stylex.when.ancestor(':focus-visible', tabBarItemMarker)]: + focusVars['--focus-outline-style'], + }, + outlineWidth: focusVars['--focus-outline-width'], + outlineColor: focusVars['--focus-outline-color'], + outlineOffset: focusVars['--focus-outline-offset'], + }, + searchSelected: { + color: colorVars['--color-accent'], + }, + // The Cancel button over the search circle: where the circle sits at the + // bar's trailing end, round, with its own focus ring. + searchCancel: (size: string) => ({ + insetInlineEnd: 0, + width: size, + height: size, borderRadius: radiusVars['--radius-full'], + outlineStyle: { + default: 'none', + ':focus-visible': focusVars['--focus-outline-style'], + }, + outlineWidth: focusVars['--focus-outline-width'], + outlineColor: focusVars['--focus-outline-color'], + outlineOffset: focusVars['--focus-outline-offset'], + }), + // The search field's host, inline in the bar's row; active under a + // finger it sits above the keyboard (`raised`). + accessoryAside: { + pointerEvents: 'none', + }, + // Under a mouse the field takes the accessory's place while searching. + accessoryHidden: { + opacity: 0, + }, + searchHostHidden: { + opacity: 0, + }, + searchHost: { + position: 'absolute', + pointerEvents: 'none', + }, + searchHostShown: { + pointerEvents: 'auto', + }, + // Under a finger only the field takes touches: its host spans the inline + // box, over the place the cancel button moves to. + searchFieldShown: { + pointerEvents: 'auto', + }, + // Under a finger the active field and its cancel button sit above the + // keyboard: an offset committed at each keyboard report, never animated. + raised: (x: number, y: number) => ({ + translate: `${x}px ${y}px`, + }), + searchField: { + position: 'absolute', + top: 0, + bottom: 0, + }, + searchFieldBox: (start: string, width: string) => ({ + insetInlineStart: start, + width, + }), + searchFieldContent: { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + }, + // While searching, the leading circle holds the tab the search tab was + // chosen from, in its plain colour. + circlePlain: { + color: colorVars['--color-text-primary'], + }, + // The tab buttons' layer: it fades as one when the bar minimizes. + items: { + position: 'absolute', + inset: 0, + }, + // The capsule's glass, in three pieces that can contract into a circle + // without distorting its round ends (useTabBarMinimize.ts). + platter: (half: string) => ({ + position: 'absolute', + inset: 0, + // Below the resting lens (z-index -1), which sits on the glass under the + // tabs. The tab list is no stacking context once the glass is here. + zIndex: -2, + pointerEvents: 'none', + transformOrigin: { + default: `${half} ${half}`, + ':is([dir="rtl"] *)': `calc(100% - ${half}) ${half}`, + }, + }), + glass: { + position: 'absolute', + top: 0, + bottom: 0, backgroundColor: `color-mix(in srgb, ${colorVars['--color-background-popover']} 78%, transparent)`, backdropFilter: 'blur(16px) saturate(180%)', boxShadow: shadowVars['--shadow-med'], - touchAction: 'manipulation', + }, + // Each piece keeps its shadow off the pieces it meets. + platterStart: (half: string) => ({ + insetInlineStart: 0, + width: half, + borderStartStartRadius: half, + borderEndStartRadius: half, + clipPath: { + default: 'inset(-32px 0 -32px -32px)', + ':is([dir="rtl"] *)': 'inset(-32px -32px -32px 0)', + }, + }), + platterMiddle: (half: string) => ({ + insetInlineStart: half, + insetInlineEnd: half, + clipPath: 'inset(-32px 0)', + transformOrigin: { + default: 'left center', + ':is([dir="rtl"] *)': 'right center', + }, + }), + platterEnd: (half: string) => ({ + insetInlineEnd: 0, + width: half, + borderStartEndRadius: half, + borderEndEndRadius: half, + clipPath: { + default: 'inset(-32px -32px -32px 0)', + ':is([dir="rtl"] *)': 'inset(-32px 0 -32px -32px)', + }, + }), + // Above the capsule: inline, it moves down over the contracting glass. + accessory: { + position: 'absolute', + zIndex: 1, + pointerEvents: 'auto', + }, + accessoryBox: (start: string, width: string, height: string) => ({ + insetInlineStart: start, + width, + height, + }), + accessoryAbove: (gap: string) => ({ + bottom: `calc(100% + ${gap})`, + }), + accessoryInline: (top: string) => ({ + top, + }), + // The aria-hidden containing block of a glass's two animated pieces (the + // accessory's, the search field's). Its ::before draws the start cap, which + // stays still: half the glass's height wide, rounded at its start. + accessoryGlass: { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + '::before': { + content: '""', + position: 'absolute', + top: 0, + bottom: 0, + insetInlineStart: 0, + aspectRatio: '1 / 2', + borderStartStartRadius: '100% 50%', + borderEndStartRadius: '100% 50%', + backgroundColor: `color-mix(in srgb, ${colorVars['--color-background-popover']} 78%, transparent)`, + backdropFilter: 'blur(16px) saturate(180%)', + boxShadow: shadowVars['--shadow-med'], + clipPath: { + default: 'inset(-32px 0 -32px -32px)', + ':is([dir="rtl"] *)': 'inset(-32px -32px -32px 0)', + }, + }, + }, + // The content's window: moved with the glass's trailing cap while the + // glass grows (useTabBarMinimize.ts), so the content never shows past it. + accessoryClip: { + position: 'absolute', + inset: 0, + overflow: 'hidden', + borderRadius: radiusVars['--radius-full'], + }, + accessoryContent: { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + overflow: 'hidden', + borderRadius: radiusVars['--radius-full'], + }, + circle: { + position: 'absolute', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: radiusVars['--radius-full'], + color: colorVars['--color-accent'], + pointerEvents: 'none', + }, + circleButton: { + margin: 0, + padding: 0, + borderWidth: 0, + backgroundColor: 'transparent', + font: 'inherit', + outlineStyle: { + default: 'none', + ':focus-visible': focusVars['--focus-outline-style'], + }, + outlineWidth: focusVars['--focus-outline-width'], + outlineColor: focusVars['--focus-outline-color'], + outlineOffset: focusVars['--focus-outline-offset'], WebkitTapHighlightColor: 'transparent', }, - capsuleSize: (height: string, maxWidth: string) => ({height, maxWidth}), + circleActive: { + pointerEvents: 'auto', + cursor: { + default: 'pointer', + ':is(:disabled,[aria-disabled="true"])': 'default', + }, + }, + itemDisabled: { + color: colorVars['--color-text-disabled'], + }, item: { position: 'absolute', top: 0, @@ -292,7 +724,7 @@ const styles = stylex.create({ transitionTimingFunction: easeVars['--ease-standard'], }, // At rest the lens is the pill under the selected item, which draws its own - // selected look. While it slides it carries the copy over the items. + // selected look. While it moves it carries the copy over the items. lensUnder: { zIndex: -1, }, @@ -322,7 +754,7 @@ const styles = stylex.create({ inset: 0, overflow: 'hidden', borderRadius: radiusVars['--radius-full'], - // Opaque: while it slides it hides the plain items under the copy. + // Opaque: while it moves it hides the plain items under the copy. backgroundColor: `color-mix(in srgb, ${colorVars['--color-text-primary']} 9%, ${colorVars['--color-background-popover']})`, // The lifted lens: the capsule's own surface with a light rim, opaque so // the plain copy under it stays hidden; the animator fades it in. @@ -342,7 +774,7 @@ const styles = stylex.create({ position: 'absolute', inset: 0, }, - // Mounted only while the lens slides under a finger, where the animator + // Mounted only while the lens moves under a finger, where the animator // moves it: no transition of its own. copyOuter: { position: 'absolute', @@ -412,6 +844,25 @@ const ITEM_HEIGHT = px(tabBar.itemHeight); const ICON_START = `calc(50% - ${px(tabBar.iconSize / 2)})`; const ICON_TOP = px(tabBar.iconCenterY - tabBar.iconSize / 2); const ICON_SIZE = px(tabBar.iconSize); +const HALF_BAR = px(tabBar.barHeight / 2); +const HALF_ACCESSORY = px(tabBar.accessoryHeight / 2); +const ACCESSORY_HEIGHT = px(tabBar.accessoryHeight); +/** The inline accessory's start and top (its width follows the bar's). */ +const INLINE_ACCESSORY = accessoryBox(0, true); +/** The room a search tab takes at the bar's trailing end. */ +const SEARCH_ROOM = px(tabBar.barHeight + tabBar.searchGap); +/** The inline search field (its width follows the bar's), and the active one relative to it. */ +const INLINE_FIELD = searchFieldBox(0, false); +const ACTIVE_FIELD = searchFieldBox(0, true); +const ACTIVE_FIELD_SHIFT = { + start: px(ACTIVE_FIELD.start - INLINE_FIELD.start), + width: px(ACTIVE_FIELD.width - INLINE_FIELD.width), +}; +const SEARCH_SIZE = px(tabBar.barHeight); +const SEARCH_ICON_INSET = px((tabBar.barHeight - tabBar.iconSize) / 2); +const CIRCLE_INSET = px((tabBar.barHeight - tabBar.minimizedSize) / 2); +const CIRCLE_SIZE = px(tabBar.minimizedSize); +const CIRCLE_ICON_INSET = px((tabBar.minimizedSize - tabBar.iconSize) / 2); const LABEL_TOP = px(tabBar.labelTop); const LABEL_HEIGHT = px(tabBar.labelSize * 1.2); const LABEL_SIZE = px(tabBar.labelSize); @@ -457,17 +908,17 @@ function ItemFace({ // ============================================================================= /** - * The iOS 26 tab bar: the app's top-level sections as a floating capsule at - * the bottom of the screen. A selection lens slides between them. - * - * @example - * ``` - * - * } /> - * } badge={3} /> - * - * ``` + * A wrapper the bar mounts only `when` something needs it. Otherwise its + * children render in its place, so the DOM holds only the wrappers in use. */ +function Layer({ + when, + children, + ...props +}: ComponentPropsWithRef<'div'> & {when: boolean}) { + return when ?
{children}
: children; +} + /* * WHAT IT DRAWS * @@ -519,10 +970,50 @@ function ItemFace({ * * Badges appear, change and disappear without animating, as UIKit's do. * + * MORE TAB + * + * With more than five tabs the bar shows the first four and a More tab, as + * UITabBarController does, at the five-tab layout. More is the selected tab + * while `value` is `moreValue` or any tab past the fourth. The app shows that + * section inside More's own stack, where TabBarMoreList lists the tabs. + * Choosing More again while it holds a tab is a reselect, back to its list. + * + * SEARCH TAB + * + * A TabBarSearchTab (UIKit's `UISearchTab`) is a circle as tall as the bar at + * its trailing end. The other tabs share a capsule narrowed to leave it room. + * Choosing it selects it like any tab, and the bar collapses to the leading + * circle. The circle holds the tab it was chosen from, and choosing it returns + * there. Meanwhile the search field shows inline in the bar. The field stays + * mounted (inert while hidden) so that the tap choosing the tab can, in the + * same commit, activate the field and focus it. Focus is what raises the + * keyboard on iOS. Active, its glass widens to span the screen but for the + * search circle. The circle is then the field's cancel button: it calls the + * field handle's `cancel`. Under a finger both sit above the software keyboard, + * an offset committed at each keyboard report (useTabBarSearch.ts). + * * Accessibility: a `tablist` of `tab` buttons (`aria-selected`) with one tab * stop. The arrow keys, Home and End move between tabs, and Enter or Space * chooses one. A badge is part of its tab's name, one message with both - * ("Library, 3"; `badgeLabel`, or the badge itself). + * ("Library, 3"; `badgeLabel`, or the badge itself). A disabled tab is dimmed, + * skipped by the keys and never chosen. Collapsed, the bar is one button for + * assistive technology, as on iOS. That button is the circle, named after the + * selected tab and collapsed (`aria-expanded`). The tabs stay inert until it + * expands. Activated from the keys, it moves focus to the selected tab. The + * circle and the three glass pieces mount only when the bar can collapse under + * a finger. That is, when minimizing is on or a search tab is there. + */ +/** + * The iOS 26 tab bar: the app's top-level sections as a floating capsule at + * the bottom of the screen. A selection lens slides between them. + * + * @example + * ``` + * + * } /> + * } badge={3} /> + * + * ``` */ export function TabBar({ value, @@ -531,6 +1022,9 @@ export function TabBar({ label, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, + minimizeBehavior = 'never', + bottomAccessory, + moreValue = 'more', children, xstyle, className, @@ -545,9 +1039,30 @@ export function TabBar({ ariaLabelledBy != null ? undefined : (ariaLabel ?? label ?? t('@astryx.tabList.label')); - const items = readItems(children); + const allItems = readItems(children); + const overflow = overflowItems(children); + const hasMore = overflow.length > 0; + // More than five tabs: the first four and a More tab, as on iOS. + const items: TabBarItemProps[] = hasMore + ? [ + ...allItems.slice(0, MAX_TABS - 1), + { + value: moreValue, + label: t('@astryx.tabBar.more'), + icon: , + }, + ] + : allItems; + const search = readSearch(children); + const hasSearch = search != null; const count = items.length; - const selectedIndex = items.findIndex(item => item.value === value); + // A tab past the fourth is shown inside More: More is the selected tab. + const isMoreSelected = + hasMore && + (value === moreValue || overflow.some(item => item.value === value)); + const selectedIndex = isMoreSelected + ? MAX_TABS - 1 + : items.findIndex(item => item.value === value); const baseId = useId(); // UIKit's motion belongs to a finger (a coarse primary pointer, iOS and @@ -562,28 +1077,238 @@ export function TabBar({ hasRovingTabIndex: true, }); const choose = useCallback( - (item: TabBarItemProps) => { - if (item.value === value) { + (item: {value: string; isDisabled?: boolean}) => { + if (item.isDisabled === true) { + return; + } + // More again while it holds a tab returns its list, as iOS pops More's + // navigation stack: a reselect. + if ( + item.value === value || + (item.value === moreValue && isMoreSelected) + ) { onReselect?.(item.value); - } else { - onChange(item.value); + return; + } + // Leaving the search tab ends search: the field goes with the tab. + if (search != null && value === search.value) { + search.onActiveChange?.(false); } + onChange(item.value); }, - [value, onChange, onReselect], + [value, onChange, onReselect, moreValue, isMoreSelected, search], ); + const isSearchSelected = search != null && search.value === value; + // Every tab a search can be left back to (More's sections included). + const allTabs: ReadonlyArray<{value: string; isDisabled?: boolean}> = + allItems; + // The tab the search tab was chosen from: the leading circle holds it while + // searching, and choosing it leaves the search tab. + // Kept in state and adjusted during render as the selection changes (a + // discarded render leaves no tab behind, as a ref written there would). + const [lastTabValue, setLastTabValue] = useState(null); + if (!isSearchSelected && selectedIndex >= 0 && lastTabValue !== value) { + setLastTabValue(value); + } + // A section inside More: the circle holds More. + const isLastInMore = + hasMore && + (lastTabValue === moreValue || + overflow.some(item => item.value === lastTabValue)); + const previousIndex = isLastInMore + ? MAX_TABS - 1 + : // When the app has disabled it, the enabled tab nearest it. + returnIndex( + items, + items.findIndex(item => item.value === lastTabValue), + ); + // Under a finger, while searching, the leading circle holds the tab search + // was chosen from; under a mouse the search tab is selected in place. + const shownIndex = + isSearchSelected && isTouch ? Math.max(previousIndex, 0) : selectedIndex; + const isSearchActive = isSearchSelected && (search?.isActive ?? false); + const chooseSearch = () => { + if (search == null) { + return; + } + // Under a mouse the field shows active at once, in the accessory's place. + const activates = !isTouch || (search.automaticallyActivates ?? true); + if (isSearchSelected && (!activates || isSearchActive)) { + onReselect?.(search.value); + return; + } + // One commit: the field becomes visible and focusable before the focus, + // inside the tap, which is what lets iOS raise the keyboard. + flushSync(() => { + if (!isSearchSelected) { + onChange(search.value); + } + if (activates) { + search.onActiveChange?.(true); + } + }); + if (activates) { + // The field rises above the keyboard itself, so the page keeps still. + search.fieldRef?.current?.focus({preventScroll: true}); + } + }; + // Until when the search tab's glass keeps its minimized size as the bar + // expands: the search field's Cancel wobbles at that size first. + const searchGlassHoldRef = useRef(0); + // The bar can collapse under a finger: minimizing is on, or a search tab + // is there (choosing it collapses the bar). Only then do the circle and the + // glass's three pieces mount. + const canCollapse = + isTouch && (minimizeBehavior === 'onScrollDown' || search != null); + const minimize = useTabBarMinimize({ + capsuleRef, + hasSearch, + // The search tab's collapse is a touch presentation: under a mouse it + // selects in place. + isSearching: isSearchSelected && isTouch, + isEnabled: minimizeBehavior === 'onScrollDown' && isTouch, + searchGlassHoldRef, + }); + const searchHostRef = useRef(null); + const searchMotion = useTabBarSearch({ + capsuleRef, + hostRef: searchHostRef, + isSearching: isSearchSelected && isTouch, + isActive: isSearchActive, + isTouch, + glassHoldRef: searchGlassHoldRef, + }); + const isSearchCancel = isSearchActive && isTouch; + const circleRef = useRef(null); + const cancelRef = useRef(null); + const mergedCancelRef = useMergedRefs(cancelRef, searchMotion.cancel); const lens = useTabBarLens({ capsuleRef, count, - selectedIndex, + selectedIndex: shownIndex, isTouch, + isMinimized: minimize.isCollapsed, onLift: index => choose(items[index]), + isDisabled: index => items[index]?.isDisabled === true, }); - const mergedCapsuleRef = useMergedRefs(capsuleRef, listRef, lens.capsule); + const mergedCapsuleRef = useMergedRefs( + capsuleRef, + listRef, + lens.capsule, + minimize.capsule, + ); + const mergedCircleRef = useMergedRefs(minimize.circleIcon, circleRef); + const {isCollapsed: isMinimized, expand} = minimize; + const placement: TabBarAccessoryPlacement = isMinimized + ? 'inline' + : 'expanded'; + // What the accessory shows where it is: drawn only when that renders + // something (`false`, `''` or a function returning `null` draw no capsule). + const accessoryContent = + typeof bottomAccessory === 'function' + ? bottomAccessory(placement) + : bottomAccessory; + const hasAccessory = + accessoryContent != null && + typeof accessoryContent !== 'boolean' && + accessoryContent !== ''; + const selectedItem = shownIndex >= 0 ? items[shownIndex] : null; + // The collapsed circle: it expands the bar, or while searching returns to + // the tab it holds. Activated from the keys (or VoiceOver), focus moves on + // to the selected tab once the bar has expanded. + const chooseCircle = () => { + const doc = capsuleRef.current?.ownerDocument; + const hadFocus = doc != null && doc.activeElement === circleRef.current; + // Expanded, the circle is hidden from assistive technology and holds + // no tab stop, so focus it had moves on to the tab the bar shows. + const moveFocusOn = () => { + if (!hadFocus) { + return; + } + const stop = + capsuleRef.current?.querySelector( + '[role="tab"][tabindex="0"]', + ) ?? + doc?.getElementById( + `${baseId}-tab-${selectedIndex >= 0 ? selectedIndex : 0}`, + ); + stop?.focus({preventScroll: true}); + }; + if (isSearchSelected) { + // Back to the tab search was chosen from, a section inside More included. + // A bar mounted on its search tab goes back to the tab the circle shows. + const last = + lastTabValue != null + ? (allItems.find(item => item.value === lastTabValue) ?? { + value: lastTabValue, + }) + : selectedItem; + // A tab the app has disabled is never chosen: the circle holds, and + // returns to, the enabled tab nearest it (see returnIndex). + const back = + last != null && 'isDisabled' in last && last.isDisabled === true + ? selectedItem + : last; + if (back != null) { + flushSync(() => { + // Choosing the tab it holds leaves the search tab, which ends + // search (see choose). + choose(back); + }); + } + moveFocusOn(); + return; + } + flushSync(expand); + moveFocusOn(); + }; + // Cancel ends search: through the field's handle when it has one (it clears, + // blurs and reports), else by blurring the field and reporting. So the + // keyboard goes as the field moves. Focus on Cancel moves on with it: to the + // collapsed bar's circle, or to the tab holding the tab stop once the bar + // expands. + const cancelSearch = () => { + if (search == null) { + return; + } + const doc = cancelRef.current?.ownerDocument; + const hadFocus = doc != null && doc.activeElement === cancelRef.current; + const field = search.fieldRef?.current; + flushSync(() => { + if (field?.cancel != null) { + field.cancel(); + } else { + field?.blur(); + search.onActiveChange?.(false); + } + }); + if (!hadFocus || doc.activeElement !== doc.body) { + return; + } + const circle = circleRef.current; + if (circle != null && circle.tabIndex === 0) { + circle.focus({preventScroll: true}); + } else { + capsuleRef.current + ?.querySelector('[role="tab"][tabindex="0"]') + ?.focus({preventScroll: true}); + } + }; + // Collapsed, the search tab beside the circle stays a tab of its own unless + // it is the one being searched. The tab list goes inert only when none of its + // tabs is reachable. + const isSearchTabReachable = search != null && !(isSearchSelected && isTouch); - const restIndex = Math.max(selectedIndex, 0); + const restIndex = Math.max(shownIndex, 0); const capsuleInset = px(tabBar.itemInset); const itemSize = `calc(${size(count)})`; + // The bar's box, the platter and the tab layer mount only while something + // needs them. Otherwise the tab list takes the box's flex and size, draws its + // own glass and holds the tabs itself. + const isBoxed = canCollapse || hasAccessory || search != null; + const hasPlatter = canCollapse; + return (
-
- {items.map((item, index) => { - const isSelected = index === selectedIndex; - const {start, width, itemStart} = column(index, count); - return ( + {hasAccessory ? ( +
+
+
+
+
+
+
+ {accessoryContent} +
+
+
+ ) : null} +
{ + handleFocus(event); + if (isMinimized) { + expand(); + } + }} + {...stylex.props( + styles.capsule, + !isBoxed && [ + styles.capsuleAlone, + styles.barSize( + px(tabBar.barHeight), + hasSearch ? 'none' : px(naturalWidth(count)), + ), + ], + !hasPlatter && styles.capsuleGlass(HALF_BAR), + hasSearch && styles.capsuleBesideSearch(SEARCH_ROOM), + isMinimized && styles.capsuleMinimized, + )}> + + {canCollapse ? ( + <> +
+
+
+ + ) : null} + + + {items.map((item, index) => { + const isSelected = index === selectedIndex; + const {start, width, itemStart} = column(index, count); + return ( + + ); + })} + + {search != null ? ( - ); - })} - {selectedIndex >= 0 ? ( -
+ ) : null} + {shownIndex >= 0 ? (
+ ref={lens.lens} + aria-hidden + data-tab-bar-lens="" + {...stylex.props( + styles.lens, + !lens.isCopyShown && styles.lensUnder, + styles.box(capsuleInset, capsuleInset, itemSize, ITEM_HEIGHT), + styles.lensRest(lensOffset(restIndex, count)), + )}>
- {lens.isCopyShown ? ( -
+ ref={lens.body} + data-tab-bar-lens-body="" + {...stylex.props(styles.lensBody)}> +
+ {lens.isCopyShown ? (
- {items.map((item, index) => ( - - - - ))} + ref={lens.copy} + data-tab-bar-copy="" + {...stylex.props(styles.copyInner)}> +
+ {items.map((item, index) => ( + + + + ))} +
-
- ) : null} + ) : null} +
+ ) : null} +
+ {items.map((item, index) => + hasBadge(item.badge) ? ( + + {item.badge} + + ) : null, + )}
- ) : null} -
- {items.map((item, index) => - hasBadge(item.badge) ? ( +
+ {canCollapse ? ( +
-
+ ) : null} + + ) : null} + {search != null ? ( +
{ + // Under a mouse Escape leaves the search tab, back to the tab it + // was chosen from, and the accessory returns. + if (isTouch || event.key !== 'Escape' || lastTabValue == null) { + return; + } + // A tab the app has disabled is never chosen: the enabled tab + // nearest it instead (see returnIndex). + const last = allTabs.find(item => item.value === lastTabValue); + const back = + last != null && last.isDisabled !== true + ? last + : items[previousIndex]; + if (back == null) { + return; + } + event.preventDefault(); + flushSync(() => { + choose(back); + }); + capsuleRef.current + ?.querySelector( + '[role="tab"][aria-selected="true"]', + ) + ?.focus({preventScroll: true}); + }} + {...stylex.props( + styles.searchHost, + isTouch + ? [ + styles.accessoryBox( + px(INLINE_FIELD.start), + // The field's width follows the bar's (its box at width 0 is negative). + `calc(100% + ${px(INLINE_FIELD.width)})`, + ACCESSORY_HEIGHT, + ), + styles.accessoryInline(px(INLINE_FIELD.top)), + styles.raised(0, searchMotion.place.lift), + ] + : [ + // Under a mouse, in the accessory's place, at once. + styles.accessoryBox('0px', '100%', ACCESSORY_HEIGHT), + styles.accessoryAbove(px(tabBar.accessoryGap)), + !isSearchSelected && styles.searchHostHidden, + ], + isSearchSelected && !isTouch && styles.searchHostShown, + )}> +
+
+
+
+
+
+ {search.children} +
+
+
+ ) : null} + {search != null && isSearchCancel ? ( + // Active under a finger, the field's Cancel button lies over the + // search circle, outside the tab list (whose tabs stay inert). + // Concentric with it, as UIKit's is, its glyph drawn by the circle. +
); } diff --git a/packages/lab/src/TabBar/TabBarMoreList.tsx b/packages/lab/src/TabBar/TabBarMoreList.tsx new file mode 100644 index 0000000000000..cf295ae17c555 --- /dev/null +++ b/packages/lab/src/TabBar/TabBarMoreList.tsx @@ -0,0 +1,134 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file TabBarMoreList.tsx + * @input React, core's List and Icon, core i18n, and TabBar.tsx's tab + * reading (`overflowItems`) + * @output Exports TabBarMoreList: the More tab's screen, a list of the tabs + * past the fourth (UIKit's `UIMoreListController`) + * @position Lab component beside TabBar (packages/lab/src/TabBar) + * + * SYNC: When modified, update TabBar.doc.mjs, TabBar.test.tsx and + * __tests__/TabBar.interaction.browser.spec.ts. + */ + +import type {ReactNode} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import type {BaseProps} from '@astryxdesign/core'; +import {useTranslator} from '@astryxdesign/core/i18n'; +import {Icon} from '@astryxdesign/core/Icon'; +import {List, ListItem} from '@astryxdesign/core/List'; +import { + colorVars, + fontWeightVars, + radiusVars, + typographyVars, +} from '@astryxdesign/core/theme/tokens.stylex'; +import {rtlStyles} from '@astryxdesign/core/utils'; +import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden'; +import {badgedName, hasBadge, overflowItems} from './TabBar'; + +const styles = stylex.create({ + // A tab's badge, beside the row's chevron, as iOS's More list shows it. + badge: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + boxSizing: 'border-box', + minWidth: 20, + height: 20, + paddingInline: 6, + marginInlineEnd: 8, + borderRadius: radiusVars['--radius-full'], + whiteSpace: 'nowrap', + fontFamily: typographyVars['--font-family-body'], + fontWeight: fontWeightVars['--font-weight-medium'], + fontSize: 13, + fontVariantNumeric: 'tabular-nums', + backgroundColor: colorVars['--color-error'], + color: colorVars['--color-on-error'], + }, +}); + +export interface TabBarMoreListProps extends Omit< + BaseProps, + 'onChange' +> { + /** The same `TabBarItem` elements given to TabBar. */ + children: ReactNode; + /** The selected tab's value: its row reads as selected. */ + value?: string; + /** Called with a row's value when it is chosen. */ + onChoose: (value: string) => void; +} + +/** + * The More tab's screen: a list of the tabs past the fourth. + * + * @example + * ``` + * {tab === 'more' ? ( + * {tabs} + * ) : null} + * {tabs} + * ``` + */ +/* + * WHAT IT DRAWS + * + * With more than five tabs, TabBar shows the first four and a More tab. This is + * what More shows: one row per remaining tab, its icon, its label and a + * disclosure chevron (mirrored right to left), in order. Choosing a row hands + * its value to the app, which shows that section inside More's own navigation + * stack (back to "More"). TabBar keeps More selected meanwhile, as iOS does. + * The app passes the same TabBarItem children it gives TabBar, so the two agree + * on which tabs overflow. + * + * Not here: iOS's Edit, which rearranges the tabs by dragging them onto the + * bar. + */ +export function TabBarMoreList({ + children, + value, + onChoose, + ...props +}: TabBarMoreListProps) { + const t = useTranslator(); + return ( + + {overflowItems(children).map(item => ( + + {item.label} + {badgedName(item, t)} + + ) + } + startContent={item.icon} + endContent={ + <> + {hasBadge(item.badge) ? ( + + {item.badge} + + ) : null} + {/* Right to left it points left, as Core's disclosure rows' do. */} + + + } + isSelected={item.value === value} + isDisabled={item.isDisabled} + onClick={() => onChoose(item.value)} + /> + ))} + + ); +} diff --git a/packages/lab/src/TabBar/TabBarTouch.ts b/packages/lab/src/TabBar/TabBarTouch.ts index 37f1f5f65106f..97a7134e82736 100644 --- a/packages/lab/src/TabBar/TabBarTouch.ts +++ b/packages/lab/src/TabBar/TabBarTouch.ts @@ -14,3 +14,5 @@ */ export {useTabBarLens} from './useTabBarLens'; +export {useTabBarMinimize} from './useTabBarMinimize'; +export {useTabBarSearch} from './useTabBarSearch'; diff --git a/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts b/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts index ffef514db74e7..af94832316162 100644 --- a/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts +++ b/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts @@ -5,13 +5,15 @@ * @input The built "Lab/TabBar" stories, real touch and mouse input on the * harness's phone, and the interaction harness * @output Proofs that TabBar moves like UITabBar on iOS 26.2: the lens's - * slide, its press tracking and drag. Also the mouse presentation. + * slide, its press tracking and drag, minimizing on scroll and the + * bottom accessory. Also the search tab and its field, the More tab, and the + * mouse presentation. * @position Interaction proof for the lab TabBar (TabBar.tsx, * tabBarModel.ts) */ import {expect, test, type Locator, type Page} from '@playwright/test'; -import type {TouchScript} from '@astryxdesign/interaction-harness'; +import type {TouchDriver, TouchScript} from '@astryxdesign/interaction-harness'; import { advanceFrozenPage, animationStoryboard, @@ -23,15 +25,19 @@ import { drag, freezePage, freezePageOn, + freezePageWhen, holdAnimations, installPageFreeze, installTimerGate, markElement, + merge, openStory, + PARTIAL_LIFT_SKIP, PEN_SKIP, releaseAnimations, sampleCurve, sampleRects, + seekAnimations, springStep, startBusyControl, startScreencast, @@ -46,6 +52,7 @@ import { waitForAnimation, waitForRest, webInputDriver, + withFinger, withStorybook, writeEvidence, } from '@astryxdesign/interaction-harness'; @@ -855,6 +862,38 @@ test('when the app keeps the old selection, the lens settles back onto it', asyn expect(await scaleOf(lensBody(page))).toEqual({x: 1, y: 1}); }); +test('a second finger landing mid-drag never moves the lens or changes the choice', async ({ + page, +}) => { + const {driver} = await openBar(page); + test.skip(!driver.partialLifts, PARTIAL_LIFT_SKIP); + const home = await itemCenter(page, 'Home'); + const browse = await itemCenter(page, 'Browse'); + const profile = await itemCenter(page, 'Profile'); + const first = withFinger( + drag(home, browse, {durationMs: 500, restMs: 300}), + 1, + ); + const second = withFinger(tapScript(profile, {holdMs: 100}), 2, 250); + // While both fingers are down, the lens stays with the first. + let farthest = -Infinity; + let twoFingers = 0; + await driver.play(merge(first, second), { + onEvent: async step => { + if (step.fingers.length < 2) { + return; + } + twoFingers += 1; + farthest = Math.max(farthest, await lensCenterX(page)); + }, + }); + expect(twoFingers).toBeGreaterThan(0); + expect(farthest).toBeLessThan(browse.x + 9); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true'); + await waitForRest(page.getByRole('tablist')); + expect(Math.abs((await lensCenterX(page)) - browse.x)).toBeLessThan(0.5); +}); + test('right to left, a drag runs from the first tab leftward and commits where it lifts', async ({ page, }) => { @@ -869,6 +908,168 @@ test('right to left, a drag runs from the first tab leftward and commits where i expect(Math.abs((await lensCenterX(page)) - profile.x)).toBeLessThan(0.5); }); +test('a disabled tab is announced disabled on the tab itself, with its name, in the accessibility tree (spec:AST-085 FR5)', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'reads Chromium’s accessibility tree'); + await openBar(page, 'disabled-tab'); + const cdp = await page.context().newCDPSession(page); + const {nodes} = await cdp.send('Accessibility.getFullAXTree'); + const tabs = nodes + .filter(node => node.ignored !== true && node.role?.value === 'tab') + .map(node => ({ + name: node.name?.value, + disabled: + node.properties?.some( + property => + property.name === 'disabled' && property.value.value === true, + ) ?? false, + })); + expect(tabs).toContainEqual({name: 'Browse', disabled: true}); + expect(tabs.filter(entry => entry.disabled)).toHaveLength(1); +}); + +test('a tap on a disabled tab never moves or lifts the lens (UIKit ignores it)', async ({ + page, + browserName, +}) => { + const {driver} = await openBar(page, 'disabled-tab'); + const home = await itemCenter(page, 'Home'); + const browse = await itemCenter(page, 'Browse'); + // Every frame's lens position while the finger lands on Browse and lifts. + const sampling = sampleRects(lensBody(page), 900); + if (browserName === 'chromium') { + let held: unknown = null; + await driver.play(tapScript(browse, {holdMs: 200}), { + onEvent: async step => { + if (step.type === 'touchStart') { + await page.waitForTimeout(120); + held = await scaleOf(lensBody(page)); + } + }, + }); + expect(held).toEqual({x: 1, y: 1}); + } else { + await driver.tap(browse); + } + const centers = (await sampling).flatMap(sample => + sample.box == null ? [] : [sample.box.x + sample.box.width / 2], + ); + expect(centers.length).toBeGreaterThan(5); + expect( + Math.max(...centers.map(x => Math.abs(x - home.x))), + JSON.stringify(centers), + ).toBeLessThan(0.5); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); +}); + +test('a lift over a disabled tab leaves the selection, and the lens settles back', async ({ + page, +}) => { + const {driver} = await openBar(page, 'disabled-tab'); + const home = await itemCenter(page, 'Home'); + const browse = await itemCenter(page, 'Browse'); + await driver.play(drag(home, browse, {durationMs: 400, restMs: 200})); + // (UIKit's own drag commits a disabled tab; a tap does not. Neither does + // here.) + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + await expect(tab(page, 'Browse')).toHaveAttribute('aria-disabled', 'true'); + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - home.x), { + timeout: 3000, + }) + .toBeLessThan(0.5); +}); + +test('a drag over a disabled tab returns the lens to the selected tab, since the lift there chooses nothing; the disabled tab keeps its paint (AST-085 FR6)', async ({ + page, +}) => { + const {driver, bar} = await openBar(page, 'disabled-tab'); + const home = await itemCenter(page, 'Home'); + const browse = await itemCenter(page, 'Browse'); + const box = await bar.boundingBox(); + if (box == null) { + throw new Error('no bar'); + } + // Where an item sits under the press's flex (about the capsule's centre). + const center = box.x + box.width / 2; + const flexed = (x: number) => center + (x - center) * FLEX_SCALE; + // Just short of Browse's centre: over Browse, a disabled tab. + const over = {x: browse.x - 5, y: browse.y}; + // The lens's farthest point on the way, as it chases the finger. + let farthest = -Infinity; + let held: { + lens: number; + color: string; + weight: string; + accent: string; + } | null = null; + const script = drag(home, over, {durationMs: 500, restMs: 600}); + await driver.play(script, { + onEvent: async (_step, index) => { + farthest = Math.max(farthest, await lensCenterX(page)); + if (index !== script.length - 2) { + return; + } + await page.waitForTimeout(450); + const paint = await page.evaluate(() => { + const item = document.querySelector( + '[data-tab-bar-copy-item="browse"]', + ); + const label = item?.querySelector(':scope > span:last-child'); + const enabled = document.querySelector( + '[data-tab-bar-copy-item="home"]', + ); + return { + color: item == null ? '' : getComputedStyle(item).color, + weight: label == null ? '' : getComputedStyle(label).fontWeight, + accent: enabled == null ? '' : getComputedStyle(enabled).color, + }; + }); + held = {lens: await lensCenterX(page), ...paint}; + }, + }); + const atHold = held as { + lens: number; + color: string; + weight: string; + accent: string; + } | null; + expect(atHold).not.toBeNull(); + // On the way the lens chased the finger away from Home. + expect(farthest).toBeGreaterThan(flexed(home.x) + 30); + // Over Browse it waits on Home, the selected tab: nothing is highlighted + // over a disabled tab. + expect(Math.abs((atHold?.lens ?? Infinity) - flexed(home.x))).toBeLessThan( + 0.5, + ); + // Browse keeps its disabled paint in the lens's copy, not the selected look. + expect(atHold?.color).not.toBe(''); + expect(atHold?.color).not.toBe(atHold?.accent); + expect(atHold?.weight).not.toBe('600'); + // The lift over a disabled tab chooses nothing. + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); +}); + +test('with Reduce Motion the settle back from a disabled tab cross-fades', async ({ + page, +}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver} = await openBar(page, 'disabled-tab'); + const home = await itemCenter(page, 'Home'); + const browse = await itemCenter(page, 'Browse'); + await driver.play(drag(home, browse, {durationMs: 400, restMs: 300})); + // Since the page's own lift, not a read the test makes after it. + const released = await liftedAt(page); + expect(released, 'no lift reached the page').toBeGreaterThan(0); + // The fade starts with the input; what it changes follows a few + // frames later. + await expectCrossFade(page, released); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + assertOpacityOnly(await runningAnimations(page, released)); +}); + test('a lift between two items commits the nearer one (an exact tie goes to the leading one: tabBarModel.test.ts)', async ({ page, }) => { @@ -1498,9 +1699,9 @@ test.describe('a mouse or a pen drags the lens too (spec:AST-094 FR43)', () => { test.use({hasTouch: false, isMobile: false}); /** Opens the default bar under the context's fine pointer. */ - async function openFine(page: Page) { + async function openFine(page: Page, story = 'default') { await expectFinePointer(page); - await openStory(page, storybook(), 'lab-tabbar--default'); + await openStory(page, storybook(), `lab-tabbar--${story}`); await expect(page.getByRole('tablist', {name: 'Sections'})).toBeVisible(); } @@ -1620,6 +1821,32 @@ test.describe('a mouse or a pen drags the lens too (spec:AST-094 FR43)', () => { await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); }); + test('a mouse press dragged over a disabled tab shows the selected tab again, since its release there chooses nothing', async ({ + page, + }) => { + await openFine(page, 'disabled-tab'); + // On screen first: the lens. + await expectPainted(lensBody(page)); + const home = await itemCenter(page, 'Home'); + const library = await mouseDragTo(page, 'Home', 'Library'); + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - library.x)) + .toBeLessThan(1); + // On to Browse, a disabled tab, the button still down. + const browse = await itemCenter(page, 'Browse'); + for (let step = 1; step <= 8; step++) { + await page.mouse.move( + library.x + ((browse.x - library.x) * step) / 8, + library.y, + ); + } + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - home.x)) + .toBeLessThan(1); + await page.mouse.up(); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + }); + test('a mouse press released off the bar chooses nothing, a tab or a reselect: slide-off cancels (family:action-groups FR3)', async ({ page, }) => { @@ -1924,3 +2151,2334 @@ test( expect(Math.max(...frames.map(f => f.flex))).toBeGreaterThan(1.03); }, ); + +// ============================================================================= +// Minimize on scroll +// ============================================================================= + +/** uikitTabBar.ts: `minimizeDistance`, `minimizedSize`, the collapse springs. */ +const MINIMIZE_DISTANCE = 66; +const MINIMIZED_SIZE = 48; +const COLLAPSE_TRAVEL = {response: 0.4, dampingFraction: 0.85}; +const COLLAPSE_SIZE = {response: 0.6, dampingFraction: 0.85}; + +// The bar's tab list carries the flag (the platter mounts only while needed). +const isMinimized = (page: Page) => + page + .locator('[role="tablist"]') + .first() + .evaluate(element => element.hasAttribute('data-tab-bar-minimized')); +const pageScroll = (page: Page) => page.evaluate(() => window.scrollY); +const circle = (page: Page) => page.locator('[data-tab-bar-circle]'); + +/** + * A finger dragging the page content by `dy` px (positive scrolls the page + * down), then resting so nothing coasts. Where injected touches scroll natively + * (Chromium) the page scrolls itself. In WebKit, whose injected touches do not + * scroll, the page is scrolled by the finger's travel after each move while the + * finger is down. The bar reads that scroll the same way. + */ +async function scrollByFinger( + page: Page, + driver: TouchDriver, + dy: number, + {settle = true, restMs}: {settle?: boolean; restMs?: number} = {}, +) { + const from = {x: 200, y: dy > 0 ? 520 : 160}; + const to = {x: from.x, y: from.y - dy}; + const script = drag(from, to, { + durationMs: Math.max(160, Math.abs(dy) * 3), + restMs: restMs ?? (settle ? 150 : 0), + }); + if (driver.drivesNativeGestures) { + await driver.play(script); + } else { + let lastY = from.y; + await driver.play(script, { + realtime: false, + onEvent: async step => { + const finger = step.changed[0]; + if (step.type !== 'touchMove' || finger == null) { + return; + } + const delta = lastY - finger.y; + lastY = finger.y; + await page.evaluate(by => window.scrollBy(0, by), delta); + }, + }); + } + if (settle) { + await page.waitForTimeout(200); + } +} + +/** + * Lays the story out right to left: the story's wrapper carries the + * direction (Storybook's decorator sets `dir` on it), so it is switched + * there. + */ +async function rightToLeft(page: Page) { + await page.evaluate(() => { + document + .querySelector('[role="tablist"]') + ?.closest('[dir]') + ?.setAttribute('dir', 'rtl'); + }); + await page.evaluate( + () => + new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); +} + +/** Resolves once the page has not scrolled for three frames. */ +async function waitForScrollIdle(page: Page) { + await page.evaluate( + () => + new Promise(resolve => { + let last = window.scrollY; + let still = 0; + const tick = () => { + still = window.scrollY === last ? still + 1 : 0; + last = window.scrollY; + if (still >= 3) { + resolve(); + } else { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }), + ); +} + +/** + * Scrolls the page by a finger and reads the running animations in the frame + * after `isChanged` first holds. The finger is still down then: a 200 ms fade + * ends long before it lifts. + */ +async function readDuringScroll( + page: Page, + driver: TouchDriver, + isChanged: () => Promise, + since: number, +) { + let sampled: Awaited> | null = null; + const from = {x: 200, y: 520}; + let lastY = from.y; + await driver.play(drag(from, {x: 200, y: 320}, {durationMs: 600}), { + realtime: driver.drivesNativeGestures, + onEvent: async step => { + const finger = step.changed[0]; + if (step.type !== 'touchMove' || finger == null) { + return; + } + if (!driver.drivesNativeGestures) { + const delta = lastY - finger.y; + lastY = finger.y; + await page.evaluate(by => window.scrollBy(0, by), delta); + } + if (sampled == null && (await isChanged())) { + await page.waitForTimeout(40); + sampled = await runningAnimations(page, since); + } + }, + }); + if (sampled == null) { + throw new Error('it never changed while the finger was down'); + } + return sampled; +} + +async function minimizeBar(page: Page, driver: TouchDriver) { + await scrollByFinger(page, driver, 200); + await expect.poll(() => isMinimized(page)).toBe(true); +} + +test.describe('minimize on scroll', () => { + test('the page root keeps no touch-action of its own while the bar watches the scroll', async ({ + page, + }) => { + const {driver, bar} = await openBar(page, 'minimize-on-scroll'); + const root = () => + page.evaluate(() => ({ + inline: document.documentElement.style.touchAction || '', + computed: getComputedStyle(document.documentElement).touchAction, + })); + expect(await root()).toEqual({inline: '', computed: 'auto'}); + // The watch runs from a finger's pan, which claims no touch: the + // gesture host manages the styles and writes none here. + await scrollByFinger(page, driver, 200); + await expect(bar).toHaveAttribute('data-tab-bar-minimized', ''); + expect(await root()).toEqual({inline: '', computed: 'auto'}); + }); + + test('a finger scrolling the page down minimizes the bar 66 px into the scroll, not before', async ({ + page, + }) => { + const {driver} = await openBar(page, 'minimize-on-scroll'); + await scrollByFinger(page, driver, 45); + const first = await pageScroll(page); + expect(first).toBeGreaterThan(10); + expect(first).toBeLessThan(MINIMIZE_DISTANCE); + expect(await isMinimized(page)).toBe(false); + await scrollByFinger(page, driver, 60); + expect(await pageScroll(page)).toBeGreaterThanOrEqual(MINIMIZE_DISTANCE); + await expect.poll(() => isMinimized(page)).toBe(true); + }); + + test('scrolling back up mid-page leaves it minimized; reaching the top expands it', async ({ + page, + }) => { + const {driver} = await openBar(page, 'minimize-on-scroll'); + await scrollByFinger(page, driver, 300); + await expect.poll(() => isMinimized(page)).toBe(true); + await scrollByFinger(page, driver, -120); + expect(await pageScroll(page)).toBeGreaterThan(0); + expect(await isMinimized(page)).toBe(true); + await scrollByFinger(page, driver, -400); + expect(await pageScroll(page)).toBe(0); + await expect.poll(() => isMinimized(page)).toBe(false); + }); + + test('expanded by a tap, the bar minimizes again only after another 66 px down', async ({ + page, + }) => { + const {driver} = await openBar(page, 'minimize-on-scroll'); + await minimizeBar(page, driver); + await waitForRest(lensBody(page)); + const box = await circle(page).boundingBox(); + if (box == null) { + throw new Error('no circle'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect.poll(() => isMinimized(page)).toBe(false); + // Less than the distance down: still expanded. + const from = await pageScroll(page); + await scrollByFinger(page, driver, 40); + expect((await pageScroll(page)) - from).toBeLessThan(MINIMIZE_DISTANCE); + expect(await isMinimized(page)).toBe(false); + // The rest of the distance minimizes it again. + await scrollByFinger(page, driver, 60); + await expect.poll(() => isMinimized(page)).toBe(true); + }); + + test('where the browser sends no scrollend, the watch ends with the person’s scroll: a script’s scroll after it counts for nothing', async ({ + page, + }) => { + await page.addInitScript(() => { + // A browser without scrollend: no handler property to detect, and + // no event delivered. + // (On the global object the handler is an own property.) + for (const target of [ + window, + Window.prototype, + Document.prototype, + Element.prototype, + HTMLElement.prototype, + ]) { + delete (target as unknown as {onscrollend?: unknown}).onscrollend; + } + window.addEventListener( + 'scrollend', + event => event.stopImmediatePropagation(), + {capture: true}, + ); + }); + const {driver} = await openBar(page, 'minimize-on-scroll'); + expect(await page.evaluate(() => 'onscrollend' in window)).toBe(false); + await scrollByFinger(page, driver, 40); + expect(await isMinimized(page)).toBe(false); + // Past the reader's fallback after the last scroll event. + await page.waitForTimeout(400); + await page.evaluate(() => window.scrollBy(0, 120)); + await page.waitForTimeout(300); + expect(await pageScroll(page)).toBeGreaterThan(MINIMIZE_DISTANCE); + expect(await isMinimized(page)).toBe(false); + }); + + test('a script scrolling the page to the top leaves it minimized', async ({ + page, + }) => { + const {driver} = await openBar(page, 'minimize-on-scroll'); + await minimizeBar(page, driver); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(300); + expect(await pageScroll(page)).toBe(0); + expect(await isMinimized(page)).toBe(true); + }); + + test('a tap on the circle expands the bar, keeping the tab and the scroll', async ({ + page, + }) => { + const {driver} = await openBar(page, 'minimize-on-scroll'); + await minimizeBar(page, driver); + await waitForRest(lensBody(page)); + const scrolled = await pageScroll(page); + const box = await circle(page).boundingBox(); + if (box == null) { + throw new Error('no circle'); + } + expect(box.width).toBeCloseTo(MINIMIZED_SIZE, 0); + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect.poll(() => isMinimized(page)).toBe(false); + expect(await pageScroll(page)).toBe(scrolled); + await expect(tab(page, 'Library')).toHaveAttribute('aria-selected', 'true'); + }); + + test('at rest the lens draws over the glass, under the tabs', async ({ + page, + }) => { + await openBar(page, 'minimize-on-scroll'); + const lens = page.locator('[data-tab-bar-lens]'); + // On screen first: the lens and the glass it must draw over. + await expectPainted(lens); + await expectPainted(page.locator('[data-tab-bar-platter]')); + // Paint order at the lens, topmost first. The bar takes no pointer, so + // hit testing is turned on for this read only. + const order = await lens.evaluate(element => { + const style = document.createElement('style'); + style.textContent = + '.astryx-tab-bar * { pointer-events: auto !important; }'; + document.head.append(style); + const box = element.getBoundingClientRect(); + const stack = document.elementsFromPoint( + box.x + 6, + box.y + box.height / 2, + ); + style.remove(); + const first = (selector: string) => + stack.findIndex(node => node.closest(selector) != null); + return { + lens: first('[data-tab-bar-lens]'), + glass: first('[data-tab-bar-platter]'), + }; + }); + expect(order.lens).toBeGreaterThanOrEqual(0); + expect(order.glass).toBeGreaterThanOrEqual(0); + expect(order.lens, JSON.stringify(order)).toBeLessThan(order.glass); + }); + + test('minimized, the accessibility tree holds no empty tab list beside the circle', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'reads Chromium’s accessibility tree', + ); + const {driver} = await openBar(page, 'minimize-on-scroll'); + await minimizeBar(page, driver); + const cdp = await page.context().newCDPSession(page); + const {nodes} = await cdp.send('Accessibility.getFullAXTree'); + const roles = nodes + .filter(node => node.ignored !== true) + .map(node => node.role?.value); + expect(roles).not.toContain('tablist'); + expect(roles).toContain('button'); + }); + + test('minimized, the circle alone stands for the bar: the selected tab, collapsed; the keys expand it onto that tab', async ({ + page, + }) => { + const {driver} = await openBar(page, 'minimize-on-scroll'); + await minimizeBar(page, driver); + const button = page.getByRole('button', {name: 'Library'}); + await expect(button).toHaveAttribute('aria-expanded', 'false'); + // The tabs are inert: out of reach of assistive technology and focus. + const tabsInert = () => + page + .getByRole('tablist') + .evaluate(list => + [...list.querySelectorAll('[role="tab"]')].map( + element => element.inert, + ), + ); + expect(await tabsInert()).toEqual([true, true, true, true]); + await button.focus(); + await page.keyboard.press('Enter'); + await expect.poll(() => isMinimized(page)).toBe(false); + await expect(tab(page, 'Library')).toBeFocused(); + expect(await tabsInert()).toEqual([false, false, false, false]); + }); + + test('expanded again, the lens carries no copy at rest, and a slide’s copy lines up with the items', async ({ + page, + }) => { + const {driver} = await openBar(page, 'minimize-on-scroll'); + await minimizeBar(page, driver); + await waitForRest(lensBody(page)); + const box = await circle(page).boundingBox(); + if (box == null) { + throw new Error('no circle'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect.poll(() => isMinimized(page)).toBe(false); + await waitForRest(lensBody(page)); + // At rest the selected tab draws its own look: no copy is mounted. + await expect(lensCopy(page)).toHaveCount(0); + // A slide mounts it again, and it starts where the items are: no offset + // or scale left over from the collapse. + await driver.tap(await itemCenter(page, 'Browse')); + await waitForAnimation(lensCopy(page)); + await holdAnimations(page); + const offsets = await page.evaluate(() => + [...document.querySelectorAll('[data-tab-bar-copy-item]')].map(item => { + const value = item.getAttribute('data-tab-bar-copy-item'); + const face = document.querySelector( + `[data-tab-bar-value="${value}"] > span`, + ); + const a = item.getBoundingClientRect(); + const b = face?.getBoundingClientRect(); + return b == null + ? Infinity + : Math.abs(a.left + a.width / 2 - (b.left + b.width / 2)); + }), + ); + await releaseAnimations(page, {finish: true}); + expect(offsets.length).toBeGreaterThan(0); + for (const offset of offsets) { + expect(offset).toBeLessThan(0.5); + } + }); + + test('minimized, the page takes the touches outside the circle', async ({ + page, + }) => { + const {driver} = await openBar(page, 'minimize-on-scroll'); + const profile = await itemCenter(page, 'Profile'); + await minimizeBar(page, driver); + const hit = await page.evaluate(({x, y}) => { + const element = document.elementFromPoint(x, y); + return { + inBar: element?.closest('[role="tablist"]') != null, + onCircle: element?.closest('[data-tab-bar-circle]') != null, + }; + }, profile); + expect(hit).toEqual({inBar: false, onCircle: false}); + }); + + test('a tap on the circle mid-collapse turns the bar around from where it is', async ({ + page, + }) => { + const {driver} = await openBar(page, 'minimize-on-scroll'); + const library = await itemCenter(page, 'Library'); + const body = lensBody(page); + const sampling = sampleRects(body, 1500); + // Just past the trigger, so the page stops scrolling while the bar is still + // collapsing. A tap on a page that is still scrolling only stops it, here + // as on iOS. + await scrollByFinger(page, driver, 80, {settle: false}); + await expect.poll(() => isMinimized(page)).toBe(true); + await waitForScrollIdle(page); + expect( + await body.evaluate(element => + element.getAnimations().some(a => a.playState === 'running'), + ), + ).toBe(true); + const turning = await lensCenterX(page); + const box = await circle(page).boundingBox(); + if (box == null) { + throw new Error('no circle'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect.poll(() => isMinimized(page)).toBe(false); + const samples = (await sampling).flatMap(sample => + sample.box == null + ? [] + : [{t: sample.t, x: sample.box.x + sample.box.width / 2}], + ); + // It was on its way into the circle, then came back without a jump: + // consecutive frames never cover the remaining distance at once. + expect(Math.abs(turning - library.x)).toBeGreaterThan(5); + const steps = samples + .slice(1) + .flatMap((sample, i) => + sample.t - samples[i].t <= 34 ? [sample.x - samples[i].x] : [], + ); + expect( + Math.max(...steps.map(Math.abs)), + JSON.stringify(samples), + ).toBeLessThan(40); + await waitForRest(body); + expect(Math.abs((await lensCenterX(page)) - library.x)).toBeLessThan(0.5); + await expect(tab(page, 'Library')).toHaveAttribute('aria-selected', 'true'); + }); + + test('with Reduce Motion the bar cross-fades into the circle: opacity only, within 200 ms', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver} = await openBar(page, 'minimize-on-scroll'); + const since = await timelineNow(page); + assertCrossFade( + await readDuringScroll(page, driver, () => isMinimized(page), since), + ); + await expect.poll(() => isMinimized(page)).toBe(true); + }); + + test('watching the page’s scroll never holds it: no touch listener on the page’s root path blocks a scroll', async ({ + page, + browserName, + }) => { + // A blocking (non-passive) touchstart, touchmove or wheel listener on the + // root path makes the browser wait for the main thread before every page + // scroll. That is the gesture README's measured limit; the scroll watch's + // listeners are passive. (A finger scrolling through a busy main thread is + // a simulator proof: the harness cannot drive one.) + test.skip( + browserName !== 'chromium', + 'reads listeners through Chromium’s debugger', + ); + const {driver} = await openBar(page, 'minimize-on-scroll'); + // A scroll first, so every listener the scroll watch adds while it tracks a + // touch is in place. + await scrollByFinger(page, driver, 120); + const cdp = await page.context().newCDPSession(page); + const blocking: string[] = []; + for (const expression of [ + 'window', + 'document', + 'document.documentElement', + 'document.body', + ]) { + const {result} = await cdp.send('Runtime.evaluate', {expression}); + if (result.objectId == null) { + throw new Error(`no ${expression}`); + } + const {listeners} = await cdp.send('DOMDebugger.getEventListeners', { + objectId: result.objectId, + }); + for (const listener of listeners) { + if ( + /^(touchstart|touchmove|wheel)$/.test(listener.type) && + !listener.passive + ) { + blocking.push(`${expression}: ${listener.type}`); + } + } + } + expect(blocking).toEqual([]); + }); + + test('right to left, the bar collapses into a circle at the right edge', async ({ + page, + }) => { + const {driver} = await openBar(page, 'minimize-on-scroll'); + await rightToLeft(page); + const width = await page.evaluate(() => window.innerWidth); + await minimizeBar(page, driver); + await waitForRest(lensBody(page)); + const box = await circle(page).boundingBox(); + if (box == null) { + throw new Error('no circle'); + } + // At the right, where the bar starts in a right-to-left layout. The bar's + // 21 px inset plus the 7 px the 48 px circle sits inside the 62 px bar. + expect(width - (box.x + box.width)).toBeCloseTo(28, 0); + expect(box.width).toBeCloseTo(MINIMIZED_SIZE, 0); + }); + + test('minimized, a resize keeps the circle whole: the cap and the lens stay on it', async ({ + page, + }) => { + // A phone in portrait: the capsule narrower than its natural width. + await page.setViewportSize({width: 375, height: 667}); + const {driver} = await openBar(page, 'minimize-on-scroll'); + await minimizeBar(page, driver); + await waitForRest(lensBody(page)); + // Rotated to landscape while minimized: the capsule widens. + await page.setViewportSize({width: 667, height: 375}); + await page.waitForTimeout(200); + const circleBox = await circle(page).boundingBox(); + const endBox = await page + .locator('[data-tab-bar-platter-end]') + .boundingBox(); + const lensBox = await lensBody(page).boundingBox(); + if (circleBox == null || endBox == null || lensBox == null) { + throw new Error('no circle'); + } + // The trailing cap closes on the circle, and the lens sits in it. + expect( + Math.abs(endBox.x + endBox.width - (circleBox.x + circleBox.width)), + ).toBeLessThan(1); + expect( + Math.abs( + lensBox.x + lensBox.width / 2 - (circleBox.x + circleBox.width / 2), + ), + ).toBeLessThan(1); + }); + + test('collapsing carries the lens into the circle on UIKit’s springs while the platter contracts', async ({ + page, + }, testInfo) => { + const {driver} = await openFreezableBar(page, 'minimize-on-scroll'); + const body = lensBody(page); + const restBox = await body.boundingBox(); + const circleBox = await circle(page).boundingBox(); + if (restBox == null || circleBox == null) { + throw new Error('no lens or circle'); + } + const travel = + circleBox.x + circleBox.width / 2 - (restBox.x + restBox.width / 2); + const endScale = MINIMIZED_SIZE / restBox.width; + // The scene freezes on the first frame the collapse runs, however slowly + // the test process reads it: the curves are sampled by their own time. + const collapsing = freezePageWhen( + page, + () => + document + .querySelector('[data-tab-bar-lens-body]') + ?.getAnimations() + .some(animation => animation.playState === 'running') ?? false, + null, + {timeout: 10_000}, + ); + await scrollByFinger(page, driver, 200); + await collapsing; + await waitForAnimation(body, {minProgressMs: 0}); + await holdAnimations(page); + const times = Array.from({length: 61}, (_, i) => i * 16.667); + const bodySamples = await sampleCurve(body, times); + const samples = times.map((t, i) => ({ + t: +t.toFixed(1), + x: bodySamples[i].transform.translateX, + scaleX: bodySamples[i].transform.scaleX, + opacity: bodySamples[i].opacity, + analyticX: travel * springStep(COLLAPSE_TRAVEL, t), + analyticScaleX: 1 + (endScale - 1) * springStep(COLLAPSE_SIZE, t), + })); + await releaseAnimations(page, {finish: true}); + await thawPage(page); + await writeEvidence( + 'curves/tabbar-collapse', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {travel, endScale, samples}, + null, + 1, + ), + }, + testInfo, + ); + for (const sample of samples) { + expect( + Math.abs(sample.x - sample.analyticX), + JSON.stringify(sample), + ).toBeLessThan(0.5); + expect( + Math.abs(sample.scaleX - sample.analyticScaleX), + JSON.stringify(sample), + ).toBeLessThan(0.005); + } + // The lens ends inside the circle, faded out; the platter's pieces have + // become the circle. + expect(samples.at(-1)?.opacity ?? 1).toBeLessThan(0.05); + const pieces = await page + .locator('[data-tab-bar-platter] > div') + .evaluateAll(elements => + elements.map(element => { + const rect = element.getBoundingClientRect(); + return {left: rect.left, right: rect.right}; + }), + ); + const left = Math.min(...pieces.map(piece => piece.left)); + const right = Math.max(...pieces.map(piece => piece.right)); + expect(right - left).toBeCloseTo(MINIMIZED_SIZE, 0); + expect(left).toBeCloseTo(circleBox.x, 0); + }); + + test('the collapse keeps advancing every frame while the main thread is blocked', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'screencast frame evidence is Chromium-only in Playwright (WebKit: the iOS simulator)', + ); + const {driver} = await openBar(page, 'minimize-on-scroll'); + const end = page.locator('[data-tab-bar-platter-end]'); + await markElement(end); + // The compositor's own control rides in the same cast. A stall the + // compositor itself makes on a loaded runner stalls it too. So the move is + // judged against what the compositor presented, not against a fixed bound. + await startBusyControl(page); + const cast = await startScreencast(page); + // Armed before the drag, the page blocks itself in the frame that sees the + // collapse running. One evaluate sees it and blocks, so no runner latency + // between the two can eat the collapse first. The judged part ends where + // the platter settles (settledWithin). + const blocking = blockMainThread(page, 600, {during: end}); + // Just past the trigger. + await driver.play( + drag({x: 200, y: 520}, {x: 200, y: 420}, {durationMs: 300, restMs: 150}), + ); + const block = await blocking; + await page.waitForTimeout(150); + const castFrames = await cast.stop(); + const tracked = await trackMarker(page, castFrames); + const control = await trackMarker(page, castFrames, {rgb: CONTROL_RGB}); + const proof = busyWindowProof( + tracked, + {from: block.from, to: block.to}, + { + axis: 'x', + settledWithin: 2, + // Screencast throughput, not the animation: no display-rate bound. + maxMedianFrameMs: Infinity, + control, + }, + ); + // The harness's verdict against the control: a frozen collapse moves on + // none of the frames the control moved. + expect( + proof.verdict.pass, + `${proof.summary}; ${proof.verdict.failures.join('; ')}`, + ).toBe(true); + }); +}); + +test( + 'storyboard: the bar collapses into its circle, and a tap on the circle expands it', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, bar} = await openBar(page, 'minimize-on-scroll'); + const box = await bar.boundingBox(); + if (box == null) { + throw new Error('no bar'); + } + const clip = { + x: 0, + y: box.y - 12, + width: box.x * 2 + box.width, + height: box.height + 24, + }; + const times = Array.from({length: 25}, (_, i) => i * 33.333); + // Crosses the trigger near the end of the drag, so the storyboard holds + // the collapse from its first frames. + await scrollByFinger(page, driver, 100, {settle: false}); + await expect.poll(() => isMinimized(page)).toBe(true); + const {storyboard: collapse} = await animationStoryboard(page, { + name: 'tabbar-collapse', + clip, + times, + columns: 5, + cellScale: 0.6, + }); + await collapse.write(testInfo); + await releaseAnimations(page, {finish: true}); + const target = await circle(page).boundingBox(); + if (target == null) { + throw new Error('no circle'); + } + await driver.tap({ + x: target.x + target.width / 2, + y: target.y + target.height / 2, + }); + await expect.poll(() => isMinimized(page)).toBe(false); + const {storyboard: expand} = await animationStoryboard(page, { + name: 'tabbar-expand', + clip, + times, + columns: 5, + cellScale: 0.6, + }); + await expand.write(testInfo); + await releaseAnimations(page, {finish: true}); + }, +); + +// ============================================================================= +// Bottom accessory +// ============================================================================= + +/** uikitTabBar.ts: the accessory's springs. */ +const ACCESSORY_COLLAPSE = {response: 0.405, dampingFraction: 0.745}; +const ACCESSORY_EXPAND = {response: 0.329, dampingFraction: 0.731}; +/** A spring step's rate of change at `t` ms, per second. */ +function stepRate( + spring: {response: number; dampingFraction: number}, + t: number, +): number { + return (springStep(spring, t + 0.5) - springStep(spring, t - 0.5)) * 1000; +} + +const accessory = (page: Page) => page.locator('[data-tab-bar-accessory]'); + +/** + * On the page: whether the accessory runs an animation that started at or + * after `since` (timeline ms), or is about to start one. + */ +const accessoryMovesSince = (since: number) => + ( + document.querySelector('[data-tab-bar-accessory]')?.getAnimations() ?? [] + ).some( + animation => + animation.playState === 'running' && + (animation.startTime == null || Number(animation.startTime) >= since), + ); + +test.describe('bottom accessory', () => { + test('moves down inline beside the circle on UIKit’s spring, and back up when the bar expands', async ({ + page, + }, testInfo) => { + const {driver, bar} = await openFreezableBar(page, 'bottom-accessory'); + const barBox = await bar.boundingBox(); + const above = await accessory(page).boundingBox(); + if (barBox == null || above == null) { + throw new Error('no bar or accessory'); + } + // Above the bar, as wide as it, 8 px over it. + expect(above.x).toBeCloseTo(barBox.x, 0); + expect(above.width).toBeCloseTo(barBox.width, 0); + expect(barBox.y - (above.y + above.height)).toBeCloseTo(8, 0); + expect(above.height).toBeCloseTo(48, 0); + + // The scene freezes on the first frame the accessory moves, however slowly + // the test process reads it. Its short spring is held at its start, not + // after it has run its course. + const collapsing = freezePageWhen( + page, + accessoryMovesSince, + await timelineNow(page), + {timeout: 10_000}, + ); + await scrollByFinger(page, driver, 100, {settle: false}); + await collapsing; + await expect(accessory(page)).toHaveAttribute( + 'data-tab-bar-accessory', + 'inline', + ); + await waitForAnimation(accessory(page), {minProgressMs: 0}); + // Only the accessory's own animations. A hold counts every curve from the + // earliest start it holds. The bar's morph can start a millisecond before + // the accessory's, and would make this spring read that much early. + await holdAnimations(page, accessory(page)); + const times = Array.from({length: 46}, (_, i) => i * 16.667); + const collapse = await sampleCurve(accessory(page), times); + await releaseAnimations(page, {finish: true}); + await thawPage(page); + const inline = await accessory(page).boundingBox(); + if (inline == null) { + throw new Error('no accessory'); + } + // Inline: 8 px after the 48 px circle, 7 px from the bar's trailing end. + expect(inline.x - barBox.x).toBeCloseTo(63, 0); + expect(inline.x + inline.width).toBeCloseTo(barBox.x + barBox.width - 7, 0); + // It started where it was above the bar and sprang into place. + const dx = above.x - inline.x; + const dy = above.y - inline.y; + const samples = times.map((t, i) => ({ + t: +t.toFixed(1), + x: collapse[i].transform.translateX, + y: collapse[i].transform.translateY, + analyticX: dx * (1 - springStep(ACCESSORY_COLLAPSE, t)), + analyticY: dy * (1 - springStep(ACCESSORY_COLLAPSE, t)), + })); + await writeEvidence( + 'curves/tabbar-accessory', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {dx, dy, samples}, + null, + 1, + ), + }, + testInfo, + ); + for (const sample of samples) { + expect( + Math.abs(sample.x - sample.analyticX), + JSON.stringify(sample), + ).toBeLessThan(0.5); + expect( + Math.abs(sample.y - sample.analyticY), + JSON.stringify(sample), + ).toBeLessThan(0.5); + } + + // Back up on the expand spring (a tap on the circle expands at once), once + // the collapse would have ended on its own. Finish() above skipped the + // page's animations to their end, not the animator's clock. + await page.waitForTimeout(1000); + const target = await circle(page).boundingBox(); + if (target == null) { + throw new Error('no circle'); + } + // Frozen on the expand's first frame, as the collapse was. + const expanding = freezePageWhen( + page, + accessoryMovesSince, + await timelineNow(page), + {timeout: 10_000}, + ); + await driver.tap({ + x: target.x + target.width / 2, + y: target.y + target.height / 2, + }); + await expanding; + await expect(accessory(page)).toHaveAttribute( + 'data-tab-bar-accessory', + 'expanded', + ); + await waitForAnimation(accessory(page), {minProgressMs: 0}); + await holdAnimations(page, accessory(page)); + const expand = await sampleCurve(accessory(page), times); + await releaseAnimations(page, {finish: true}); + await thawPage(page); + times.forEach((t, i) => { + expect( + Math.abs( + expand[i].transform.translateX + + dx * (1 - springStep(ACCESSORY_EXPAND, t)), + ), + JSON.stringify({t, x: expand[i].transform.translateX}), + ).toBeLessThan(0.5); + }); + const back = await accessory(page).boundingBox(); + expect(back?.x ?? 0).toBeCloseTo(above.x, 0); + expect(back?.y ?? 0).toBeCloseTo(above.y, 0); + }); + + test('expanding, its content stays inside its glass', async ({page}) => { + const {driver} = await openBar(page, 'bottom-accessory'); + await scrollByFinger(page, driver, 100); + await expect(accessory(page)).toHaveAttribute( + 'data-tab-bar-accessory', + 'inline', + ); + await page.waitForTimeout(1000); + const target = await circle(page).boundingBox(); + if (target == null) { + throw new Error('no circle'); + } + await driver.tap({ + x: target.x + target.width / 2, + y: target.y + target.height / 2, + }); + await expect(accessory(page)).toHaveAttribute( + 'data-tab-bar-accessory', + 'expanded', + ); + await waitForAnimation(accessory(page)); + await holdAnimations(page, accessory(page)); + const overhangs: {t: number; overhang: number; visible: number}[] = []; + for (const t of [0, 16.667, 50, 100, 140, 200, 300]) { + await seekAnimations(page, t); + const {overhang, visible} = await accessory(page).evaluate(element => { + // The glass's trailing cap, and "Next" as it shows: its box cut by + // every clipping box between it and the accessory. + const glass = element.firstElementChild as HTMLElement; + const cap = ( + glass.lastElementChild as HTMLElement + ).getBoundingClientRect(); + const next = [...element.querySelectorAll('button')].find( + button => + button.getAttribute('aria-label') === 'Next' || + button.textContent === 'Next', + ); + if (next == null) { + throw new Error('no Next'); + } + let right = next.getBoundingClientRect().right; + let left = next.getBoundingClientRect().left; + for ( + let node = next.parentElement; + node != null && node !== element.parentElement; + node = node.parentElement + ) { + if (getComputedStyle(node).overflow !== 'visible') { + right = Math.min(right, node.getBoundingClientRect().right); + left = Math.max(left, node.getBoundingClientRect().left); + } + } + return {overhang: right - cap.right, visible: right - left}; + }); + overhangs.push({t, overhang, visible}); + } + await releaseAnimations(page, {finish: true}); + // On screen first: "Next" shows through the window while it is measured + // (a window closed to nothing would keep it inside the glass too). + await expectPainted( + accessory(page).getByRole('button', {name: 'Next', exact: true}), + ); + for (const {t, visible} of overhangs.filter(sample => sample.t >= 200)) { + expect(visible, JSON.stringify({t, overhangs})).toBeGreaterThan(0); + } + for (const {t, overhang} of overhangs) { + expect(overhang, JSON.stringify({t, overhangs})).toBeLessThanOrEqual(0.5); + } + }); + + test('turned around mid-move, it keeps its speed (spec:AST-094 FR6)', async ({ + page, + }) => { + const {driver} = await openBar(page, 'bottom-accessory'); + // The latest start among the accessory's running animations, on the + // document timeline: the animator sets each one. + const startOf = () => + accessory(page).evaluate(element => { + const starts = element + .getAnimations() + .filter( + animation => + animation.playState === 'running' && animation.startTime != null, + ) + .map(animation => Number(animation.startTime)); + return starts.length === 0 ? null : Math.max(...starts); + }); + // Just past the trigger, the finger rests long enough that the page doesn't + // fling, since a tap on a flinging page only stops it. Then the circle is + // tapped at once: the turn comes while the accessory still moves. The + // person scrolls most of the way first, and a second drag crosses the 66 px + // trigger near its end. A finger's own scroll keeps a few pixels as slop, + // so how far the first got is read rather than assumed. + await scrollByFinger(page, driver, 50); + const scrolled = await page.evaluate(() => window.scrollY); + expect(scrolled).toBeLessThan(66); + await expect(accessory(page)).toHaveAttribute( + 'data-tab-bar-accessory', + 'expanded', + ); + await scrollByFinger(page, driver, 66 - scrolled + 30, { + settle: false, + restMs: 100, + }); + await expect(accessory(page)).toHaveAttribute( + 'data-tab-bar-accessory', + 'inline', + ); + await expect.poll(startOf).not.toBeNull(); + const collapseStart = Number(await startOf()); + const target = await circle(page).boundingBox(); + if (target == null) { + throw new Error('no circle'); + } + await driver.tap({ + x: target.x + target.width / 2, + y: target.y + target.height / 2, + }); + await expect(accessory(page)).toHaveAttribute( + 'data-tab-bar-accessory', + 'expanded', + ); + await expect.poll(startOf).toBeGreaterThan(collapseStart); + const turnedAt = Number(await startOf()) - collapseStart; + await holdAnimations(page, accessory(page)); + const [first, second] = await sampleCurve(accessory(page), [0, 16.667]); + await releaseAnimations(page, {finish: true}); + // The collapse ran from rest. At the turn it had covered this share of its + // distance. So the offset it turns from gives the distance, and its rate + // there gives its speed. + const share = springStep(ACCESSORY_COLLAPSE, turnedAt); + const collapseSpeed = + (first.transform.translateY / share) * + stepRate(ACCESSORY_COLLAPSE, turnedAt); + // The expand spring from that offset with that speed (a spring's answer + // to a starting speed is its step's rate over its natural rate squared). + const naturalRate = (2 * Math.PI) / ACCESSORY_EXPAND.response; + const predicted = + first.transform.translateY * (1 - springStep(ACCESSORY_EXPAND, 16.667)) + + (collapseSpeed * stepRate(ACCESSORY_EXPAND, 16.667)) / naturalRate ** 2; + expect( + Math.abs(second.transform.translateY - predicted), + JSON.stringify({first, second, turnedAt, collapseSpeed, predicted}), + ).toBeLessThan(0.5); + }); + + test('its content takes taps above the bar and inline, never choosing a tab', async ({ + page, + }) => { + const {driver} = await openBar(page, 'bottom-accessory'); + const play = () => page.getByRole('button', {name: /^(Play|Pause)$/}); + const tapPlay = async () => { + const box = await play().boundingBox(); + if (box == null) { + throw new Error('no play button'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + }; + await tapPlay(); + await expect(page.getByRole('button', {name: 'Pause'})).toBeVisible(); + await expect(tab(page, 'Library')).toHaveAttribute('aria-selected', 'true'); + await minimizeBar(page, driver); + // Inline, the content drops its secondary control. + await expect(page.getByRole('button', {name: 'Next'})).toHaveCount(0); + await waitForRest(accessory(page)); + await tapPlay(); + await expect(page.getByRole('button', {name: 'Play'})).toBeVisible(); + expect(await isMinimized(page)).toBe(true); + await expect(tab(page, 'Library')).toHaveAttribute('aria-selected', 'true'); + }); + + test('it keeps moving inline every frame while the main thread is blocked', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'screencast frame evidence is Chromium-only in Playwright (WebKit: the iOS simulator)', + ); + const {driver} = await openBar(page, 'bottom-accessory'); + const moving = accessory(page); + await markElement(moving); + // The compositor's own control rides in the same cast. A stall the + // compositor itself makes on a loaded runner stalls it too. So the move is + // judged against what the compositor presented, not against a fixed bound. + await startBusyControl(page); + const cast = await startScreencast(page); + // Armed before the scroll, the page blocks itself in the frame that sees + // the move running. One evaluate sees it and blocks, so no runner latency + // between the two can eat the short move first. The judged part ends where + // the accessory settles (settledWithin). + const blocking = blockMainThread(page, 600, {during: moving}); + await scrollByFinger(page, driver, 100, {settle: false}); + const block = await blocking; + await page.waitForTimeout(150); + const castFrames = await cast.stop(); + const tracked = await trackMarker(page, castFrames); + const control = await trackMarker(page, castFrames, {rgb: CONTROL_RGB}); + const proof = busyWindowProof( + tracked, + {from: block.from, to: block.to}, + { + axis: 'y', + settledWithin: 2, + // Screencast throughput, not the animation: no display-rate bound. + maxMedianFrameMs: Infinity, + control, + }, + ); + expect( + proof.verdict.pass, + `${proof.summary}; ${proof.verdict.failures.join('; ')}`, + ).toBe(true); + }); + + test('with Reduce Motion it cross-fades inline: opacity only, within 200 ms', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver} = await openBar(page, 'bottom-accessory'); + const since = await timelineNow(page); + assertCrossFade( + await readDuringScroll( + page, + driver, + async () => + (await accessory(page).getAttribute('data-tab-bar-accessory')) === + 'inline', + since, + ), + ); + await expect(accessory(page)).toHaveAttribute( + 'data-tab-bar-accessory', + 'inline', + ); + }); + + test('right to left, it moves inline to the left of the circle', async ({ + page, + }) => { + const {driver, bar} = await openBar(page, 'bottom-accessory'); + await rightToLeft(page); + const barBox = await bar.boundingBox(); + await minimizeBar(page, driver); + await waitForRest(accessory(page)); + const inline = await accessory(page).boundingBox(); + const dot = await circle(page).boundingBox(); + if (barBox == null || inline == null || dot == null) { + throw new Error('no bar, accessory or circle'); + } + // The circle sits at the right; the accessory ends 8 px before it and + // starts 7 px from the bar's (left) trailing end. + expect(dot.x - (inline.x + inline.width)).toBeCloseTo(8, 0); + expect(inline.x - barBox.x).toBeCloseTo(7, 0); + }); +}); + +test( + 'storyboard: the bottom accessory moves inline as the bar collapses, and back', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, bar} = await openBar(page, 'bottom-accessory'); + const box = await bar.boundingBox(); + if (box == null) { + throw new Error('no bar'); + } + const clip = { + x: 0, + y: box.y - 72, + width: box.x * 2 + box.width, + height: box.height + 84, + }; + const times = Array.from({length: 20}, (_, i) => i * 33.333); + await scrollByFinger(page, driver, 100, {settle: false}); + await expect.poll(() => isMinimized(page)).toBe(true); + const {storyboard: collapse} = await animationStoryboard(page, { + name: 'tabbar-accessory-collapse', + clip, + times, + columns: 5, + cellScale: 0.6, + }); + await collapse.write(testInfo); + await releaseAnimations(page, {finish: true}); + const target = await circle(page).boundingBox(); + if (target == null) { + throw new Error('no circle'); + } + await driver.tap({ + x: target.x + target.width / 2, + y: target.y + target.height / 2, + }); + await expect.poll(() => isMinimized(page)).toBe(false); + const {storyboard: expand} = await animationStoryboard(page, { + name: 'tabbar-accessory-expand', + clip, + times, + columns: 5, + cellScale: 0.6, + }); + await expand.write(testInfo); + await releaseAnimations(page, {finish: true}); + }, +); + +/** uikitTabBar.ts: the search field's morph and its starting squash. */ +const SEARCH_MORPH = {response: 0.244, dampingFraction: 1.108}; +const SEARCH_SQUASH = -3424; +/** Inline beside the circle, and active (spanning the screen but for cancel). */ +const INLINE_FIELD = {start: 67, inset: 7}; +const ACTIVE_FIELD = {start: -13, width: 330}; + +const searchCircle = (page: Page) => page.locator('[data-tab-bar-search]'); +const searchHost = (page: Page) => page.locator('[data-tab-bar-search-field]'); +const searchField = (page: Page) => searchHost(page).locator('> div'); +/** The field glass's trailing cap, its last piece: it carries the width's change. */ +const searchFieldEnd = (page: Page) => + searchField(page).locator('> div[aria-hidden] > div').last(); + +/** + * A damped spring's offset from rest `t` ms after it starts `d0` px away, + * moving at `v0` px/s (closed form; under-, over- and critically damped). + */ +function springOffset( + { + response, + dampingFraction: zeta, + }: {response: number; dampingFraction: number}, + d0: number, + v0: number, + t: number, +) { + const s = t / 1000; + const w = (2 * Math.PI) / response; + if (Math.abs(zeta - 1) < 1e-9) { + return Math.exp(-w * s) * (d0 + (v0 + w * d0) * s); + } + if (zeta < 1) { + const wd = w * Math.sqrt(1 - zeta * zeta); + return ( + Math.exp(-zeta * w * s) * + (d0 * Math.cos(wd * s) + ((v0 + zeta * w * d0) / wd) * Math.sin(wd * s)) + ); + } + const root = w * Math.sqrt(zeta * zeta - 1); + const r1 = -zeta * w + root; + const r2 = -zeta * w - root; + const c2 = (v0 - r1 * d0) / (r2 - r1); + return (d0 - c2) * Math.exp(r1 * s) + c2 * Math.exp(r2 * s); +} + +async function tapSearch(page: Page, driver: TouchDriver) { + const box = await searchCircle(page).boundingBox(); + if (box == null) { + throw new Error('no search circle'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); +} + +test.describe('search tab', () => { + test('the tabs share a narrower capsule and the search circle sits 8 px after it, as UIKit lays them out', async ({ + page, + }) => { + const {bar} = await openBar(page, 'search-tab'); + const capsule = await bar.boundingBox(); + const search = await searchCircle(page).boundingBox(); + const viewport = page.viewportSize(); + if (capsule == null || search == null || viewport == null) { + throw new Error('no bar'); + } + // 402 pt wide: the capsule (21, 290) and the circle (319, 62 × 62). + const room = viewport.width - 2 * 21; + expect(capsule.x).toBeCloseTo(21, 0); + expect(capsule.width).toBeCloseTo(room - 70, 0); + expect(search.x - (capsule.x + capsule.width)).toBeCloseTo(8, 0); + expect(search.width).toBeCloseTo(62, 0); + expect(search.height).toBeCloseTo(62, 0); + expect(search.y).toBeCloseTo(capsule.y, 0); + // It is the tablist's last tab. + await expect(page.getByRole('tab').last()).toHaveAccessibleName('Search'); + }); + + test('a tap on it selects it and raises its field, the bar collapsing to a circle with the tab it came from; cancel returns there', async ({ + page, + browserName, + }) => { + const {driver} = await openBar(page, 'search-tab'); + const box = await searchCircle(page).boundingBox(); + if (box == null) { + throw new Error('no search circle'); + } + const at = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + if (browserName === 'chromium') { + // A finger held on the search circle never presses the lens. (Only + // Chromium's touches can be read mid-script; WebKit's scripted hold + // reads as a long press, which fires no click.) + const playing = driver.play(tapScript(at, {holdMs: 200})); + await page.waitForTimeout(100); + expect((await scaleOf(lensBody(page))).x).toBeCloseTo(1, 3); + await playing; + } else { + await driver.tap(at); + } + + // Selected and active at once: the search circle is now the field's + // cancel button. + await expect(page.getByRole('button', {name: 'Cancel'})).toBeVisible(); + await expect.poll(() => isMinimized(page)).toBe(true); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'active', + ); + // The tap focused the field itself (iOS raises the keyboard for that). + await expect(page.getByRole('searchbox', {name: 'Search'})).toBeFocused(); + // The circle holds Home, the tab the search tab was chosen from. + await expect(circle(page).locator('svg')).toHaveCount(1); + await waitForRest(searchField(page)); + const field = await searchField(page).boundingBox(); + const viewport = page.viewportSize(); + if (field == null || viewport == null) { + throw new Error('no field'); + } + expect(field.x).toBeCloseTo(8, 0); + expect(field.width).toBeCloseTo(viewport.width - 8 - 48 - 8 - 8, 0); + + await page.getByRole('button', {name: 'Cancel'}).click(); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + await expect.poll(() => isMinimized(page)).toBe(false); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'inline', + ); + await expect + .poll(() => + searchHost(page).evaluate(element => (element as HTMLElement).inert), + ) + .toBe(true); + }); + + test('while the field is active under a finger, the leading circle it covers takes no focus and leaves the accessibility tree', async ({ + page, + }) => { + const {driver} = await openBar(page, 'search-tab'); + await tapSearch(page, driver); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'active', + ); + await waitForRest(searchHost(page)); + // On screen first: the field and the circle it covers. + await expectPainted(searchHost(page)); + await expectPainted(circle(page)); + // The field covers the circle: a tap at its centre lands in the field. + const box = await circle(page).boundingBox(); + if (box == null) { + throw new Error('no circle'); + } + expect( + await page.evaluate( + ([x, y]) => + document + .elementFromPoint(x, y) + ?.closest('[data-tab-bar-search-field]') != null, + [box.x + box.width / 2, box.y + box.height / 2], + ), + ).toBe(true); + // So the circle takes no focus, and it sits in an inert subtree, which + // browsers leave out of the accessibility tree. + expect( + await circle(page).evaluate(element => { + (element as HTMLElement).focus(); + return document.activeElement === element; + }), + ).toBe(false); + expect( + await circle(page).evaluate( + element => element.closest('[inert]') != null, + ), + ).toBe(true); + }); + + test('the field moves and widens on UIKit’s spring, squashing as it starts', async ({ + page, + }, testInfo) => { + const {driver} = await openBar(page, 'search-tab'); + await tapSearch(page, driver); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'active', + ); + // Held as soon as the tap commits: in software-rendered WebKit the first + // frame of this many glass layers can take a second. Only the field's own + // animations are held, so their start is the sampling origin, whatever + // frame the bar's other layers started in. + await holdAnimations(page, searchField(page)); + const times = Array.from({length: 31}, (_, i) => i * 16.667); + const moved = await sampleCurve(searchField(page), times); + const widened = await sampleCurve(searchFieldEnd(page), times); + await releaseAnimations(page, {finish: true}); + const host = await searchHost(page).boundingBox(); + if (host == null) { + throw new Error('no field host'); + } + // From its inline place, 67 px into the bar and 7 px from its end, to the + // active one. The active place is 8 px from the screen's edge and 330 px + // wide on this screen. + const dx = INLINE_FIELD.start - ACTIVE_FIELD.start; + const dw = host.width - (host.width + 44); + const samples = times.map((t, i) => ({ + t: +t.toFixed(1), + x: moved[i].transform.translateX, + analyticX: dx * (1 - springStep(SEARCH_MORPH, t)), + end: widened[i].transform.translateX, + analyticEnd: springOffset(SEARCH_MORPH, dw, SEARCH_SQUASH, t), + })); + await writeEvidence( + 'curves/tabbar-search', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {dx, dw, samples}, + null, + 1, + ), + }, + testInfo, + ); + for (const sample of samples) { + expect( + Math.abs(sample.x - sample.analyticX), + JSON.stringify(sample), + ).toBeLessThan(0.5); + expect( + Math.abs(sample.end - sample.analyticEnd), + JSON.stringify(sample), + ).toBeLessThan(0.5); + } + // The squash: the width first shrinks below where it started. + expect(Math.min(...samples.map(sample => sample.end))).toBeLessThan( + dw - 10, + ); + }); + + test('with automaticallyActivates={false} the field waits inline until it is tapped', async ({ + page, + }) => { + const {driver} = await openBar(page, 'search-tab-waits-inline'); + await tapSearch(page, driver); + await expect(tab(page, 'Search')).toHaveAttribute('aria-selected', 'true'); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'inline', + ); + await expect( + page.getByRole('searchbox', {name: 'Search'}), + ).not.toBeFocused(); + const field = await page + .getByRole('searchbox', {name: 'Search'}) + .boundingBox(); + if (field == null) { + throw new Error('no field'); + } + await driver.tap({ + x: field.x + field.width / 2, + y: field.y + field.height / 2, + }); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'active', + ); + await expect(page.getByRole('searchbox', {name: 'Search'})).toBeFocused(); + }); + + test('a main thread blocked 120 ms after the tap still leaves the field focused by the tap itself', async ({ + page, + }) => { + const {driver} = await openBar(page, 'search-tab'); + // The page blocks its main thread for 600 ms, 120 ms after the search tab + // is selected. The focus that raises iOS's keyboard happens in the tap, + // before it. + await page.evaluate(() => { + const log: number[] = []; + (window as unknown as {__block: number[]}).__block = log; + // Selected and active at once, the field gets its Cancel button. + const observer = new MutationObserver(() => { + if (document.querySelector('[data-tab-bar-search-cancel]') == null) { + return; + } + observer.disconnect(); + log.push( + document.activeElement?.getAttribute('aria-label') === 'Search' + ? 1 + : 0, + ); + setTimeout(() => { + const until = performance.now() + 600; + while (performance.now() < until) { + // Busy. + } + log.push(2); + }, 120); + }); + observer.observe(document.body, {childList: true, subtree: true}); + }); + await tapSearch(page, driver); + await expect + .poll(() => + page.evaluate(() => (window as unknown as {__block: number[]}).__block), + ) + .toEqual([1, 2]); + await expect(page.getByRole('searchbox', {name: 'Search'})).toBeFocused(); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'active', + ); + }); + + test('with Reduce Motion the field cross-fades into place: opacity only, within 200 ms', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver} = await openBar(page, 'search-tab'); + const since = await timelineNow(page); + await tapSearch(page, driver); + // The fade starts with the input; what it changes follows a few + // frames later. + await expectCrossFade(page, since); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'active', + ); + assertOpacityOnly(await runningAnimations(page, since)); + }); + + test.describe('under a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('with Reduce Motion the search tab’s field appears at once', async ({ + page, + }) => { + await expectFinePointer(page); + await page.emulateMedia({reducedMotion: 'reduce'}); + await openStory(page, storybook(), 'lab-tabbar--search-tab'); + await expect(page.getByRole('tablist', {name: 'Sections'})).toBeVisible(); + const since = await timelineNow(page); + await page.getByRole('tab', {name: 'Search'}).click(); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'above', + ); + await expectNoMotion(page, since); + }); + }); + + test('while searching, the keys never land on a faded-out tab', async ({ + page, + }) => { + const {driver} = await openBar(page, 'search-tab-waits-inline'); + await tapSearch(page, driver); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'inline', + ); + // The search tab has faded out. + await waitForRest(searchCircle(page)); + await page.getByRole('searchbox', {name: 'Search'}).focus(); + await page.keyboard.press('Shift+Tab'); + const landed = await page.evaluate(() => { + const focused = document.activeElement; + if (focused == null || focused === document.body) { + return {opacity: 1, name: 'body'}; + } + let opacity = 1; + for ( + let at: Element | null = focused; + at != null; + at = at.parentElement + ) { + opacity *= Number(getComputedStyle(at).opacity); + } + return { + opacity, + name: focused.getAttribute('aria-label') ?? focused.textContent ?? '', + }; + }); + expect(landed.opacity, JSON.stringify(landed)).toBeGreaterThan(0.5); + }); + + test('while searching, a tap on the leading circle returns to the tab it holds', async ({ + page, + }) => { + // The field waits inline beside the circle (a focused field covers the + // bar's row, under the keyboard). + const {driver} = await openBar(page, 'search-tab-waits-inline'); + await tapSearch(page, driver); + await expect(tab(page, 'Search')).toHaveAttribute('aria-selected', 'true'); + await expect.poll(() => isMinimized(page)).toBe(true); + // The circle is one button named after the tab it holds. + const held = page.locator('[data-tab-bar-circle]'); + await expect(held).toHaveAccessibleName('Home'); + await waitForRest(lensBody(page)); + const box = await held.boundingBox(); + if (box == null) { + throw new Error('no circle'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + await expect.poll(() => isMinimized(page)).toBe(false); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'inline', + ); + }); + + test('searching from a disabled tab, the leading circle holds the enabled tab nearest it and returns there', async ({ + page, + }) => { + const {driver} = await openBar(page, 'search-from-disabled-tab'); + await tapSearch(page, driver); + await expect(tab(page, 'Search')).toHaveAttribute('aria-selected', 'true'); + await expect.poll(() => isMinimized(page)).toBe(true); + const held = page.locator('[data-tab-bar-circle]'); + await expect(held).toHaveAccessibleName('Library'); + await waitForRest(lensBody(page)); + const box = await held.boundingBox(); + if (box == null) { + throw new Error('no circle'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect(tab(page, 'Library')).toHaveAttribute('aria-selected', 'true'); + await expect.poll(() => isMinimized(page)).toBe(false); + }); + + test('right to left, the search circle sits at the left end and the field spans from the right', async ({ + page, + }) => { + const {driver, bar} = await openBar(page, 'search-tab'); + await rightToLeft(page); + const viewport = page.viewportSize(); + const capsule = await bar.boundingBox(); + const search = await searchCircle(page).boundingBox(); + if (capsule == null || search == null || viewport == null) { + throw new Error('no bar'); + } + expect(search.x).toBeCloseTo(21, 0); + expect(capsule.x - (search.x + search.width)).toBeCloseTo(8, 0); + expect(viewport.width - (capsule.x + capsule.width)).toBeCloseTo(21, 0); + await tapSearch(page, driver); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'active', + ); + await waitForRest(searchField(page)); + const field = await searchField(page).boundingBox(); + if (field == null) { + throw new Error('no field'); + } + // 8 px from the right edge, the cancel button's room on the left. + expect(viewport.width - (field.x + field.width)).toBeCloseTo(8, 0); + expect(field.width).toBeCloseTo(viewport.width - 8 - 48 - 8 - 8, 0); + }); + + test.describe('under a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('the search tab selects in place: the bar stays expanded and focus lands in the field, with no animator writes', async ({ + page, + }) => { + await expectFinePointer(page); + await openStory(page, storybook(), 'lab-tabbar--search-tab'); + await expect(page.getByRole('tablist', {name: 'Sections'})).toBeVisible(); + const since = await timelineNow(page); + await page.getByRole('tab', {name: 'Search'}).click(); + await expect(page.getByRole('searchbox', {name: 'Search'})).toBeFocused(); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'above', + ); + // One search landmark: the field's own. The bar's host is none. + await expect(page.getByRole('search')).toHaveCount(1); + expect(await isMinimized(page)).toBe(false); + // Nothing the animator plays (CSS transitions aside). + expect( + await page.evaluate( + since => + document + .getAnimations() + .filter( + animation => + !(animation instanceof CSSTransition) && + !(animation instanceof CSSAnimation) && + (animation.startTime == null || + Number(animation.startTime) >= since), + ).length, + since, + ), + ).toBe(0); + // Escape returns to the tab search was chosen from. + await page.keyboard.press('Escape'); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + await expect(tab(page, 'Home')).toBeFocused(); + // The keys choose it too, focus landing in the field. + await tab(page, 'Search').focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('searchbox', {name: 'Search'})).toBeFocused(); + }); + + test('searching from a disabled tab, Escape returns to the enabled tab nearest it, never the disabled one', async ({ + page, + }) => { + await expectFinePointer(page); + await openStory( + page, + storybook(), + 'lab-tabbar--search-from-disabled-tab', + ); + await expect(page.getByRole('tablist', {name: 'Sections'})).toBeVisible(); + await page.getByRole('tab', {name: 'Search'}).click(); + await expect(page.getByRole('searchbox', {name: 'Search'})).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(tab(page, 'Library')).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'false'); + }); + }); + + test('minimized, both circles stay and the accessory ends 8 px before the search circle', async ({ + page, + }) => { + const {driver, bar} = await openBar(page, 'search-tab'); + const barBox = await bar.boundingBox(); + const search = await searchCircle(page).boundingBox(); + if (barBox == null || search == null) { + throw new Error('no bar'); + } + await minimizeBar(page, driver); + const glass = searchCircle(page).locator('> span').first(); + await waitForRest(glass); + await waitForRest(accessory(page)); + const shrunk = await glass.boundingBox(); + const inline = await accessory(page).boundingBox(); + if (shrunk == null || inline == null) { + throw new Error('no glass'); + } + // The search circle shrinks to 48 about its centre (326, 798 on iOS). + expect(shrunk.width).toBeCloseTo(MINIMIZED_SIZE, 0); + expect(shrunk.x + shrunk.width / 2).toBeCloseTo( + search.x + search.width / 2, + 0, + ); + expect(shrunk.y + shrunk.height / 2).toBeCloseTo( + search.y + search.height / 2, + 0, + ); + // The accessory sits between the circles: 63 px into the bar, ending + // 8 px before the search circle (84 → 318 on iOS, 234 wide). + expect(inline.x - barBox.x).toBeCloseTo(63, 0); + expect(shrunk.x - (inline.x + inline.width)).toBeCloseTo(8, 0); + }); +}); + +test( + 'storyboard: the search tab raises its field as the bar collapses, and cancel brings the tabs back', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, bar} = await openBar(page, 'search-tab'); + const box = await bar.boundingBox(); + if (box == null) { + throw new Error('no bar'); + } + const clip = { + x: 0, + y: box.y - 72, + width: box.x * 2 + box.width + 62, + height: box.height + 84, + }; + const times = Array.from({length: 20}, (_, i) => i * 33.333); + await tapSearch(page, driver); + await expect(searchHost(page)).toHaveAttribute( + 'data-tab-bar-search-field', + 'active', + ); + const {storyboard: raise} = await animationStoryboard(page, { + name: 'tabbar-search-activate', + clip, + times, + columns: 5, + cellScale: 0.6, + }); + await raise.write(testInfo); + await releaseAnimations(page, {finish: true}); + await page.getByRole('button', {name: 'Cancel'}).click(); + await expect.poll(() => isMinimized(page)).toBe(false); + const {storyboard: cancel} = await animationStoryboard(page, { + name: 'tabbar-search-cancel', + clip, + times, + columns: 5, + cellScale: 0.6, + }); + await cancel.write(testInfo); + await releaseAnimations(page, {finish: true}); + }, +); + +test.describe('More tab', () => { + test('six tabs: the bar shows the first four and More at the five-tab layout, as UIKit does', async ({ + page, + }) => { + await openBar(page, 'more-tab'); + const tabs = page.getByRole('tab'); + await expect(tabs).toHaveCount(5); + await expect(tabs.last()).toHaveAccessibleName('More'); + // iOS 26.2 on a 402 pt screen: five 77 pt items at x 25, 93.75, 162.5, + // 231.25 and 300. + const viewport = page.viewportSize(); + const lefts: number[] = []; + for (let index = 0; index < 5; index += 1) { + const box = await tabs.nth(index).locator('span').first().boundingBox(); + if (box == null) { + throw new Error('no item'); + } + // Within the item-width fit's 1 px (tabBar.ts `itemWidth`). + expect(Math.abs(box.width - 77)).toBeLessThan(1); + lefts.push(box.x); + } + if (viewport?.width === 402) { + [25, 93.75, 162.5, 231.25, 300].forEach((x, index) => { + expect(Math.abs(lefts[index] - x)).toBeLessThan(1); + }); + } + }); + + test('with Reduce Motion a tap on More cross-fades the lens', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver} = await openBar(page, 'more-tab'); + const more = await itemCenter(page, 'More'); + const since = await timelineNow(page); + await driver.tap(more); + // The fade starts with the input; what it changes follows a few + // frames later. + await expectCrossFade(page, since); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + assertOpacityOnly(await runningAnimations(page, since)); + // Nothing travels, so the lens carries no copy: it lands on More, and + // More draws its own selected look. + await waitForRest(lensBody(page)); + await expect(lensCopy(page)).toHaveCount(0); + expect(Math.abs((await lensCenterX(page)) - more.x)).toBeLessThan(0.5); + expect(await labelWeight(page, 'More')).toBe('600'); + }); + + test('a section chosen in More opens inside it, More staying selected; More again returns to the list', async ({ + page, + }) => { + const {driver} = await openBar(page, 'more-tab'); + const more = await itemCenter(page, 'More'); + await driver.tap(more); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + const settings = page.getByRole('button', {name: /Settings/}); + await expect(settings).toBeVisible(); + await settings.click(); + await expect(page.getByRole('heading', {name: 'Settings'})).toBeVisible(); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + await waitForRest(page.getByRole('tablist')); + expect(Math.abs((await lensCenterX(page)) - more.x)).toBeLessThan(0.5); + await driver.tap(more); + await expect(page.getByRole('heading', {name: 'More'})).toBeVisible(); + }); + + test('right to left, the More list’s disclosure chevrons point left, as Core’s disclosure rows’ do', async ({ + page, + }) => { + const {driver} = await openBar(page, 'more-tab'); + await rightToLeft(page); + await driver.tap(await itemCenter(page, 'More')); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + const settings = page.getByRole('button', {name: /Settings/}); + await expect(settings).toBeVisible(); + // On screen first: the row's chevron. + await expectPainted( + page.locator('li', {has: settings}).locator('svg').last(), + ); + // The row's last glyph is its chevron (beside the label's button): flipped + // on it or a wrapper. + const transform = await settings.evaluate(button => { + const row = button.closest('li') ?? button; + const glyphs = [...row.querySelectorAll('svg')]; + for ( + let node: Element | null = glyphs[glyphs.length - 1] ?? null; + node != null && node !== row; + node = node.parentElement + ) { + const value = getComputedStyle(node).transform; + if (value !== 'none') { + return value; + } + } + return 'none'; + }); + expect(transform).toBe('matrix(-1, 0, 0, 1, 0, 0)'); + }); + + test.describe('under a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('More works the same, the lens on the theme’s motion', async ({ + page, + }) => { + await expectFinePointer(page); + await openStory(page, storybook(), 'lab-tabbar--more-tab'); + const more = await itemCenter(page, 'More'); + await tab(page, 'More').click(); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + await page.getByRole('button', {name: /Settings/}).click(); + await expect(page.getByRole('heading', {name: 'Settings'})).toBeVisible(); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - more.x)) + .toBeLessThan(0.5); + await expectCssSlide(page, {isRunning: false}); + }); + }); +}); + +test('with a search tab, a section opened inside More stays in the search circle, as More', async ({ + page, +}) => { + const {driver} = await openBar(page, 'more-tab-with-search'); + await driver.tap(await itemCenter(page, 'More')); + const settings = await page + .getByRole('button', {name: /Settings/}) + .boundingBox(); + if (settings == null) { + throw new Error('no Settings row'); + } + await driver.tap({ + x: settings.x + settings.width / 2, + y: settings.y + settings.height / 2, + }); + await expect(page.getByRole('heading', {name: 'Settings'})).toBeVisible(); + await tapSearch(page, driver); + const held = page.locator('[data-tab-bar-circle]'); + await expect(held).toHaveAccessibleName('More'); + await waitForRest(lensBody(page)); + const box = await held.boundingBox(); + if (box == null) { + throw new Error('no circle'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + // Back inside More, on the section it held. + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('heading', {name: 'Settings'})).toBeVisible(); +}); + +/** + * Before the page loads, `window.visualViewport` becomes one the test drives. + * `__keyboard(cover)` shrinks it from the bottom with one `resize`, as Mobile + * Safari reports a software keyboard. Mobile Safari has no VirtualKeyboard API, + * so the helper removes it. With `virtualKeyboardApi`, the page gets Chrome on + * Android's `navigator.virtualKeyboard`, and `__chromeKeyboard(cover)` sets its + * rect with one `geometrychange`. `__listeners()` counts the keyboard listeners + * attached to either. + */ +async function installKeyboard( + page: Page, + {virtualKeyboardApi = false}: {virtualKeyboardApi?: boolean} = {}, +) { + await page.addInitScript(withApi => { + let listeners = 0; + const counted = (target: T, types: string[]): T => { + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + target.addEventListener = (type: string, ...rest: [never, never?]) => { + if (types.includes(type)) { + listeners++; + } + add(type, ...rest); + }; + target.removeEventListener = (type: string, ...rest: [never, never?]) => { + if (types.includes(type)) { + listeners--; + } + remove(type, ...rest); + }; + return target; + }; + const w = window as unknown as { + __keyboard: (cover: number) => void; + __chromeKeyboard: (cover: number) => void; + __listeners: () => number; + }; + w.__listeners = () => listeners; + if (withApi) { + const keyboard = counted( + Object.assign(new EventTarget(), { + overlaysContent: false, + boundingRect: new DOMRect(0, 0, 0, 0), + }), + ['geometrychange'], + ); + Object.defineProperty(navigator, 'virtualKeyboard', { + configurable: true, + value: keyboard, + }); + w.__chromeKeyboard = cover => { + keyboard.boundingRect = new DOMRect( + 0, + window.innerHeight - cover, + window.innerWidth, + cover, + ); + keyboard.dispatchEvent(new Event('geometrychange')); + }; + } else { + Reflect.deleteProperty(Navigator.prototype, 'virtualKeyboard'); + Reflect.deleteProperty(navigator, 'virtualKeyboard'); + } + const viewport = counted( + Object.assign(new EventTarget(), { + height: window.innerHeight, + width: window.innerWidth, + offsetTop: 0, + offsetLeft: 0, + pageTop: 0, + pageLeft: 0, + scale: 1, + }), + ['resize'], + ); + Object.defineProperty(window, 'visualViewport', { + configurable: true, + get: () => viewport, + }); + w.__keyboard = cover => { + viewport.height = window.innerHeight - cover; + viewport.dispatchEvent(new Event('resize')); + }; + }, virtualKeyboardApi); +} + +/** iPhone 17 Pro's keyboard in Mobile Safari, px of the 874 px screen. */ +const KEYBOARD = 335; + +const showKeyboard = async (page: Page, cover = KEYBOARD) => + page.evaluate( + c => (window as unknown as {__keyboard: (c: number) => void}).__keyboard(c), + cover, + ); +const reportChromeKeyboard = async (page: Page, cover = KEYBOARD) => + page.evaluate( + c => + ( + window as unknown as {__chromeKeyboard: (c: number) => void} + ).__chromeKeyboard(c), + cover, + ); +const keyboardListeners = async (page: Page) => + page.evaluate(() => + (window as unknown as {__listeners: () => number}).__listeners(), + ); +const overlaysContent = async (page: Page) => + page.evaluate( + () => + (navigator as unknown as {virtualKeyboard: {overlaysContent: boolean}}) + .virtualKeyboard.overlaysContent, + ); + +/** Whether `locator` and its subtree run no animation of `translate`. */ +const translatesInPlace = async (locator: Locator) => + locator.evaluate(element => + element + .getAnimations() + .every( + animation => + !(animation.effect as KeyframeEffect) + .getKeyframes() + .some(frame => 'translate' in frame || 'transform' in frame), + ), + ); + +test.describe('search tab, active above the keyboard', () => { + test('under a finger the field and its cancel button sit 8 px above the keyboard, each report committed once with no ride', async ({ + page, + }) => { + await installKeyboard(page); + const {driver} = await openBar(page, 'search-tab'); + expect(await keyboardListeners(page)).toBe(0); + await tapSearch(page, driver); + await expect(page.getByRole('searchbox', {name: 'Search'})).toBeFocused(); + const cancel = page.getByRole('button', {name: 'Cancel'}); + await expect(cancel).toHaveAttribute('data-tab-bar-search-cancel', ''); + // Watching the keyboard only now, while the field is active. + expect(await keyboardListeners(page)).toBeGreaterThan(0); + await waitForRest(searchFieldEnd(page)); + await showKeyboard(page); + const top = await page.evaluate(k => window.innerHeight - k, KEYBOARD); + // One commit: the next frame already shows the resting place. + await page.evaluate( + () => new Promise(resolve => requestAnimationFrame(() => resolve(null))), + ); + const host = await searchHost(page).boundingBox(); + const button = await cancel.boundingBox(); + if (host == null || button == null) { + throw new Error('no field'); + } + expect(host.y + host.height).toBeCloseTo(top - 8, 0); + // The button's platter (the minimized circle's size) is level with the + // field: their centres meet. + expect(button.y + button.height / 2).toBeCloseTo( + host.y + host.height / 2, + 0, + ); + // UIKit's cancel button sits 8 px from the screen's edge. + const width = await page.evaluate(() => window.innerWidth); + const platter = (button.width - 48) / 2; + expect(width - (button.x + button.width - platter)).toBeCloseTo(8, 0); + expect(await translatesInPlace(searchHost(page))).toBe(true); + expect(await translatesInPlace(cancel)).toBe(true); + // The keyboard goes down after a cancel: the place holds until it is + // reported down, then the bar is back. + await cancel.click(); + await showKeyboard(page, 0); + await expect.poll(() => keyboardListeners(page)).toBe(0); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + }); + + test('Cancel back to the tab search was chosen from: the field holds above the keyboard until it is reported down (spec:AST-099 FR5)', async ({ + page, + }) => { + await installKeyboard(page); + const {driver} = await openBar(page, 'search-tab'); + await tapSearch(page, driver); + await expect(page.getByRole('searchbox', {name: 'Search'})).toBeFocused(); + await waitForRest(searchFieldEnd(page)); + await expectPainted(searchHost(page)); + await showKeyboard(page); + const top = await page.evaluate(k => window.innerHeight - k, KEYBOARD); + const bottom = async () => { + await page.evaluate( + () => + new Promise(resolve => requestAnimationFrame(() => resolve(null))), + ); + const host = await searchHost(page).boundingBox(); + return host == null ? null : Math.round(host.y + host.height); + }; + expect(await bottom()).toBe(Math.round(top - 8)); + // The story's Cancel returns to the tab search was chosen from. + await page.getByRole('button', {name: 'Cancel'}).click(); + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + // The keyboard is still up: the field keeps its place above it. + expect(await bottom()).toBe(Math.round(top - 8)); + // Reported down, the hold ends and the bar is back. + await showKeyboard(page, 0); + await expect.poll(() => keyboardListeners(page)).toBe(0); + // On screen first: the bar and the field, then the field's new place. A + // field gone from the page is not at the raised place either. + await expectPainted(page.getByRole('tablist')); + await expectPainted(searchHost(page)); + const rested = await bottom(); + expect(rested).not.toBeNull(); + expect(rested).not.toBe(Math.round(top - 8)); + }); + + test('in Chrome on Android too, the field rides the visual viewport and leaves the app’s keyboard setting alone', async ({ + page, + }) => { + await installKeyboard(page, {virtualKeyboardApi: true}); + const {driver} = await openBar(page, 'search-tab'); + expect(await overlaysContent(page)).toBe(false); + await tapSearch(page, driver); + await expect(page.getByRole('searchbox', {name: 'Search'})).toBeFocused(); + // The field never opts the page into the keyboard's overlay. + expect(await overlaysContent(page)).toBe(false); + await waitForRest(searchFieldEnd(page)); + const bottomOf = async () => { + const host = await searchHost(page).boundingBox(); + return host == null ? null : Math.round(host.y + host.height); + }; + const resting = await bottomOf(); + expect(resting).not.toBeNull(); + // Chrome's own report moves nothing. The visual viewport is the measure in + // every browser, and the field commits a report in the next frame. So two + // frames later it would have moved. + await reportChromeKeyboard(page, 300); + await page.evaluate( + () => + new Promise(resolve => + requestAnimationFrame(() => + requestAnimationFrame(() => resolve(null)), + ), + ), + ); + expect(await bottomOf()).toBe(resting); + await showKeyboard(page, 300); + const top = await page.evaluate(() => window.innerHeight - 300); + await expect.poll(bottomOf).toBe(Math.round(top - 8)); + await page.getByRole('button', {name: 'Cancel'}).click(); + await showKeyboard(page, 0); + expect(await overlaysContent(page)).toBe(false); + }); + + test.describe('under a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('the search tab never watches the keyboard', async ({page}) => { + await expectFinePointer(page); + await installKeyboard(page); + await openStory(page, storybook(), 'lab-tabbar--search-tab'); + await page.getByRole('tab', {name: 'Search'}).click(); + await expect(page.getByRole('searchbox', {name: 'Search'})).toBeFocused(); + expect(await keyboardListeners(page)).toBe(0); + }); + }); +}); + +/** + * The painted circle's width under the Cancel button over a held press and its + * release. Each width is a fraction of its width at rest (the minimized size). + */ +async function pressCancel(page: Page, driver: TouchDriver, windowMs = 1400) { + const cancel = page.getByRole('button', {name: 'Cancel'}); + await expect(cancel).toHaveAttribute('data-tab-bar-search-cancel', ''); + const glass = page.locator('[data-tab-bar-search] > span').first(); + await waitForRest(page.locator('[data-tab-bar-search]')); + await waitForRest(glass); + const box = await cancel.boundingBox(); + const rest = await glass.boundingBox(); + if (box == null || rest == null) { + throw new Error('no cancel button'); + } + const sampling = sampleRects(glass, windowMs); + await driver.play( + tapScript( + {x: box.x + box.width / 2, y: box.y + box.height / 2}, + {holdMs: 400}, + ), + ); + return (await sampling).flatMap(sample => + sample.box == null ? [] : [sample.box.width / rest.width], + ); +} + +test.describe('search tab, the cancel button', () => { + test("pressed, it grows to UIKit's pressed size (48 → 63.65) and, released, shrinks back past its size before settling, UIKit's wobble", async ({ + page, + browserName, + }) => { + test.skip( + browserName === 'webkit', + "Playwright's WebKit reads a held finger as a long press, which fires no click", + ); + const {driver} = await openBar(page, 'search-tab'); + await tapSearch(page, driver); + const widths = await pressCancel(page, driver, 2600); + const peak = widths.indexOf(Math.max(...widths)); + expect(widths[peak]).toBeCloseTo(63.65 / 48, 1); + // The wobble plays on the painted circle at the minimized size, as + // UIKit's does (63.65 → 43.6 → 49.2 → 48), before the bar grows it back. + const released = widths.slice(peak); + const dip = released.indexOf(Math.min(...released)); + expect(released[dip]).toBeLessThan(0.95); + expect(released[dip]).toBeGreaterThan(43.6 / 48 - 0.04); + expect(released.slice(dip).some(width => Math.abs(width - 1) < 0.02)).toBe( + true, + ); + // Then the bar expands, the search circle with it (62 px). + expect(widths.at(-1)).toBeCloseTo(62 / 48, 1); + // The lift ran the cancel: back on the tab search was chosen from. + await expect(tab(page, 'Home')).toHaveAttribute('aria-selected', 'true'); + }); + + test('search ending while it is still pressed plays the circle back: it rests at its own size, not pressed', async ({ + page, + }) => { + const {driver} = await openBar(page, 'search-tab'); + await tapSearch(page, driver); + const cancel = page.getByRole('button', {name: 'Cancel'}); + await expect(cancel).toHaveAttribute('data-tab-bar-search-cancel', ''); + const circleGlass = page.locator('[data-tab-bar-search] > span').first(); + await waitForRest(page.locator('[data-tab-bar-search]')); + await waitForRest(circleGlass); + // On screen first: the circle's glass and the Cancel button over it. + await expectPainted(circleGlass); + await expectPainted(cancel); + const box = await cancel.boundingBox(); + if (box == null) { + throw new Error('no cancel button'); + } + await driver.play( + tapScript( + {x: box.x + box.width / 2, y: box.y + box.height / 2}, + {holdMs: 900}, + ), + { + onEvent: async (_step, index) => { + if (index !== 0) { + return; + } + // Pressed, the circle grows; then Escape in the field ends search + // under the held finger. + await expect + .poll(async () => (await circleGlass.boundingBox())?.width ?? 0) + .toBeGreaterThan(55); + await page.getByRole('searchbox', {name: 'Search'}).press('Escape'); + await expect(searchHost(page)).not.toHaveAttribute( + 'data-tab-bar-search-field', + 'active', + ); + }, + }, + ); + // It rests at its own size: 48 px while the bar stays collapsed on the + // search tab, 62 px once it expands. The pressed size would be 63.65 px + // collapsed, and about 82 px once a bar that expands grows it. + await waitForRest(page.locator('[data-tab-bar-search]')); + await waitForRest(circleGlass); + const size = (await isMinimized(page)) ? MINIMIZED_SIZE : 62; + expect((await circleGlass.boundingBox())?.width ?? 0).toBeCloseTo(size, 0); + }); + + test.describe('with Reduce Motion (design:reduced-motion)', () => { + test('a press scales nothing', async ({page, browserName}) => { + test.skip( + browserName === 'webkit', + "Playwright's WebKit reads a held finger as a long press, which fires no click", + ); + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver} = await openBar(page, 'search-tab'); + await tapSearch(page, driver); + // Over the held press (the release then runs Cancel, and the bar + // expands). + const widths = await pressCancel(page, driver, 380); + expect(Math.max(...widths)).toBeCloseTo(1, 2); + expect(Math.min(...widths)).toBeCloseTo(1, 2); + }); + }); +}); diff --git a/packages/lab/src/TabBar/index.ts b/packages/lab/src/TabBar/index.ts index 7441666a5ee7f..9ede695438d3d 100644 --- a/packages/lab/src/TabBar/index.ts +++ b/packages/lab/src/TabBar/index.ts @@ -4,10 +4,18 @@ /** * @file index.ts - * @input Imports from TabBar.tsx - * @output Re-exports TabBar, TabBarItem and their types + * @input Imports from TabBar.tsx and TabBarMoreList.tsx + * @output Re-exports TabBar, TabBarItem, TabBarSearchTab, TabBarMoreList + * and their types * @position Barrel export; consumed by packages/lab/src/index.ts */ -export {TabBar, TabBarItem} from './TabBar'; -export type {TabBarItemProps, TabBarProps} from './TabBar'; +export {TabBar, TabBarItem, TabBarSearchTab} from './TabBar'; +export {TabBarMoreList} from './TabBarMoreList'; +export type {TabBarMoreListProps} from './TabBarMoreList'; +export type { + TabBarAccessoryPlacement, + TabBarItemProps, + TabBarProps, + TabBarSearchTabProps, +} from './TabBar'; diff --git a/packages/lab/src/TabBar/tabBarModel.test.ts b/packages/lab/src/TabBar/tabBarModel.test.ts index 9cbcbd7f2c3f4..42e8518c4117f 100644 --- a/packages/lab/src/TabBar/tabBarModel.test.ts +++ b/packages/lab/src/TabBar/tabBarModel.test.ts @@ -19,6 +19,11 @@ import { itemWidthCss, lensTarget, liftScale, + accessoryBox, + searchCancelShift, + searchFieldBox, + searchLift, + minimizedCenter, naturalWidth, nearestIndex, } from './tabBarModel'; @@ -200,3 +205,77 @@ describe('copyTransform', () => { } }); }); + +describe('minimizedCenter', () => { + it('is concentric with the leading cap: the 48 pt circle at x 28 in a bar at x 21', () => { + expect(minimizedCenter(290) - tabBar.minimizedSize / 2 + 21).toBe(28); + expect(minimizedCenter(360)).toBe(31); + }); + + it('sits at the right end in a right-to-left bar', () => { + expect(minimizedCenter(360, true)).toBe(329); + }); +}); + +describe('accessoryBox', () => { + // UIKit's frames over a bar at (21, 791), 360 wide. + const onScreen = (box: {start: number; top: number; width: number}) => ({ + x: 21 + box.start, + y: 791 + box.top, + width: box.width, + }); + + it('sits 8 pt above the bar, as wide as it: (21, 735, 360 × 48)', () => { + expect(onScreen(accessoryBox(360, false))).toEqual({ + x: 21, + y: 735, + width: 360, + }); + }); + + it('minimized, sits inline after the circle: (84, 798, 290 × 48)', () => { + expect(onScreen(accessoryBox(360, true))).toEqual({ + x: 84, + y: 798, + width: 290, + }); + }); + + it('with a search tab, ends 8 pt before the minimized search circle: (84, 798, 234 × 48)', () => { + expect(onScreen(accessoryBox(360, true, true))).toEqual({ + x: 84, + y: 798, + width: 234, + }); + }); +}); + +describe('searchFieldBox', () => { + it('sits inline beside the leading circle: (88, 798, 286 × 48)', () => { + const box = searchFieldBox(360, false); + expect({x: 21 + box.start, y: 791 + box.top, width: box.width}).toEqual({ + x: 88, + y: 798, + width: 286, + }); + }); + + it('active, spans the screen but for the cancel button: (8, 330 wide)', () => { + const box = searchFieldBox(360, true); + expect({x: 21 + box.start, width: box.width}).toEqual({x: 8, width: 330}); + }); +}); + +describe('the active search field', () => { + it('moves the search circle 20 pt as it becomes the cancel button: (326 → 346)', () => { + expect(searchCancelShift()).toBe(20); + }); + + it('lifts the field to 8 pt above the keyboard: 798 → 483 over a 335 pt keyboard', () => { + // The field's bottom rests at 846 in the bar on an 874 pt screen. + expect(searchLift(874, 335, 846)).toBe(483 - 798); + // No keyboard, or one too low to reach it: no lift. + expect(searchLift(874, 0, 846)).toBe(0); + expect(searchLift(874, 10, 846)).toBe(0); + }); +}); diff --git a/packages/lab/src/TabBar/tabBarModel.ts b/packages/lab/src/TabBar/tabBarModel.ts index 1ab86f537798f..8a5fe5701dae9 100644 --- a/packages/lab/src/TabBar/tabBarModel.ts +++ b/packages/lab/src/TabBar/tabBarModel.ts @@ -4,7 +4,8 @@ * @file tabBarModel.ts * @input TabBar's UIKit values (uikitTabBar.ts) * @output TabBar's pure geometry: the items' widths and centres, the item a - * lift commits, and the lens's target and scale. + * lift commits, and the lens's target and scale. Also the minimized + * circle and the accessory's and the search field's boxes. * @position Math of the lab TabBar; no DOM, no React. Coordinates are the * capsule's own, in px, before its flex scale. x runs from its left edge, * and items go in logical order. @@ -134,3 +135,102 @@ export function copyTransform( ): {x: number; scaleX: number; scaleY: number} { return {x: -x / scaleX, scaleX: 1 / scaleX, scaleY: 1 / scaleY}; } + +/** + * The minimized circle's centre in a capsule `width` px wide: concentric with + * the capsule's leading cap. + */ +export function minimizedCenter(width: number, isRtl = false): number { + const half = tabBar.barHeight / 2; + return isRtl ? width - half : half; +} + +export interface AccessoryBox { + /** From the capsule's inline-start edge, px. */ + readonly start: number; + /** From the capsule's top edge, px (negative: above it). */ + readonly top: number; + readonly width: number; +} + +/** + * The bottom accessory's box in a bar `width` px wide (the capsule, and the + * search circle when there is one). Above the bar it is as wide as the bar. + * Minimized, it sits inline between the circle and the bar's trailing end, + * concentric with the trailing cap. With a minimized search circle it ends + * `accessoryGap` before that circle. + */ +export function accessoryBox( + width: number, + isInline: boolean, + hasSearch = false, +): AccessoryBox { + const {barHeight, minimizedSize, accessoryHeight, accessoryGap} = tabBar; + if (!isInline) { + return {start: 0, top: -(accessoryGap + accessoryHeight), width}; + } + const start = barHeight / 2 + minimizedSize / 2 + accessoryGap; + return { + start, + top: (barHeight - accessoryHeight) / 2, + width: width - start - accessoryEnd(hasSearch), + }; +} + +/** The inline accessory's distance from the bar's trailing end. */ +export function accessoryEnd(hasSearch: boolean): number { + const inset = (tabBar.barHeight - tabBar.minimizedSize) / 2; + return hasSearch ? inset + tabBar.minimizedSize + tabBar.accessoryGap : inset; +} + +/** + * The search field's box in a bar `width` px wide, from the bar's inline-start + * edge. Inline in the bar's row beside the leading circle, or (active) spanning + * the screen but for the cancel button. `top` is the inline row, where the + * active field stays too. + */ +export function searchFieldBox(width: number, isActive: boolean): AccessoryBox { + const {barHeight, minimizedSize, searchFieldGap, barInset, searchActive} = + tabBar; + const inset = (barHeight - minimizedSize) / 2; + const top = inset; + if (!isActive) { + const start = inset + minimizedSize + searchFieldGap; + return {start, top, width: width - start - inset}; + } + const outset = barInset - searchActive.inset; + return { + start: -outset, + top, + width: + width + 2 * outset - searchActive.cancelSize - searchActive.cancelGap, + }; +} + +/** + * How far the search circle moves as it becomes the active field's cancel + * button. From the minimized circle's place at the bar's trailing end to + * `searchActive.inset` from the screen's edge, px toward the trailing side. + */ +export function searchCancelShift(): number { + const {barInset, barHeight, minimizedSize, searchActive} = tabBar; + return barInset + (barHeight - minimizedSize) / 2 - searchActive.inset; +} + +/** + * The search field's lift above the keyboard, in px (negative is up). The + * field's bottom rests at `fieldBottom` (viewport px, unlifted). It moves for + * its bottom to sit `searchKeyboardGap` above a keyboard covering `inset` px of + * a `viewportHeight` px layout viewport. A keyboard too low to reach the field + * lifts nothing. + */ +export function searchLift( + viewportHeight: number, + inset: number, + fieldBottom: number, +): number { + return Math.min( + 0, + viewportHeight - inset - tabBar.searchKeyboardGap - fieldBottom, + ); +} diff --git a/packages/lab/src/TabBar/uikitTabBar.ts b/packages/lab/src/TabBar/uikitTabBar.ts index e2eccde8daed6..73be9d3100e9a 100644 --- a/packages/lab/src/TabBar/uikitTabBar.ts +++ b/packages/lab/src/TabBar/uikitTabBar.ts @@ -4,7 +4,8 @@ * @file uikitTabBar.ts * @input Nothing; data only * @output Exports `tabBar`: the iOS 26 floating tab bar's geometry, its - * badge, and the selection lens's springs and values. + * badge, and the selection lens's springs and values. Also the bar's + * minimize, accessory and search motion. * @position TabBar's own UIKit values, beside the component that reads them; * read by its touch presentation and tabBarModel.ts * @@ -20,6 +21,14 @@ * in points, in the bar's own coordinates unless a value says otherwise. * Springs keep UIKit's terms (response in seconds, damping ratio). No value is * a motion role: each plays as measured. + * + * Open measurements: a value whose source is a guess names its task with + * `@measure`. + * - M-TB1, the minimize glass morph. On a stock UITabBarController with + * `.onScrollDown` over a long list, scroll down past the trigger, back to the + * top, and tap the circle. Read from 60 fps video what the screen shows + * between the bar and the circle (the platter's outline, the items' opacity + * or blur, the lens). */ /** @@ -240,4 +249,172 @@ export const tabBar = { * its centre, Oct 23 2026 */ lensEdgeDimension: 10.5, + /** + * Minimize on scroll (`tabBarMinimizeBehavior = .onScrollDown`): how far + * the content scrolls down, from where the downward scroll started, before + * the bar collapses into its minimized circle. A scroll back up does not + * expand it; reaching the top does (and a tap on the circle). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, slow drags: minimized after 65 pt of content scroll from + * the top and after 66.3 pt starting at offset 384; a slow 137 pt and a + * coasting 760 pt reverse scroll mid-content did not expand, Oct 24 2026 + */ + minimizeDistance: 66, + /** + * The minimized bar: a circle this wide at the leading end, concentric with + * the bar's leading cap. It sits 7 pt in from the bar's leading and bottom + * edges. It holds the selected tab's icon in the selected colour. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, the minimized `_UITabBarPlatterView` 48 × 48 at (28, 798) under a + * bar at (21, 791), with tab 1 or tab 3 selected; its one `_UITabButton` + * holds only the selected icon, tinted, Oct 24 2026 + */ + minimizedSize: 48, + /** + * The lens travelling into the minimized circle (its centre). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, least-squares fit of the lens's per-frame x as it + * collapsed from tab 3 to the leading circle (rms 0.8 pt), Oct 24 2026 + */ + collapseTravel: {response: 0.4, dampingRatio: 0.85}, + /** + * The lens's size between the item and the circle, both ways; the + * platter contracts and grows on it too. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, least-squares fits of the lens's per-frame width: + * 76 to 48 collapsing (rms 0.7 pt) and 48 to 98 expanding (rms + * 0.15 pt), Oct 24 2026 + */ + collapseSize: {response: 0.6, dampingRatio: 0.85}, + /** + * The tab items fading while the glass morphs between the bar and the + * circle, and the selected copy inside the lens with them. + * @source GUESS — UIKit morphs the glass on its compositor: layer + * properties only show the circles appearing in one frame at the start + * and the bar's items, lens and platter disappearing in one frame + * 0.75 s later (iOS 26.2 simulator, Oct 24 2026), so what a person sees + * in between is still to be read from video + * @measure M-TB1 + */ + minimizeFade: {response: 0.25, dampingRatio: 1}, + /** + * With a search tab (`UISearchTab`) the bar's tabs share a narrower capsule. + * The search tab is a circle as tall as the bar, this far after the capsule + * at the trailing end (290 + 8 + 62 = 360). Minimized, it shrinks to + * `minimizedSize` about its centre, and its glyph does not change. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, the platter at (21, 791, 290 × 62) and the search circle at (319, + * 791, 62 × 62) with three and four tabs; minimized, the search circle at + * (326, 798, 48 × 48) with its 28 pt glyph unchanged, Oct 24 2026 + */ + searchGap: 8, + /** + * The search tab selected: the bar collapses to the leading circle, and the + * search field sits inline in the bar's row. The field sits this far after + * the circle and is concentric with the trailing cap (88 to 374 beside a + * circle ending at 76). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, UISearchTab with and without automaticallyActivatesSearch: the + * inline field at (88, 798, 286 × 48) beside the leading circle at (28, + * 798), Oct 24 2026 + */ + searchFieldGap: 12, + /** + * The active search field, above the keyboard, sits this far from the + * screen's edges. At the trailing end it leaves room for the 48 pt cancel + * button and the gap before it. The field spans 8 to 338, and the button is + * centred at 370. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, the active field at (8, 483, 330 × 48) and the cancel button + * centred at (370, 507), Oct 24 2026 + */ + searchActive: {inset: 8, cancelSize: 48, cancelGap: 8}, + /** + * The search field's glass between its inline and active places (x and + * width). On activation it starts with this inward width velocity, in pt/s. + * The glass squashes as it starts to move: 286 → 255 → 260 → 279 → … → 330. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, a joint fit of the field's per-frame width activating from the + * expanded bar and from the minimized bar (rms 1.3 pt), Oct 24 2026 + */ + searchMorph: {response: 0.244, dampingRatio: 1.108}, + /** + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the same joint fit's initial width velocity, Oct 24 2026 + */ + searchSquashVelocity: -3424, + /** + * Active, the search field sits above the software keyboard, its bottom + * this far over the keyboard's top (531 over 539). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, UISearchTab with a UISearchController: the active field + * at (8, 483, 330 × 48) over the keyboard's top at 539, Oct 24 2026 + */ + searchKeyboardGap: 8, + /** + * Active, the search circle is the field's cancel button. At the minimized + * circle's place it appears this long after the tap (opacity 0 → 1 within a + * frame). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, the cancel button (a 48 pt UIButton in its own platter) at (326, + * 798) with opacity 0 until 0.12–0.13 s after the tap, Oct 24 2026 + */ + searchCancelDelay: 0.12, + /** + * Pressed, the cancel button grows from the minimized size to this, + * about its centre (48 → 63.65), on `searchCancelPress`. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the cancel button's per-frame size from the touch + * (48 → 49.5 → 52.2 → 58.2 → … → 63.65), Oct 24 2026 + */ + searchCancelPressedSize: 63.65, + /** + * The cancel button's press, to `searchCancelPressedSize`. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, a fit of the cancel button's per-frame size from the + * touch (rms 0.6 pt), Oct 24 2026 + */ + searchCancelPress: {response: 0.145, dampingRatio: 0.746}, + /** + * Released, the cancel button shrinks back to the minimized size with a + * wobble (63.65 → 43.6 → 49.2 → 48). + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, the cancel button's per-frame size from the release + * (rms 0.03 pt), Oct 24 2026 + */ + searchCancelReturn: {response: 0.399, dampingRatio: 0.374}, + /** + * The bottom accessory (`UITabBarController.bottomAccessory`, Music's mini + * player): a capsule this tall, as wide as the bar and this far above it. + * Minimized, it sits inline between the circle and the bar's trailing end, + * `accessoryGap` after the circle and concentric with the trailing cap. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, iPhone + * 17 Pro, four tabs: the accessory at (21, 735, 360 × 48) over a bar at + * (21, 791); minimized at (84, 798, 290 × 48) beside the circle at (28, + * 798), and (84, 798, 234 × 48) ending 8 pt before the search circle, Oct + * 24 2026 + */ + accessoryHeight: 48, + /** + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, 8 pt between the accessory and the bar (735 + 48 to + * 791) and between the minimized circle and the inline accessory (76 to + * 84), Oct 24 2026 + */ + accessoryGap: 8, + /** + * The accessory moving down inline as the bar minimizes: its x, y and + * width on one spring. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, a joint least-squares fit of the accessory's per-frame + * x, y and width collapsing (rms 0.94 pt), Oct 24 2026 + */ + accessoryCollapse: {response: 0.405, dampingRatio: 0.745}, + /** + * The accessory moving back up as the bar expands. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, a joint least-squares fit of the accessory's per-frame + * x, y and width expanding (rms 1.1 pt), Oct 24 2026 + */ + accessoryExpand: {response: 0.329, dampingRatio: 0.731}, } as const; diff --git a/packages/lab/src/TabBar/useTabBarLens.ts b/packages/lab/src/TabBar/useTabBarLens.ts index dfe3d110be52b..9e03436499f3c 100644 --- a/packages/lab/src/TabBar/useTabBarLens.ts +++ b/packages/lab/src/TabBar/useTabBarLens.ts @@ -5,7 +5,7 @@ /** * @file useTabBarLens.ts * @input The capsule, the item count, the selected index, the presentation, - * and the lift's handler. Core's animator and long-press + * the minimized state and the lift's handler. Core's animator and long-press * recognizer (internal/touch), uikitTabBar.ts and tabBarModel.ts. * @output useTabBarLens: the selection lens's motion and press tracking, and * the refs TabBar puts on the capsule and the lens's layers @@ -35,12 +35,14 @@ import { UIKIT_GESTURES, useGestureRecognizer, } from '@astryxdesign/core/internal/touch'; +import {observeResize} from '@astryxdesign/core'; import { copyTransform, itemCenters, itemWidth, lensTarget, liftScale, + minimizedCenter, nearestIndex, } from './tabBarModel'; import {useMotionAnimator} from '../internal/useMotionAnimator'; @@ -51,6 +53,12 @@ const DRAG_SLOP = UIKIT_GESTURES.longPress.allowableMovement.value; /** How long the selected copy's curves run: past any lens spring's settle. */ const COPY_HORIZON_S = 1.5; +/** + * How long the selected copy stays visible when the bar minimizes. It fades + * with the items (`minimizeFade`, under 1% after 0.3 s), so its counter-move + * stops there instead of sampling keyframes nobody sees. + */ +const COPY_FADE_S = 0.3; /** Seconds between the samples a derived curve's velocity is read from. */ const VELOCITY_STEP_S = 0.001; @@ -67,6 +75,8 @@ interface Press { isDragging: boolean; /** A tab was selected when the finger landed: the lens follows it. */ hasLens: boolean; + /** Landed on a disabled tab: nothing moves until the finger drags. */ + isIgnored: boolean; /** A fine pointer's press: a drag moves the lens body, with no lift. */ readonly isFine: boolean; /** The capsule's box in client px, before the flex. */ @@ -99,8 +109,15 @@ export interface UseTabBarLensOptions { * lens on springs. Otherwise a press drags it in the mouse presentation. */ readonly isTouch: boolean; + /** The bar is minimized (useTabBarMinimize.ts): the lens is in the circle. */ + readonly isMinimized: boolean; /** A press released over the item at `index`: a lift, or a drag's release. */ readonly onLift: (index: number) => void; + /** + * Whether the item at `index` is disabled: a finger landing on it is + * ignored until it drags (UIKit ignores a tap on a disabled tab). + */ + readonly isDisabled?: (index: number) => boolean; } export interface TabBarLensRefs { @@ -179,10 +196,16 @@ function useAnimatorList(count: number) { * * A selection change slides the body from where the lens was on screen to its * new resting place. On `lensTravel` from rest (a key, the app), or continuing - * the settle a lift started. Every animation runs on the compositor; a finger's - * moves retarget them, so the lens keeps moving through a busy main thread. - * Under Reduce Motion the animator cross-fades instead of moving and a press - * scales nothing. Each spring plays as measured, from the UIKit table. + * the settle a lift started. + * + * Minimizing (useTabBarMinimize.ts) carries the lens into the minimized circle. + * It travels to the circle's centre (`collapseTravel`) and shrinks to its size + * while fading (`collapseSize`), and the selected copy fades out with the items + * (`minimizeFade`). Expanding plays it back. A minimized bar takes no press. + * Every animation runs on the compositor; a finger's moves retarget them, so + * the lens keeps moving through a busy main thread. Under Reduce Motion the + * animator cross-fades instead of moving and a press scales nothing. Each + * spring plays as measured, from the UIKit table. * * Under a fine pointer the same press drags the lens in the mouse presentation. * A fine pointer is a mouse, a pen, or a finger on a screen whose primary @@ -200,7 +223,9 @@ export function useTabBarLens({ count, selectedIndex, isTouch, + isMinimized, onLift, + isDisabled, }: UseTabBarLensOptions): TabBarLensRefs { const capsule = useMotionAnimator(); const body = useMotionAnimator(); @@ -256,13 +281,19 @@ export function useTabBarLens({ const selectedRef = useRef(selectedIndex); selectedRef.current = selectedIndex; const onLiftRef = useRef(onLift); + const isDisabledRef = useRef(isDisabled); + isDisabledRef.current = isDisabled; onLiftRef.current = onLift; + // The tabs sit in the tab layer while one is mounted. const tabsOf = (capsule: HTMLElement) => - [...capsule.children].filter( + [ + ...(capsule.querySelector('[data-tab-bar-items]') ?? capsule).children, + ].filter( (child): child is HTMLElement => child.getAttribute('role') === 'tab', ); // The tab a press at viewport (x, y) would choose: the tab nearest it. Off // the bar nothing is chosen, under every pointer, so sliding off cancels. + // A disabled tab is never chosen. const pickRef = useRef<(x: number, y: number) => HTMLElement | null>( () => null, ); @@ -277,6 +308,9 @@ export function useTabBarLens({ return null; } const index = nearestIndex(x - press.left, press.centers); + if (isDisabledRef.current?.(index) === true) { + return null; + } return tabsOf(element)[index] ?? null; }; // What the lift does with the tab it chose: set by the lift's own handler. @@ -293,15 +327,6 @@ export function useTabBarLens({ onActivate: tab => runRef.current?.(tab), })), ); - /** - * Where the lens heads for a press at `x`: after it, rubber-banded past - * the end items. Off the bar, where the lift would choose nothing, the lens - * shows the selected tab again. - */ - const chaseTarget = (x: number, centers: number[]) => - tracker.highlighted == null - ? centers[Math.max(selectedRef.current, 0)] - : lensTarget(x, centers); /** Runs the tab the tracker resolved, through `run`; null if none. */ const runLift = (run: (tab: HTMLElement) => void) => { runRef.current = run; @@ -347,9 +372,11 @@ export function useTabBarLens({ /** * Plays the selected copy's counter-transform from `at`, derived from the - * body's curves as they now stand. Called after every change to the body. + * body's curves as they now stand. Called after every change to the body; + * `fromRest` when the body's call started from rest, so the copy joins its + * start at the browser's commit. */ - const playCopy = (at: number) => { + const playCopy = (at: number, fromRest = false, horizon = COPY_HORIZON_S) => { const sample = (t: number) => { const time = at + t * 1000; return copyTransform( @@ -359,7 +386,7 @@ export function useTabBarLens({ ); }; const curve = (key: 'x' | 'scaleX' | 'scaleY'): MotionCurve => ({ - duration: COPY_HORIZON_S, + duration: horizon, at: t => sample(t)[key], velocity: t => (sample(t + VELOCITY_STEP_S)[key] - @@ -368,7 +395,7 @@ export function useTabBarLens({ }); copy.animator.play( {x: curve('x'), scaleX: curve('scaleX'), scaleY: curve('scaleY')}, - {at}, + fromRest ? {} : {at}, ); }; @@ -410,6 +437,16 @@ export function useTabBarLens({ ); }; + /** + * Where the lens heads for a press at `x`: after it, rubber-banded past + * the end items. Where the lift would choose nothing (off the bar, or over + * a disabled tab) the lens shows the selected tab again. + */ + const chaseTarget = (x: number, centers: number[]) => + tracker.highlighted == null + ? centers[Math.max(selectedRef.current, 0)] + : lensTarget(x, centers); + const begin = (recognizer: LongPressGestureRecognizer) => { const element = capsuleRef.current; const selected = selectedRef.current; @@ -425,6 +462,10 @@ export function useTabBarLens({ const centers = itemCenters(element.offsetWidth, count, isRtl); const size = itemWidth(element.offsetWidth, count); const x = recognizer.location().x - left; + // A finger landing on a disabled tab: UIKit ignores the tap, so the lens + // neither heads for it nor lifts unless the finger then drags. + const isIgnored = + isDisabledRef.current?.(nearestIndex(x, centers)) === true; pressRef.current = { centers, size, @@ -433,6 +474,7 @@ export function useTabBarLens({ lastX: x, isDragging: false, hasLens: selected >= 0, + isIgnored, isFine: !isTouch, box: { left, @@ -448,9 +490,10 @@ export function useTabBarLens({ if (!isTouch) { return; } - // No tab selected: there is no lens to move; the lift chooses the tab - // nearest the finger. - if (selected < 0) { + // Nothing moves now when no tab is selected: there is no lens to move, and + // the lift chooses the tab nearest the finger. Nothing moves either for a + // finger landing on a disabled tab, since UIKit ignores the tap. + if (selected < 0 || isIgnored) { return; } // The lifted lens magnifies the selected look: its copy mounts now, in @@ -483,11 +526,13 @@ export function useTabBarLens({ press.isDragging ||= Math.abs(x - press.startX) > DRAG_SLOP; if (press.isFine) { // A fine pointer's drag carries the lens on the finger's tracking - // spring, with no lift and no copy of the items. Where the release would - // choose nothing, the lens shows the selected tab again. + // spring, with no lift and no copy of the items. A press that began on a + // disabled tab counts once it drags. Where the release would choose + // nothing, the lens shows the selected tab again. if (!press.isDragging) { return; } + press.isIgnored = false; if (press.hasLens && selected >= 0) { const target = chaseTarget(x, press.centers) - press.centers[selected]; if (body.animator.reducedMotion({}) === 'off') { @@ -503,10 +548,25 @@ export function useTabBarLens({ } return; } + const at = performance.now(); + if (press.isIgnored) { + if (!press.isDragging) { + return; + } + // A drag from a disabled tab: the press counts from here (the lens, + // when there is one, lifts, its copy drawn, and chases). + press.isIgnored = false; + if (press.hasLens && selected >= 0) { + motionRef.current += 1; + if (body.animator.reducedMotion({}) === 'off') { + flushSync(() => setIsCopyShown(true)); + } + lift(true, at, press.size); + } + } if (!press.hasLens || selected < 0) { return; } - const at = performance.now(); body.animator.animate( {x: chaseTarget(x, press.centers) - press.centers[selected]}, {spring: press.isDragging ? tabBar.lensTrack : tabBar.lensTravel}, @@ -520,7 +580,8 @@ export function useTabBarLens({ pressRef.current = null; const selected = selectedRef.current; const element = capsuleRef.current; - if (press == null || element == null) { + // A tap on a disabled tab moved nothing and chooses nothing. + if (press == null || press.isIgnored || element == null) { tracker.cancel(); return; } @@ -648,6 +709,11 @@ export function useTabBarLens({ isTouchRef.current ? lockSelection(elements, target) : () => undefined, + // Only the capsule's own box: the search tab beside it is a button. + shouldReceiveTouch: (_recognizer, touch) => { + const box = capsuleRef.current?.getBoundingClientRect(); + return box != null && touch.x >= box.left && touch.x <= box.right; + }, }), recognizer => { switch (recognizer.state) { @@ -667,23 +733,109 @@ export function useTabBarLens({ } }, { + isEnabled: !isMinimized, // Under a mouse the host writes no touch styles: desktop selection // is unchanged. manageStyle: isTouch, }, ); - // Synchronizes the recognizer's press with the presentation: when the - // presentation changes under a press, the press ends here, as a cancel. A - // cancel chooses nothing, so the app hears nothing. + // Synchronizes the recognizer's press with the bar's state and the + // presentation. When the bar minimizes (the recognizer turns off) or the + // presentation changes under a press, the press ends here as a cancel. A + // recognizer turned off reports nothing more. A cancel chooses nothing, so + // the app hears nothing. const endRef = useRef(end); endRef.current = end; useEffect(() => { const press = pressRef.current; - if (press != null && press.isFine === isTouch) { + if (press != null && (isMinimized || press.isFine === isTouch)) { endRef.current(true); } - }, [isTouch]); + }, [isTouch, isMinimized]); + /** The body's offset and scale that put the lens in the minimized circle. */ + const collapsed = (element: HTMLElement, selected: number) => { + const width = element.offsetWidth; + const isRtl = isRtlElement(element); + return { + x: + minimizedCenter(width, isRtl) - + itemCenters(width, count, isRtl)[selected], + scaleX: tabBar.minimizedSize / itemWidth(width, count), + scaleY: tabBar.minimizedSize / tabBar.itemHeight, + }; + }; + + // Minimizing carries the lens into the circle; expanding brings it back. + const wasMinimizedRef = useRef(isMinimized); + useLayoutEffect(() => { + const was = wasMinimizedRef.current; + wasMinimizedRef.current = isMinimized; + const element = capsuleRef.current; + const selected = selectedRef.current; + if (was === isMinimized || element == null || selected < 0) { + return; + } + const target = isMinimized + ? collapsed(element, selected) + : {x: 0, scaleX: 1, scaleY: 1}; + const now = performance.now(); + // From rest every curve starts when the browser commits the frame, so a + // busy main thread delays the motion instead of skipping into it. Motion + // already on screen continues from now. + const isMoving = + body.animator.getVelocity('x', now) !== 0 || + body.animator.getVelocity('scaleX', now) !== 0; + const options = isMoving ? {at: now} : {}; + // Under a finger: the bar collapses for the minimize and the search tab. + body.animator.animate( + {x: target.x}, + {spring: tabBar.collapseTravel}, + options, + ); + body.animator.animate( + { + scaleX: target.scaleX, + scaleY: target.scaleY, + opacity: isMinimized ? 0 : 1, + }, + {spring: tabBar.collapseSize}, + options, + ); + copy.animator.animate( + {opacity: isMinimized ? 0 : 1}, + {spring: tabBar.minimizeFade}, + options, + ); + playCopy(now, !isMoving, isMinimized ? COPY_FADE_S : COPY_HORIZON_S); + }, [isMinimized, isTouch, capsuleRef, body, copy]); + + // Minimized, the bar's width can still change (a rotation, a split view): + // the lens stays in the circle. + useEffect(() => { + const element = capsuleRef.current; + if (!isMinimized || element == null) { + return; + } + let width = element.offsetWidth; + // Core's shared observer. Its first call, at once, finds the same width. + return observeResize(element, () => { + const next = element.offsetWidth; + const selected = selectedRef.current; + if (next === width || selected < 0) { + return; + } + width = next; + const isRtl = isRtlElement(element); + body.animator.set({ + x: + minimizedCenter(next, isRtl) - + itemCenters(next, count, isRtl)[selected], + scaleX: tabBar.minimizedSize / itemWidth(next, count), + scaleY: tabBar.minimizedSize / tabBar.itemHeight, + }); + }); + }, [isMinimized, capsuleRef, body, count]); // Synchronizes the recognizer's press and the lens's animations with the tab // list. When it changes under a held finger, the press ends here, as a @@ -718,6 +870,14 @@ export function useTabBarLens({ ) { return; } + if (wasMinimizedRef.current) { + // Minimized: the lens stays in the circle, now measured from the new + // resting place. + const target = collapsed(element, selectedIndex); + body.animator.set(target); + playCopy(performance.now()); + return; + } const isRtl = isRtlElement(element); const centers = itemCenters(element.offsetWidth, count, isRtl); const now = performance.now(); diff --git a/packages/lab/src/TabBar/useTabBarMinimize.ts b/packages/lab/src/TabBar/useTabBarMinimize.ts new file mode 100644 index 0000000000000..13f83e73e49ba --- /dev/null +++ b/packages/lab/src/TabBar/useTabBarMinimize.ts @@ -0,0 +1,528 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useTabBarMinimize.ts + * @input The capsule, whether minimizing applies, and the search tab's state. + * Core's animator, pan recognizer and finger-scroll reader (internal/touch) + * and uikitTabBar.ts. + * @output useTabBarMinimize: whether the bar is minimized (UIKit's + * `.onScrollDown`) and a way to expand it. It also returns the refs TabBar + * puts on the platter, the items, the badges, the circle and the accessory. + * @position Part of TabBar's touch layer (TabBarTouch.ts) + * + * SYNC: When modified, update TabBar.tsx, useTabBarLens.ts, + * useTabBarSearch.ts and __tests__/TabBar.interaction.browser.spec.ts. + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type RefObject, +} from 'react'; +import { + isRtlElement, + PanGestureRecognizer, + useFingerScroll, + useGestureRecognizer, +} from '@astryxdesign/core/internal/touch'; +import {observeResize} from '@astryxdesign/core'; +import {accessoryBox} from './tabBarModel'; +import {useMotionAnimator} from '../internal/useMotionAnimator'; +import {tabBar} from './uikitTabBar'; + +export interface UseTabBarMinimizeOptions { + readonly capsuleRef: RefObject; + /** A search tab sits at the bar's trailing end. */ + readonly hasSearch: boolean; + /** The search tab is selected: the bar collapses as it does minimized. */ + readonly isSearching: boolean; + /** `minimizeBehavior="onScrollDown"` under a finger. */ + readonly isEnabled: boolean; + /** + * Until when (ms, the document timeline) the search tab's glass keeps its + * minimized size as the bar expands. The search field's Cancel wobbles at + * that size first, as UIKit's does. + */ + readonly searchGlassHoldRef?: RefObject; +} + +export interface TabBarMinimize { + /** Minimized by the page's scrolling. */ + readonly isMinimized: boolean; + /** Drawn collapsed: minimized, or the search tab selected. */ + readonly isCollapsed: boolean; + /** Expands a minimized bar (the circle activated, a tap or the keys; or focus on the search tab, which stays in reach). */ + readonly expand: () => void; + /** The capsule: its document is the page whose scrolling minimizes the bar. */ + readonly capsule: (element: HTMLElement | null) => void; + /** The platter: shrinks to the circle about the leading cap's centre. */ + readonly platter: (element: HTMLElement | null) => void; + /** The platter's middle: narrows to nothing. */ + readonly platterMiddle: (element: HTMLElement | null) => void; + /** The platter's trailing cap: slides onto the leading one. */ + readonly platterEnd: (element: HTMLElement | null) => void; + /** The items' layer (every tab button): fades as one. */ + readonly items: (element: HTMLElement | null) => void; + /** The badges: fade with the items. */ + readonly badges: (element: HTMLElement | null) => void; + /** The minimized circle's icon: fades in. */ + readonly circleIcon: (element: HTMLElement | null) => void; + /** The bottom accessory: moves between its two places. */ + readonly accessory: (element: HTMLElement | null) => void; + /** The accessory glass's middle: its width. */ + readonly accessoryMiddle: (element: HTMLElement | null) => void; + /** The accessory glass's trailing cap: its width. */ + readonly accessoryEnd: (element: HTMLElement | null) => void; + /** The search tab's glass: shrinks to the minimized size about its centre. */ + readonly searchGlass: (element: HTMLElement | null) => void; + /** The accessory content's window: its trailing edge rides the cap. */ + readonly accessoryClip: (element: HTMLElement | null) => void; + /** The accessory's content: held in place inside its moving window. */ + readonly accessoryContent: (element: HTMLElement | null) => void; +} + +/** No scroller to watch: the server, or minimizing is off. */ +const NO_SCROLLER = {current: null}; + +/** + * Follows the person's scrolling of the page: minimizes after + * `minimizeDistance` down, expands at the top. The person's scrolling starts as + * a vertical pan on the page that never claims its touch (`claimsTouches: + * false`). The gesture host gives it only passive listeners, so the page + * scrolls exactly as it would without it. From the pan's start until the + * person's scroll ends, and only then, a passive scroll listener follows the + * offset. The gesture host's finger-scroll reader says when the scroll ends, + * and it counts the coast after the lift as the person's. + */ +function useScrollMinimize(isEnabled: boolean) { + const [isMinimized, setIsMinimized] = useState(false); + // The bar's own document, which an iframe or another window can hold, and + // its root for the pan below. The capsule's ref fills both. + const docRef = useRef(null); + const rootRef = useRef(null); + // The downward run, across the person's scrolls: where it started and + // where the page was last seen. + const runRef = useRef({lastY: Number.NaN, downFrom: null}); + const capsule = useCallback((element: HTMLElement | null) => { + const doc = element?.ownerDocument ?? null; + if (doc === docRef.current) { + return; + } + docRef.current = doc; + rootRef.current = doc?.documentElement ?? null; + // A bar now in another document starts its run where that page is. + runRef.current = { + lastY: doc?.defaultView?.scrollY ?? Number.NaN, + downFrom: null, + }; + }, []); + // Whose scroll the page's is, from the gesture host: watched only while + // minimizing is on. + const isFingerScroll = useFingerScroll(isEnabled ? docRef : NO_SCROLLER); + const watchRef = useRef<{lift: () => void; stop: () => void} | null>(null); + useGestureRecognizer( + rootRef, + () => + new PanGestureRecognizer({ + name: 'tab-bar-page-scroll', + direction: 'vertical', + claimsTouches: false, + allowedPointerTypes: ['touch'], + }), + pan => { + const view = docRef.current?.defaultView; + if (pan.state === 'began' && view != null) { + watchRef.current?.stop(); + watchRef.current = watchScroll( + view, + runRef.current, + // The finger's travel since it landed (up scrolls the page down). + -pan.totalTranslation().y, + setIsMinimized, + isFingerScroll, + () => { + watchRef.current = null; + }, + ); + } else if (pan.state === 'ended' || pan.state === 'cancelled') { + watchRef.current?.lift(); + } + }, + {isEnabled}, + ); + // Minimizing turned off: the bar is expanded again, read during render, + // so a later turn on starts expanded. + const [wasEnabled, setWasEnabled] = useState(isEnabled); + if (wasEnabled !== isEnabled) { + setWasEnabled(isEnabled); + if (!isEnabled) { + setIsMinimized(false); + } + } + useEffect(() => { + const view = docRef.current?.defaultView; + if (!isEnabled || view == null) { + return; + } + // The run starts where the page is when the watching starts. + runRef.current = {lastY: view.scrollY, downFrom: null}; + return () => watchRef.current?.stop(); + }, [isEnabled]); + // Expanding starts the downward run over, in place, since a watch running now + // holds the same run. So an expanded bar takes another `minimizeDistance` + // down before it minimizes again. + const expand = useCallback(() => { + runRef.current.lastY = docRef.current?.defaultView?.scrollY ?? Number.NaN; + runRef.current.downFrom = null; + setIsMinimized(false); + }, []); + return {isMinimized: isEnabled && isMinimized, expand, capsule}; +} + +interface ScrollRun { + lastY: number; + /** Where the current downward run started. */ + downFrom: number | null; +} + +/** + * Follows one scroll of the page by the person, from the finger's pan to the + * page's rest. Call its `lift` when the finger lifts. The listeners go at the + * `scrollend` that follows, or at once if the scroll has already ended + * (`isFingerScroll`, the gesture host's reader). Where the browser sends no + * `scrollend`, they go when the reader says the person's scroll ended. + */ +function watchScroll( + view: Window, + run: ScrollRun, + fingerTravel: number, + setIsMinimized: (isMinimized: boolean) => void, + isFingerScroll: () => boolean, + onStop: () => void, +) { + const moved = view.scrollY - run.lastY; + // Since the person last scrolled, the page moved on its own (a script, the + // keys), unless this finger's travel since it landed covers the move. The + // browser may start scrolling before the pan begins. On its own, the downward + // run starts over. + const isThisFinger = + Math.sign(moved) === Math.sign(fingerTravel) && + Math.abs(moved) <= Math.abs(fingerTravel); + if (!Number.isFinite(moved) || (moved !== 0 && !isThisFinger)) { + run.lastY = view.scrollY; + run.downFrom = null; + } + let isLifted = false; + const onScroll = () => { + const y = view.scrollY; + if (y > run.lastY) { + run.downFrom ??= run.lastY; + if (y - run.downFrom >= tabBar.minimizeDistance) { + setIsMinimized(true); + } + } else if (y < run.lastY) { + run.downFrom = null; + if (y <= 0) { + setIsMinimized(false); + } + } + run.lastY = y; + }; + const onScrollEnd = () => { + if (isLifted) { + finish(); + } + }; + // Where the browser sends no `scrollend`, the person's scroll ends when the + // gesture host's reader says so. The reader has its own fallback after the + // last scroll event, and this reads it once a frame from the lift. + let frame = 0; + const waitForReader = () => { + frame = view.requestAnimationFrame(() => { + if (isFingerScroll()) { + waitForReader(); + } else { + finish(); + } + }); + }; + const stop = () => { + view.cancelAnimationFrame(frame); + view.removeEventListener('scroll', onScroll); + view.removeEventListener('scrollend', onScrollEnd); + onStop(); + }; + // Where the page is when the person's scroll ends is theirs. The browser + // sends at most one scroll event a frame, so the last moves may not have + // arrived yet. + const finish = () => { + onScroll(); + stop(); + }; + view.addEventListener('scroll', onScroll, {passive: true}); + view.addEventListener('scrollend', onScrollEnd, {passive: true}); + return { + lift: () => { + isLifted = true; + if (!isFingerScroll()) { + finish(); + } else if (!('onscrollend' in view)) { + waitForReader(); + } + }, + stop, + }; +} + +/** + * WHEN (uikitTabBar.ts, measured on iOS 26.2) + * + * The bar minimizes once the person has scrolled the page down + * `minimizeDistance` from where the downward scroll started. Scrolling back up + * does not expand it; reaching the top does, and so does a tap on the circle + * (`expand`). Only the person's scrolling counts: a finger's drag and the coast + * after it. The gesture host reads it as a pan on the page that never claims + * its touch. So a script scrolling the page to the top leaves the bar + * minimized, as on iOS. + * + * HOW IT MORPHS + * + * The capsule's glass is drawn in three pieces: a leading cap, a middle and a + * trailing cap. Collapsing, the middle narrows to nothing from its leading side + * while the trailing cap slides onto the leading one. So the outline stays a + * capsule all the way to a circle, and the whole platter shrinks to + * `minimizedSize` about the leading cap's centre (`collapseSize`). The items + * and their badges fade out (`minimizeFade`) and the selected icon fades in at + * the circle's centre; useTabBarLens.ts carries the lens into the circle. A + * search tab's circle shrinks to `minimizedSize` about its centre, its glyph + * unchanged. The bottom accessory moves down inline beside the circle + * (`accessoryCollapse`), ending before the search tab when there is one. Its + * box changes in CSS and it starts where it was. Its glass is drawn in the same + * three pieces, so transforms change its width too. Its content is laid out at + * once for its new place. It shows through a window whose trailing edge rides + * the glass's trailing cap, so it never shows past the glass. A move turned + * around keeps its speed. Expanding plays the same motion back + * (`accessoryExpand` for the accessory). Choosing the search tab collapses the + * bar the same way (useTabBarSearch.ts shows its field) and the accessory steps + * aside. Every animation runs on the compositor, transforms and opacity only. + * + * The collapse runs on UIKit's springs, under a finger only. The bar minimizes + * there and the search tab collapses it there (under a mouse the search tab + * selects in place). + */ +export function useTabBarMinimize({ + capsuleRef, + hasSearch, + isSearching, + isEnabled, + searchGlassHoldRef, +}: UseTabBarMinimizeOptions): TabBarMinimize { + const {isMinimized, expand, capsule} = useScrollMinimize(isEnabled); + const isCollapsed = isMinimized || isSearching; + const platter = useMotionAnimator(); + const platterMiddle = useMotionAnimator(); + const platterEnd = useMotionAnimator(); + const badges = useMotionAnimator(); + const circleIcon = useMotionAnimator({initial: {opacity: 0}}); + const items = useMotionAnimator(); + const accessory = useMotionAnimator(); + const accessoryMiddle = useMotionAnimator(); + const accessoryEnd = useMotionAnimator(); + const searchGlass = useMotionAnimator(); + const accessoryClip = useMotionAnimator(); + const accessoryContent = useMotionAnimator(); + + const previousRef = useRef(isCollapsed); + useLayoutEffect(() => { + const was = previousRef.current; + previousRef.current = isCollapsed; + const element = capsuleRef.current; + if (was === isCollapsed || element == null) { + return; + } + const isRtl = isRtlElement(element); + // The trailing cap's travel onto the leading one. + const travel = element.offsetWidth - tabBar.barHeight; + // From rest every curve starts when the browser commits the frame, with the + // lens's (useTabBarLens.ts). So a busy main thread delays the morph instead + // of skipping into it. A morph already on screen continues. + const now = performance.now(); + const options = + platterMiddle.animator.getVelocity('scaleX', now) !== 0 ? {at: now} : {}; + const size = {spring: tabBar.collapseSize}; + const fade = {spring: tabBar.minimizeFade}; + platterMiddle.animator.animate( + {scaleX: isCollapsed ? 0 : 1}, + size, + options, + ); + platterEnd.animator.animate( + {x: isCollapsed ? (isRtl ? travel : -travel) : 0}, + size, + options, + ); + platter.animator.animate( + {scale: isCollapsed ? tabBar.minimizedSize / tabBar.barHeight : 1}, + size, + options, + ); + // The tabs fade as one layer: one animation, not one per tab. + items.animator.animate({opacity: isCollapsed ? 0 : 1}, fade, options); + badges.animator.animate({opacity: isCollapsed ? 0 : 1}, fade, options); + circleIcon.animator.animate( + {opacity: isCollapsed ? 1 : 0}, + isCollapsed ? size : fade, + options, + ); + // The search tab's glass shrinks to the minimized size, its glyph kept. + // Expanding as the search field's Cancel releases, it keeps that size + // until Cancel's wobble rests. + const glassHold = searchGlassHoldRef?.current ?? 0; + searchGlass.animator.animate( + {scale: isCollapsed ? tabBar.minimizedSize / tabBar.barHeight : 1}, + size, + !isCollapsed && glassHold > now ? {at: glassHold} : options, + ); + // The accessory's box changed in CSS this frame: it starts where it was + // on screen (its offset and width carried over) and springs to rest. It + // spans the whole bar: the capsule and the search tab. + const width = element.parentElement?.offsetWidth ?? element.offsetWidth; + const from = accessoryBox(width, was, hasSearch); + const to = accessoryBox(width, isCollapsed, hasSearch); + const sign = isRtl ? -1 : 1; + const cap = tabBar.accessoryHeight; + const moved = { + x: accessory.animator.get('x', now) + sign * (from.start - to.start), + y: accessory.animator.get('y', now) + (from.top - to.top), + }; + const end = + accessoryEnd.animator.get('x', now) + sign * (from.width - to.width); + const middle = + (accessoryMiddle.animator.get('scaleX', now) * (from.width - cap)) / + (to.width - cap); + // A move turned around mid-flight keeps its speed: + // each spring starts from the velocity it had (the middle's scaled with + // its value). + const velocity = { + x: accessory.animator.getVelocity('x', now), + y: accessory.animator.getVelocity('y', now), + end: accessoryEnd.animator.getVelocity('x', now), + middle: + (accessoryMiddle.animator.getVelocity('scaleX', now) * + (from.width - cap)) / + (to.width - cap), + }; + // The content is laid out at once for its new box. While the glass grows + // into it, the content's window keeps its trailing edge on the glass's + // trailing cap (the content counter-moves, so it stays put). A shrinking + // glass already holds the narrower content: no window then. + const clip = sign * Math.min(0, sign * end); + const clipVelocity = clip === 0 ? 0 : velocity.end; + accessory.animator.set(moved); + accessoryEnd.animator.set({x: end}); + accessoryMiddle.animator.set({scaleX: middle}); + accessoryClip.animator.set({x: clip}); + accessoryContent.animator.set({x: -clip}); + const spring = isCollapsed + ? tabBar.accessoryCollapse + : tabBar.accessoryExpand; + accessory.animator.animate( + {x: 0, y: 0}, + {spring, velocity: {x: velocity.x, y: velocity.y}}, + options, + ); + accessoryEnd.animator.animate( + {x: 0}, + {spring, velocity: {x: velocity.end}}, + options, + ); + accessoryMiddle.animator.animate( + {scaleX: 1}, + {spring, velocity: {scaleX: velocity.middle}}, + options, + ); + accessoryClip.animator.animate( + {x: 0}, + {spring, velocity: {x: clipVelocity}}, + options, + ); + accessoryContent.animator.animate( + {x: 0}, + {spring, velocity: {x: -clipVelocity}}, + options, + ); + }, [ + isCollapsed, + capsuleRef, + platter, + platterMiddle, + platterEnd, + badges, + circleIcon, + items, + accessory, + accessoryMiddle, + accessoryEnd, + searchGlass, + hasSearch, + accessoryClip, + accessoryContent, + ]); + + // Minimized, the bar's width can still change (a rotation, a split view): + // the trailing cap stays closed on the circle. + useEffect(() => { + const element = capsuleRef.current; + if (!isCollapsed || element == null) { + return; + } + let width = element.offsetWidth; + // Core's shared observer. Its first call, at once, finds the same width. + return observeResize(element, () => { + const next = element.offsetWidth; + if (next === width) { + return; + } + width = next; + const travel = next - tabBar.barHeight; + const isRtl = isRtlElement(element); + platterEnd.animator.set({x: isRtl ? travel : -travel}); + }); + }, [isCollapsed, capsuleRef, platterEnd]); + // While the search tab is selected the accessory steps aside. + const wasSearchingRef = useRef(isSearching); + useLayoutEffect(() => { + if (wasSearchingRef.current === isSearching) { + return; + } + wasSearchingRef.current = isSearching; + accessory.animator.animate( + {opacity: isSearching ? 0 : 1}, + {spring: tabBar.minimizeFade}, + ); + }, [isSearching, capsuleRef, accessory]); + + return { + isMinimized, + isCollapsed, + expand, + capsule, + platter: platter.ref, + platterMiddle: platterMiddle.ref, + platterEnd: platterEnd.ref, + items: items.ref, + badges: badges.ref, + circleIcon: circleIcon.ref, + accessory: accessory.ref, + accessoryMiddle: accessoryMiddle.ref, + accessoryEnd: accessoryEnd.ref, + searchGlass: searchGlass.ref, + accessoryClip: accessoryClip.ref, + accessoryContent: accessoryContent.ref, + }; +} diff --git a/packages/lab/src/TabBar/useTabBarSearch.ts b/packages/lab/src/TabBar/useTabBarSearch.ts new file mode 100644 index 0000000000000..db6955c39a913 --- /dev/null +++ b/packages/lab/src/TabBar/useTabBarSearch.ts @@ -0,0 +1,321 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useTabBarSearch.ts + * @input The capsule, the search field's host, and the search tab's state. + * Lab's keyboard measurement (../keyboard/useKeyboardHeight.ts), Core's + * animator and press tracker (internal/touch), uikitTabBar.ts and + * tabBarModel.ts. + * @output useTabBarSearch: the search tab's motion (UIKit's `UISearchTab`), + * its refs, and the active field's place above the keyboard + * @position Part of TabBar's touch layer (TabBarTouch.ts) + * + * SYNC: When modified, update TabBar.tsx, tabBarModel.ts and + * __tests__/TabBar.interaction.browser.spec.ts. + */ + +import { + useCallback, + useLayoutEffect, + useRef, + useState, + type RefObject, +} from 'react'; +import {isRtlElement, usePressTracker} from '@astryxdesign/core/internal/touch'; +import {useKeyboardHeight} from '../keyboard/useKeyboardHeight'; +import {searchCancelShift, searchFieldBox, searchLift} from './tabBarModel'; +import {useMotionAnimator} from '../internal/useMotionAnimator'; +import {tabBar} from './uikitTabBar'; + +/** The field and its button in their places in the bar. */ +const REST = {lift: 0, shift: 0}; + +export interface UseTabBarSearchOptions { + readonly capsuleRef: RefObject; + /** + * The element holding the field: focus inside it brings up the software + * keyboard, which the active field sits above. + */ + readonly hostRef: RefObject; + /** The search tab is selected. */ + readonly isSearching: boolean; + /** The search field is active (widened to span the screen). */ + readonly isActive: boolean; + /** + * A finger drives the bar: only then does the field move (on UIKit's + * springs); under a mouse it shows at once. + */ + readonly isTouch: boolean; + /** + * When the search circle's glass may grow back to its bar size (ms, the + * document timeline). Set as Cancel's release wobble starts. So the wobble + * plays at the minimized size, as UIKit's does (TabBar passes it to the + * minimize, which grows the glass). + */ + readonly glassHoldRef: RefObject; +} + +export interface TabBarSearch { + /** + * The search circle: hidden while the search tab is selected and its + * field waits inline; under a finger, the active field's cancel button. + */ + readonly circle: (element: HTMLElement | null) => void; + /** The circle's search glyph. */ + readonly searchGlyph: (element: HTMLElement | null) => void; + /** The circle's cancel glyph, while the field is active under a finger. */ + readonly cancelGlyph: (element: HTMLElement | null) => void; + /** The Cancel button over the circle: the press's target. */ + readonly cancel: (element: HTMLElement | null) => void; + /** The field's glass and content: fades in, moves between its places. */ + readonly field: (element: HTMLElement | null) => void; + /** The field glass's middle: its width. */ + readonly fieldMiddle: (element: HTMLElement | null) => void; + /** The field glass's trailing cap: its width. */ + readonly fieldEnd: (element: HTMLElement | null) => void; + /** + * Under a finger, where the active field and its cancel button sit. Their + * lift above their places in the bar (px, negative is up) and the cancel + * button's move toward the screen's trailing edge (px). Each keyboard report + * changes them once. + */ + readonly place: {readonly lift: number; readonly shift: number}; +} + +/** + * WHAT MOVES (uikitTabBar.ts, measured on iOS 26.2) + * + * Selecting the search tab collapses the bar to its leading circle, which is + * useTabBarMinimize.ts's morph. The search field shows inline in the bar's row, + * `searchFieldGap` after the circle, and the search circle fades. The field is + * always mounted, so a tap can focus it at once. Active, the field's glass + * moves and widens to span the screen but for the cancel button + * (`searchActive`, `searchMorph`). It squashes as it starts + * (`searchSquashVelocity`), its box changed in CSS and its start carried over. + * The search circle, shrunk to the minimized size, is the field's cancel + * button: its cancel glyph appears at `searchCancelDelay`, moved + * `searchCancelShift` toward the screen's edge. Pressed, it grows to + * `searchCancelPressedSize` on `searchCancelPress`. Released, it shrinks back + * with UIKit's wobble on `searchCancelReturn`. Core's press tracker reads the + * press (UIControl's highlight), only while it is the cancel button under a + * finger. Under Reduce Motion it does not scale. Field and button sit + * `searchKeyboardGap` above the software keyboard. Lab's measurement reports + * the keyboard's height from the visual viewport, and each report is committed + * once, as an offset, with no ride. Cancelling keeps that place until the + * keyboard is reported down, also when the cancel returns to the tab search was + * chosen from. Deactivating plays the morph back; leaving the search tab fades + * the field out and the circle back in. These moves run on UIKit's springs, + * under a finger only. Under a mouse the search tab selects in place and the + * field shows at once (TabBar.tsx). + */ +export function useTabBarSearch({ + capsuleRef, + hostRef, + isSearching, + isActive, + isTouch, + glassHoldRef, +}: UseTabBarSearchOptions): TabBarSearch { + const circle = useMotionAnimator(); + const searchGlyph = useMotionAnimator(); + const cancelGlyph = useMotionAnimator({initial: {opacity: 0}}); + const field = useMotionAnimator({initial: {opacity: 0}}); + const fieldMiddle = useMotionAnimator(); + const fieldEnd = useMotionAnimator(); + + // The circle shows unless the search tab waits inline (selected, its field + // not active). Active, it is the field's cancel button, which appears at + // UIKit's delay. Selecting the search tab (or leaving it) fades the field in + // (or out). + const isCancel = isSearching && isActive; + const isCircleShown = !isSearching || isCancel; + const wasShownRef = useRef(isCircleShown); + const wasSearchingRef = useRef(isSearching); + useLayoutEffect(() => { + const wasShown = wasShownRef.current; + const wasSearching = wasSearchingRef.current; + wasShownRef.current = isCircleShown; + wasSearchingRef.current = isSearching; + const fade = {spring: tabBar.minimizeFade}; + if (wasShown !== isCircleShown) { + circle.animator.animate( + {opacity: isCircleShown ? 1 : 0}, + fade, + isCancel + ? {at: performance.now() + tabBar.searchCancelDelay * 1000} + : undefined, + ); + } + if (wasSearching !== isSearching) { + // Leaving, the field stays visible while it settles back into the bar. + field.animator.animate( + {opacity: isSearching ? 1 : 0}, + isSearching ? fade : {spring: tabBar.collapseSize}, + ); + } + }, [isCircleShown, isCancel, isSearching, circle, field]); + + // The circle's glyph: the cancel glyph from UIKit's delay while it is the + // cancel button, the search glyph otherwise. + const wasCancelRef = useRef(isCancel); + useLayoutEffect(() => { + if (wasCancelRef.current === isCancel) { + return; + } + wasCancelRef.current = isCancel; + const swap = {spring: tabBar.minimizeFade}; + const options = isCancel + ? {at: performance.now() + tabBar.searchCancelDelay * 1000} + : undefined; + cancelGlyph.animator.animate({opacity: isCancel ? 1 : 0}, swap, options); + searchGlyph.animator.animate({opacity: isCancel ? 0 : 1}, swap, options); + }, [isCancel, cancelGlyph, searchGlyph]); + + // The Cancel button's press: UIControl's highlight from core's press tracker + // on the button, attached only while it shows. The circle under it draws the + // press: pressed, it grows to `searchCancelPressedSize`. Released, it shrinks + // back with UIKit's wobble, at the minimized size (the glass grows back to + // its bar size only once the wobble rests). Under Reduce Motion a press + // scales nothing. + // The Cancel button mounts only while `isCancel` holds, so its ref is + // the tracker's target. + const pressTargetRef = useRef(null); + const cancelRef = useCallback((element: HTMLElement | null) => { + pressTargetRef.current = element; + }, []); + // Whether the circle shows the press. A search ending under the press (Escape + // in the field, say) takes the tracker's target with no release. So the + // circle's return then plays from the effect below. + const isPressedRef = useRef(false); + const playPress = (isPressed: boolean) => { + isPressedRef.current = isPressed; + const options = {touchRelease: !isPressed}; + if (circle.animator.reducedMotion(options) !== 'off') { + return; + } + const now = performance.now(); + const velocity = circle.animator.getVelocity('scale', now); + const handle = circle.animator.animate( + { + scale: isPressed + ? tabBar.searchCancelPressedSize / tabBar.minimizedSize + : 1, + }, + { + spring: isPressed + ? tabBar.searchCancelPress + : tabBar.searchCancelReturn, + velocity: {scale: velocity}, + }, + options, + ); + if (!isPressed) { + const wobble = handle.curve('scale'); + glassHoldRef.current = now + (wobble?.duration ?? 0) * 1000; + } + }; + usePressTracker(pressTargetRef, {onHighlightChange: playPress}); + const playPressRef = useRef(playPress); + playPressRef.current = playPress; + useLayoutEffect(() => { + if (!isCancel && isPressedRef.current) { + playPressRef.current(false); + } + }, [isCancel]); + + // Where the active field and its cancel button sit. Above the keyboard as of + // its last report, read once per report and committed as an offset (no ride). + // After a cancel the place holds until the keyboard is reported down. So + // nothing jumps while it goes, also when the cancel returns to the tab search + // was chosen from, as the docs advise. Measured only from the field's + // activation until the keyboard is reported down after it (one listener, + // while needed). The hold is adjusted during render: set while the field is + // active, cleared once the keyboard is reported down after it. + const isFieldActive = isSearching && isActive; + const [isHeld, setIsHeld] = useState(false); + const keyboardHeight = useKeyboardHeight(isFieldActive || isHeld, hostRef); + if (isFieldActive && !isHeld) { + setIsHeld(true); + } else if (!isFieldActive && isHeld && keyboardHeight <= 0) { + setIsHeld(false); + } + const [place, setPlace] = useState(REST); + const placeRef = useRef(place); + placeRef.current = place; + useLayoutEffect(() => { + const host = hostRef.current; + const capsule = capsuleRef.current; + const isRaised = isFieldActive || (isHeld && keyboardHeight > 0); + if (!isRaised || host == null || capsule == null) { + setPlace(REST); + return; + } + const win = host.ownerDocument.defaultView; + if (win == null) { + return; + } + // Where layout puts the field without its lift. + const bottom = host.getBoundingClientRect().bottom - placeRef.current.lift; + const lift = searchLift(win.innerHeight, keyboardHeight, bottom); + const shift = isActive + ? (isRtlElement(capsule) ? -1 : 1) * searchCancelShift() + : 0; + setPlace(previous => + previous.lift === lift && previous.shift === shift + ? previous + : {lift, shift}, + ); + }, [isFieldActive, isHeld, isActive, keyboardHeight, hostRef, capsuleRef]); + + // Activating (or not): the field's box changed in CSS this frame; its + // glass starts where it was and springs to rest, squashing as it starts. + const wasActiveRef = useRef(isActive); + useLayoutEffect(() => { + const was = wasActiveRef.current; + wasActiveRef.current = isActive; + const element = capsuleRef.current; + if (!isTouch || was === isActive || element == null) { + return; + } + const bar = element.parentElement ?? element; + const isRtl = isRtlElement(element); + const sign = isRtl ? -1 : 1; + const from = searchFieldBox(bar.offsetWidth, was); + const to = searchFieldBox(bar.offsetWidth, isActive); + const now = performance.now(); + const cap = tabBar.accessoryHeight; + field.animator.set({ + x: field.animator.get('x', now) + sign * (from.start - to.start), + }); + fieldEnd.animator.set({ + x: fieldEnd.animator.get('x', now) + sign * (from.width - to.width), + }); + fieldMiddle.animator.set({ + scaleX: + (fieldMiddle.animator.get('scaleX', now) * (from.width - cap)) / + (to.width - cap), + }); + const morph = {spring: tabBar.searchMorph}; + field.animator.animate({x: 0}, morph); + // The width's squash: its trailing cap and middle start moving inward. + const squash = isActive ? tabBar.searchSquashVelocity : 0; + fieldEnd.animator.animate({x: 0}, {...morph, velocity: {x: sign * squash}}); + fieldMiddle.animator.animate( + {scaleX: 1}, + {...morph, velocity: {scaleX: squash / (to.width - cap)}}, + ); + }, [isActive, isTouch, capsuleRef, field, fieldEnd, fieldMiddle]); + + return { + circle: circle.ref, + searchGlyph: searchGlyph.ref, + cancelGlyph: cancelGlyph.ref, + cancel: cancelRef, + field: field.ref, + fieldMiddle: fieldMiddle.ref, + fieldEnd: fieldEnd.ref, + place, + }; +} diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index 215132299f8f2..da09e978a45fa 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -344,8 +344,13 @@ export { export { TabBar, TabBarItem, + TabBarMoreList, + TabBarSearchTab, + type TabBarAccessoryPlacement, type TabBarItemProps, + type TabBarMoreListProps, type TabBarProps, + type TabBarSearchTabProps, } from './TabBar'; // RichTextEditor (RFC meta/astryx#3899) moved out of @astryxdesign/lab into