diff --git a/.circleci/config.yml b/.circleci/config.yml index 3454ad5b7134..83822bea28c8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -103,9 +103,9 @@ jobs: ESMODULE_RELEVANT=false fi # Docs/website/tooling-only changes can't affect lint, typecheck or - # unit tests (Playground has unit tests, so it stays relevant). + # unit tests (Playground and motion have unit tests, so they stay relevant). if ! grep -Evq "^(${DOCS_ONLY})" /tmp/ci-changed-files \ - && ! grep -q '^website/src/components/Playground/' /tmp/ci-changed-files; then + && ! grep -Eq '^website/src/components/(Playground|motion)/' /tmp/ci-changed-files; then echo "Only docs/website/tooling paths changed; test jobs will halt." TESTS_RELEVANT=false fi @@ -184,8 +184,9 @@ jobs: - project/node_modules - project/packages - project/scripts - # Playground unit tests (transformCode, codeModel); rest of website omitted + # Playground and motion unit tests; rest of website omitted - project/website/src/components/Playground + - project/website/src/components/motion - project/.yarnrc.yml - project/babel.config.js - project/eslint.config.mjs diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md index 7509cb372c7c..7fa713e54957 100644 --- a/.claude/rules/ci-config.md +++ b/.claude/rules/ci-config.md @@ -13,7 +13,7 @@ paths: - Jest `--maxWorkers` is pinned per job to the `resource_class` vCPU count (large = 4, medium = 2) because docker containers report the host's CPUs via `os.cpus()`. Exception: the ReactNative `unit_tests` run is deliberately uncapped — its suites are fake-timer-wait dominated and capping workers flakes 5s test timeouts. - Jobs halt via the `halt-unless-relevant-change` command based on flags computed once in `setup` (`.ci-esmodule-relevant`, `.ci-tests-relevant`) and transported via `save_cache`/`restore_cache` (keyed on `CIRCLE_SHA1`) so jobs can halt before paying `attach_workspace`. Missing/unreadable flags fail open (jobs run). On the default branch both flags are always true (a push may carry several commits). The diff uses `--no-renames` so moving a file out of a relevant dir still counts. - `esmodule` (validate-esmodule-browser-build, esmodule-types*): a denylist, so new paths fail open. False only when every changed path is provably outside the esmodule jobs' inputs: the shared `DOCS_ONLY` paths (also the `tests` denylist) plus `.vscode/`, `plans/`, root `__tests__/` (excluded by every `tsconfig.compile.json`), `eslint.config.mjs`, `jest.config.js`, `examples/*.md`, and examples the jobs never build (`benchmark`, `benchmark-react`, `coin-app`, `nextjs`, `normalizr-github`, `normalizr-redux`, `test-bundlesize`, `vue-todo-app`). Only add a path if no esmodule job (or the `setup` builds feeding them) reads it. - - `tests` (lint, typecheck, unit_tests, node_matrix): false only when every changed path is docs/website/tooling (`website/`, `docs/`, `.changeset/`, `.cursor/`, `.agents/`, `.claude/`, `.github/`, root `*.md`), except `website/src/components/Playground/` (has unit tests). When both flags are false, `setup` halts before install. + - `tests` (lint, typecheck, unit_tests, node_matrix): false only when every changed path is docs/website/tooling (`website/`, `docs/`, `.changeset/`, `.cursor/`, `.agents/`, `.claude/`, `.github/`, root `*.md`), except `website/src/components/Playground/` and `website/src/components/motion/` (have unit tests). When both flags are false, `setup` halts before install. - Legacy TS types (`ci:build:legacy-types`, consumed by `esmodule-types`): - Built inside `setup` (`ci:build:setup:esmodule`) only when the esmodule flag is set; there is no separate job, to keep a job hop off the critical path. - CI builds the endpoint, normalizr and rest legacy outputs, all for TS >= 4.0 (the minimum supported TS, and the oldest in the `esmodule-types` matrix). `use-enhanced-reducer` still ships a `ts3.4` build in release builds (`build:types`). diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index fdf8974e6368..76cc5bd39dc9 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -11,7 +11,7 @@ alwaysApply: false - Jest `--maxWorkers` is pinned per job to the `resource_class` vCPU count (large = 4, medium = 2) because docker containers report the host's CPUs via `os.cpus()`. Exception: the ReactNative `unit_tests` run is deliberately uncapped — its suites are fake-timer-wait dominated and capping workers flakes 5s test timeouts. - Jobs halt via the `halt-unless-relevant-change` command based on flags computed once in `setup` (`.ci-esmodule-relevant`, `.ci-tests-relevant`) and transported via `save_cache`/`restore_cache` (keyed on `CIRCLE_SHA1`) so jobs can halt before paying `attach_workspace`. Missing/unreadable flags fail open (jobs run). On the default branch both flags are always true (a push may carry several commits). The diff uses `--no-renames` so moving a file out of a relevant dir still counts. - `esmodule` (validate-esmodule-browser-build, esmodule-types*): a denylist, so new paths fail open. False only when every changed path is provably outside the esmodule jobs' inputs: the shared `DOCS_ONLY` paths (also the `tests` denylist) plus `.vscode/`, `plans/`, root `__tests__/` (excluded by every `tsconfig.compile.json`), `eslint.config.mjs`, `jest.config.js`, `examples/*.md`, and examples the jobs never build (`benchmark`, `benchmark-react`, `coin-app`, `nextjs`, `normalizr-github`, `normalizr-redux`, `test-bundlesize`, `vue-todo-app`). Only add a path if no esmodule job (or the `setup` builds feeding them) reads it. - - `tests` (lint, typecheck, unit_tests, node_matrix): false only when every changed path is docs/website/tooling (`website/`, `docs/`, `.changeset/`, `.cursor/`, `.agents/`, `.claude/`, `.github/`, root `*.md`), except `website/src/components/Playground/` (has unit tests). When both flags are false, `setup` halts before install. + - `tests` (lint, typecheck, unit_tests, node_matrix): false only when every changed path is docs/website/tooling (`website/`, `docs/`, `.changeset/`, `.cursor/`, `.agents/`, `.claude/`, `.github/`, root `*.md`), except `website/src/components/Playground/` and `website/src/components/motion/` (have unit tests). When both flags are false, `setup` halts before install. - Legacy TS types (`ci:build:legacy-types`, consumed by `esmodule-types`): - Built inside `setup` (`ci:build:setup:esmodule`) only when the esmodule flag is set; there is no separate job, to keep a job hop off the critical path. - CI builds the endpoint, normalizr and rest legacy outputs, all for TS >= 4.0 (the minimum supported TS, and the oldest in the `esmodule-types` matrix). `use-enhanced-reducer` still ships a `ts3.4` build in release builds (`build:types`). diff --git a/jest.config.js b/jest.config.js index 1dab51d128b7..04402ea41909 100644 --- a/jest.config.js +++ b/jest.config.js @@ -57,17 +57,17 @@ const packages = [ 'test', ]; -// CircleCI persist_to_workspace omits most of website/; only include this root -// when the tree is present (full checkout / when CI persists Playground). -const playgroundRoot = path.join( - __dirname, +// CircleCI persist_to_workspace omits most of website/; only include these +// roots when the tree is present (full checkout / when CI persists them). +const websiteRoots = [ 'website/src/components/Playground', -); + 'website/src/components/motion', +]; const reactDomRoots = [ ...packages.map(pkgName => `/packages/${pkgName}/src`), - ...(fs.existsSync(playgroundRoot) ? - ['/website/src/components/Playground'] - : []), + ...websiteRoots + .filter(root => fs.existsSync(path.join(__dirname, root))) + .map(root => `/${root}`), ]; const projects = [ diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index c2502d003fcc..7c423d98f011 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -7,6 +7,7 @@ import path from 'path'; import { themes } from 'prism-react-renderer'; import gqlRedirects from './gqlRedirects'; +import { motionCss } from './src/components/motion/css'; import versions from './versions.json'; // Keep Monaco CDN preload hashes in sync with the installed monaco-editor package. @@ -299,6 +300,13 @@ const config: Config = { ], ], plugins: [ + // global motion styles (spring tokens, ): website/src/components/motion/css.ts + () => ({ + name: 'motion-css', + injectHtmlTags: () => ({ + headTags: [{ tagName: 'style', innerHTML: motionCss() }], + }), + }), [ '@docusaurus/plugin-content-docs', { diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index 1e73c1e9d69e..ca19ebec110f 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -125,7 +125,10 @@ DesignSystem/ components injected into preview scope - Each playground gets its own `DataProvider` store (`MockResolver` serves `fixtures`); `memo(Preview)` keeps it from re-rendering on code edits. - Store inspector open state persists per `groupId` via tab storage and - avoids scroll jumps; in `row` layout it replaces the result while open. + avoids scroll jumps; in `row` layout it covers the result while open (the + result stays rendered underneath, `inert`). It opens and closes as a + drawer (`../motion`: the toggle glides, the panel `Reveal`s); the panel's + contents render a frame after it starts moving (`useDeferredValue`). - `renderCount` wraps the live result in a `` and shows its commit count in the preview header (written to the DOM, so counting adds no commits). `website/profiling-plugin.js` replaces `react-dom/client` with React's diff --git a/website/src/components/Playground/preview/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx index 1b5c6bdc9f7d..4651a66a63db 100644 --- a/website/src/components/Playground/preview/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -16,6 +16,7 @@ import React, { type ProfilerOnRenderCallback, } from 'react'; +import { MotionGroup } from '../../motion'; import Boundary from '../Boundary'; import type { PreviewErrorProps } from './PreviewError'; import StoreInspector from './StoreInspector'; @@ -64,7 +65,7 @@ function Preview({ [], ); - const hiddenResult = row && selectedValue === 'y'; + const coveredResult = row && selectedValue === 'y'; return ( ({ silenceMissing={true} getInitialInterceptorData={getInitialInterceptorData} > -
- - - -
- + +
+ + + +
+ +
); diff --git a/website/src/components/Playground/preview/StoreInspector.tsx b/website/src/components/Playground/preview/StoreInspector.tsx index 81d861af8e89..0b05d9ab697e 100644 --- a/website/src/components/Playground/preview/StoreInspector.tsx +++ b/website/src/components/Playground/preview/StoreInspector.tsx @@ -1,7 +1,8 @@ import { StateContext } from '@data-client/react'; import clsx from 'clsx'; -import React, { useContext, memo, useMemo } from 'react'; +import React, { useContext, useDeferredValue, memo, useMemo } from 'react'; +import { Reveal, useLayoutMotion } from '../../motion'; import styles from '../styles.module.css'; import Tree from './Tree'; @@ -13,12 +14,16 @@ function StoreInspector({ toggle: React.MouseEventHandler; }) { const isSelected = selectedValue === 'y'; + // the empty drawer starts moving at once; the tree renders a frame later + const showTree = useDeferredValue(isSelected); return ( <> - {isSelected ? - - : null} + + {showTree ? + + : null} + ); } @@ -32,8 +37,9 @@ export function StoreToggle({ onClick?: React.MouseEventHandler; open?: boolean; }) { + const ref = useLayoutMotion(); return ( -
+
Store .playgroundHeader, } .arrow { - transition: all 200ms ease 0s; + transition: transform var(--motion-snappy); transform-origin: 45% 50% 0px; position: relative; display: inline-block; @@ -267,6 +267,9 @@ div.playgroundTextEdit > .playgroundHeader, .playgroundResult { display: flex; height: 100%; + /* frame for the Store drawer as it slides in and out */ + position: relative; + isolation: isolate; } .debugToggle { @@ -284,6 +287,26 @@ div.playgroundTextEdit > .playgroundHeader, flex: 0 0 auto; } +/* The Store drawer (painted before its contents render) */ +.storePanel { + flex: 4 1 40%; + min-width: 0; + background: var(--monoco-code-background); +} +[data-theme='dark'] .storePanel { + /* opaque, so the covered result never shows through; the tree inside + adds the translucent code tint on top */ + background: var(--ifm-background-color); +} + +/* Row layout with the Store open: the result stays underneath, so the Store + slides over real content instead of an empty frame */ +.covered { + position: absolute; + inset: 0; + z-index: -1; +} + .debugToggle:hover { background-color: var(--pg-tab-hover-bg); color: var(--pg-tab-hover); diff --git a/website/src/components/motion/MotionGroup.tsx b/website/src/components/motion/MotionGroup.tsx new file mode 100644 index 000000000000..adbc33b75a57 --- /dev/null +++ b/website/src/components/motion/MotionGroup.tsx @@ -0,0 +1,223 @@ +import React, { + Component, + createContext, + useCallback, + useContext, + useLayoutEffect, + useRef, + type RefCallback, + type RefObject, +} from 'react'; + +import { glide, velocityOf, stop, ORIGIN, type Point } from './glide'; +import type { Spring } from './spring'; +import { springs } from './tokens'; + +/** How a member enters and leaves; absent for members that only move */ +interface Presence { + /** Leaving: slides out past the end of its container; its owner unmounts it */ + exiting: boolean; +} +type Members = Map>; + +interface Snapshot { + /** Position on screen (including any glide in flight), parent-relative */ + at: Point; + velocity: Point; + /** Layout box (no transforms), to pin a presence where it was if it exits */ + box: Box; +} +interface Box { + left: number; + top: number; + width: number; + height: number; +} + +interface Move { + el: HTMLElement; + from: Point; + to: Point; + velocity?: Point; +} + +const GroupContext = createContext(null); + +interface Props { + /** Layout only animates when this changes (e.g. the open state) */ + layoutDependency: unknown; + spring?: Spring; + children: React.ReactNode; +} + +/** + * Animates layout changes React commits inside it: members glide from where + * they were on screen to their new place (FLIP, translate only, so content + * never distorts), and ``s slide in and out along their container's + * flow. One spring drives every member, so things that move together read as + * one physical object. A change mid-flight keeps each member's momentum. + * + * Measures before React touches the DOM, so it must re-render with the + * change it animates: put it where that state lives, and pass that state as + * `layoutDependency`. + */ +export default class MotionGroup extends Component { + private members: Members = new Map(); + + getSnapshotBeforeUpdate(prev: Props): Map | null { + if (Object.is(prev.layoutDependency, this.props.layoutDependency)) + return null; + const snapshots = new Map(); + // reduced motion: nothing to measure, everything lands in place + if (prefersReducedMotion()) return snapshots; + for (const el of this.members.keys()) { + snapshots.set(el, { + at: parentRelative(el), + velocity: velocityOf(el), + box: { + left: el.offsetLeft, + top: el.offsetTop, + width: el.offsetWidth, + height: el.offsetHeight, + }, + }); + } + return snapshots; + } + + componentDidUpdate( + _props: unknown, + _state: unknown, + snapshots: Map | null, + ) { + if (!snapshots) return; + if (prefersReducedMotion()) { + // land in place; a reopened exit rejoins the layout + for (const [el, { current }] of this.members) { + stop(el); + if (!current?.exiting) unpin(el); + } + return; + } + const { spring = springs.smooth } = this.props; + // settle the final layout before measuring anything + for (const [el, { box }] of snapshots) { + stop(el); + if (this.members.get(el)?.current?.exiting) pin(el, box); + else unpin(el); + } + // measure everything, then start animations (one style recalc) + const moves: Move[] = []; + for (const [el, { current: presence }] of this.members) { + const before = snapshots.get(el); + if (!before) { + // just mounted: a presence arrives from the end of its container's flow + if (presence) moves.push({ el, from: exitOffset(el), to: ORIGIN }); + continue; + } + const at = parentRelative(el); + moves.push({ + el, + from: { x: before.at.x - at.x, y: before.at.y - at.y }, + to: presence?.exiting ? exitOffset(el) : ORIGIN, + velocity: before.velocity, + }); + } + for (const { el, ...path } of moves) glide(el, spring, path); + } + + render() { + return ( + {this.props.children} + ); + } +} + +/** Ref joining an element to the nearest `` */ +export function useMember(presence?: Presence): RefCallback { + const members = useContext(GroupContext); + const latest = useRef(presence); + // runs before the group's componentDidUpdate in the same commit + useLayoutEffect(() => { + latest.current = presence; + }); + return useCallback( + (el: HTMLElement | null) => { + if (!el || !members) return; + members.set(el, latest); + return () => { + members.delete(el); + }; + }, + [members], + ); +} + +/** + * Whether the nearest `` would glide `el` when the layout + * changes; if not, changes land at once + */ +export function useWillGlide(): (el: HTMLElement) => boolean { + const members = useContext(GroupContext); + return useCallback( + el => + !!members && typeof el.animate === 'function' && !prefersReducedMotion(), + [members], + ); +} + +// members are mounted while the group measures them, so they have a parent +function parentOf(el: HTMLElement) { + return el.parentElement as HTMLElement; +} + +function parentRelative(el: HTMLElement): Point { + const rect = el.getBoundingClientRect(); + const parent = parentOf(el).getBoundingClientRect(); + return { x: rect.left - parent.left, y: rect.top - parent.top }; +} + +/** Just past the end of the parent's main axis (reversed and RTL aware) */ +function exitOffset(el: HTMLElement): Point { + const parent = parentOf(el); + const { flexDirection, direction } = getComputedStyle(parent); + const column = flexDirection.startsWith('column'); + const towardStart = + flexDirection.endsWith('reverse') !== (!column && direction === 'rtl'); + const [pos, size, extent] = + column ? + [el.offsetTop, el.offsetHeight, parent.clientHeight] + : [el.offsetLeft, el.offsetWidth, parent.clientWidth]; + const distance = towardStart ? -(pos + size) : Math.max(extent - pos, size); + return column ? { x: 0, y: distance } : { x: distance, y: 0 }; +} + +const unpinned = new WeakMap(); +/** Takes an exiting element out of flow, so siblings take its space at once */ +function pin(el: HTMLElement, { left, top, width, height }: Box) { + if (!unpinned.has(el)) unpinned.set(el, el.style.cssText); + Object.assign(el.style, { + position: 'absolute', + boxSizing: 'border-box', + left: `${left}px`, + top: `${top}px`, + width: `${width}px`, + height: `${height}px`, + }); +} +function unpin(el: HTMLElement) { + const cssText = unpinned.get(el); + if (cssText === undefined) return; + el.style.cssText = cssText; + unpinned.delete(el); +} + +// same as @docusaurus/theme-common's, but safe without matchMedia (jsdom) +function prefersReducedMotion() { + return !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; +} + +/** Ref for an element that glides to its new place instead of jumping */ +export function useLayoutMotion() { + return useMember(); +} diff --git a/website/src/components/motion/README.md b/website/src/components/motion/README.md new file mode 100644 index 000000000000..48f2256d5725 --- /dev/null +++ b/website/src/components/motion/README.md @@ -0,0 +1,92 @@ +# Motion + +Physical, interruptible animation for the website. Things move like objects +with mass: they take time to get going, settle without a hard stop, and a +change of mind mid-flight turns them around with their momentum instead of +restarting. + +## Using it + +Pick motion by what moves, never by milliseconds: + +| Token | For | +| ---------------- | ------------------------------------------- | +| `springs.snappy` | small, light things: arrows, chips, toggles | +| `springs.smooth` | panels and drawers that carry content | + +**CSS transitions** (state changes styled by a class, like a rotating arrow): + +```css +.arrow { + transition: transform var(--motion-snappy); +} +``` + +**Layout changes** (something opens, so things move): + +```tsx +import { MotionGroup, Reveal, useLayoutMotion } from '../motion'; + +function Drawer({ open }: { open: boolean }) { + return ( + // where the state lives; layoutDependency says which change to animate + + + + {/* slides in from the end of its flex container, out the same way */} + + + + + ); +} + +function Handle() { + // inside the group: glides to its new spot instead of jumping + const ref = useLayoutMotion(); + return
; +} +``` + +`Reveal` follows the flex direction, so a panel that slides in sideways on +desktop rises from the bottom when a container query stacks it. It slides +over its siblings, and its contents fill it: clip it with `overflow: hidden` +on an ancestor, and make its parent `position: relative` (exits are pinned +there while they leave). + +Reduced motion (`prefers-reduced-motion: reduce`) lands everything in place +instantly, in CSS and JS alike. + +## Why it is built this way + +- **FLIP, translate only.** React commits the final layout at once; each + member is measured before and after, then animated from where it was to + where it is with `translate`. Content is never scaled, so text never + distorts, and layout is never animated, so no frame re-runs layout. +- **Compositor-driven.** Animations are Web Animations with keyframes sampled + from the spring, so the browser runs them off the main thread. Opening the + Store mounts an expensive tree in the same moment; a JS (rAF) animation + would stutter exactly then, this one does not. +- **Springs, not easing curves.** `spring.ts` solves a damped harmonic + oscillator in closed form from a perceptual `duration` and `bounce`. + Knowing the exact position and velocity at any instant is what lets an + interruption continue smoothly. +- **Not React ``** (React 19.3): it animates snapshots, blocks + input while running, can't hand velocity to a reversal, and only runs for + transition updates (the Store's open state is a synchronous store). +- **Not Motion (framer-motion) `layout`**: it computes layout animations on + the main thread every frame, and adds tens of KB. + +## Files + +``` +spring.ts spring physics, keyframe sampling, CSS linear() easing +tokens.ts the named springs +css.ts global CSS (springs as custom properties, Reveal's box), + injected into every page by a plugin in docusaurus.config.ts +glide.ts runs and retargets one element's translate (Web Animations) +MotionGroup.tsx measures before/after a commit; drives members; useLayoutMotion +Reveal.tsx presence: mount, slide in, slide out, unmount +``` + +Tests: `yarn test --selectProjects ReactDOM --testPathPatterns website/src/components/motion` diff --git a/website/src/components/motion/Reveal.tsx b/website/src/components/motion/Reveal.tsx new file mode 100644 index 000000000000..4d0d0a5df70c --- /dev/null +++ b/website/src/components/motion/Reveal.tsx @@ -0,0 +1,66 @@ +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +import { settled } from './glide'; +import { useMember, useWillGlide } from './MotionGroup'; + +/** + * Shows `children` while `show`, sliding in from and back out past the end of + * its flex container (so it follows the layout: sideways in a row, up from + * the bottom when stacked). Stays mounted until its exit finishes, so + * reopening mid-exit just turns it around. The motion comes from the nearest + * ``; without one it just mounts and unmounts. + * + * It slides over its siblings; clip it with `overflow: hidden` on an ancestor. + */ +export default function Reveal({ + show, + className, + children, +}: { + show: boolean; + /** Layout of the sliding box: it is the flex item; its children fill it */ + className?: string; + children: React.ReactNode; +}) { + const [mounted, setMounted] = useState(show); + if (show && !mounted) setMounted(true); + // keep showing what it had while it slides out + const shown = useRef(children); + if (show) shown.current = children; + const memberRef = useMember({ exiting: !show }); + const willGlide = useWillGlide(); + const el = useRef(null); + const ref = useCallback( + (node: HTMLDivElement | null) => { + el.current = node; + return memberRef(node); + }, + [memberRef], + ); + // leaves once it comes to rest: at once (before it paints in flow) if + // nothing will slide it out, else after the group's glide settles + useLayoutEffect(() => { + const node = el.current; + if (show || !node) return; + if (!willGlide(node)) { + setMounted(false); + return; + } + let reopened = false; + // the group starts its glide later in this commit + queueMicrotask(() => + settled(node).then(() => reopened || setMounted(false)), + ); + return () => { + reopened = true; + }; + }, [show, willGlide]); + return mounted ? +
+ {shown.current} +
+ : null; +} diff --git a/website/src/components/motion/__tests__/MotionGroup.test.tsx b/website/src/components/motion/__tests__/MotionGroup.test.tsx new file mode 100644 index 000000000000..7ab27acc836c --- /dev/null +++ b/website/src/components/motion/__tests__/MotionGroup.test.tsx @@ -0,0 +1,297 @@ +/// + +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { MotionGroup, Reveal, useLayoutMotion } from '..'; + +// jsdom has no layout or Web Animations: give every box a size and record +// the animations started +const PARENT_WIDTH = 300; +const animations: { + el: Element; + keyframes: Keyframe[]; + animation: FakeAnimation; +}[] = []; +type FakeAnimation = Partial> & { + playState: AnimationPlayState; +}; +beforeEach(() => { + animations.length = 0; + jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(100); + jest + .spyOn(HTMLElement.prototype, 'clientWidth', 'get') + .mockReturnValue(PARENT_WIDTH); + HTMLElement.prototype.animate = function (keyframes: any) { + let settle!: (finished: boolean) => void; + const finished = new Promise((resolve, reject) => { + settle = done => + done ? resolve(animation as Animation) : reject(new Error()); + }); + // like a browser's, a cancelled glide's rejection is fine to ignore + finished.catch(() => undefined); + const animation: FakeAnimation = { + playState: 'running', + currentTime: null, + finished, + cancel: jest.fn(() => { + animation.playState = 'idle'; + settle(false); + }), + finish: () => { + animation.playState = 'finished'; + settle(true); + }, + }; + animations.push({ el: this, keyframes, animation }); + return animation as unknown as Animation; + }; +}); +afterEach(() => { + jest.restoreAllMocks(); + delete (window as any).matchMedia; + delete (HTMLElement.prototype as any).animate; +}); + +function Handle() { + return
; +} +/** Reveals leave once at rest, a microtask after the commit */ +const settledReveals = () => act(() => Promise.resolve()); + +// a group that animates opening, but closing doesn't change layoutDependency +function OpensOnly({ open, opened }: { open: boolean; opened: number }) { + return ( + + panel + + ); +} +function Drawer({ open, label = 'panel' }: { open: boolean; label?: string }) { + return ( + + + {label} + + ); +} + +it('does not animate what is there on first render', () => { + render(); + expect(screen.getByText('panel')).toBeTruthy(); + expect(animations).toEqual([]); +}); + +it('slides a revealed element in from the end of its container', () => { + const { rerender } = render(); + rerender(); + const panel = screen.getByText('panel'); + const enter = animations.find(({ el }) => el === panel); + expect(enter?.keyframes[0].translate).toBe(`${PARENT_WIDTH}px 0px`); + expect(enter?.keyframes.at(-1)?.translate).toBe('0px 0px'); +}); + +it('keeps an exiting element, with its last content, until it slides out', async () => { + const { rerender } = render(); + rerender(); + const exit = animations.find(({ el }) => el.textContent === 'first'); + expect(exit?.keyframes.at(-1)?.translate).toBe(`${PARENT_WIDTH}px 0px`); + expect((exit?.el as HTMLElement).style.position).toBe('absolute'); + await settledReveals(); + expect(screen.getByText('first')).toBeTruthy(); + + await act(async () => exit?.animation.finish?.()); + expect(screen.queryByText('first')).toBeNull(); +}); + +it('turns around mid-exit instead of remounting', () => { + const { rerender } = render(); + rerender(); + const panel = screen.getByText('panel'); + rerender(); + expect(screen.getByText('panel')).toBe(panel); + expect(panel.style.position).toBe(''); +}); + +it('only measures when layoutDependency changes', () => { + const rect = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect'); + const { rerender } = render(); + rerender(); + expect(rect).not.toHaveBeenCalled(); +}); + +it('lands in place when the user prefers reduced motion', () => { + window.matchMedia = jest.fn().mockReturnValue({ matches: true }); + const { rerender } = render(); + rerender(); + expect(animations).toEqual([]); + expect(screen.queryByText('panel')).toBeNull(); +}); + +it('leaves at once when no group slides it out', () => { + const { rerender } = render(alone); + rerender(alone); + expect(screen.queryByText('alone')).toBeNull(); +}); + +it('leaves on its own when the group ignores the change', async () => { + const { rerender } = render(); + rerender(); + await settledReveals(); + expect(screen.queryByText('panel')).toBeNull(); +}); + +it('reverses with the momentum it had mid-flight', () => { + const { rerender } = render(); + rerender(); + const panel = screen.getByText('panel'); + const enter = animations.find(({ el }) => el === panel); + // 100ms into sliding in (moving towards the start) + (enter as any).animation.currentTime = 100; + rerender(); + const exit = animations.at(-1); + expect(exit?.el).toBe(panel); + const x = (i: number) => parseFloat(exit?.keyframes[i].translate as string); + // keeps moving the way it was going before turning around + expect(x(1)).toBeLessThan(x(0)); + expect(x(exit!.keyframes.length - 1)).toBe(PARENT_WIDTH); +}); + +it('slides along a column container vertically', () => { + jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(200); + function Column({ open }: { open: boolean }) { + return ( +
+ +
+ ); + } + const { rerender, container } = render(); + (container.firstChild as HTMLElement).style.display = 'flex'; + rerender(); + const panel = screen.getByText('panel'); + const enter = animations.find(({ el }) => el === panel); + expect(enter?.keyframes[0].translate).toBe('0px 200px'); +}); + +it('lands in place without Web Animations', () => { + delete (HTMLElement.prototype as any).animate; + const { rerender } = render(); + rerender(); + expect(screen.queryByText('panel')).toBeNull(); +}); + +it('does not slide in members that only move', () => { + function Late({ open }: { open: boolean }) { + return ( + {open && } + ); + } + const { rerender } = render(); + rerender(); + expect(animations).toEqual([]); +}); + +it('keeps an exiting element pinned through further changes', () => { + function Steps({ step }: { step: number }) { + return ( + + panel + + ); + } + const { rerender } = render(); + rerender(); + rerender(); + const panel = screen.getByText('panel'); + expect(panel.style.position).toBe('absolute'); + rerender(); + expect(panel.style.position).toBe(''); +}); + +it('keeps its own class alongside the one it is given', () => { + render( + + styled + , + ); + expect(screen.getByText('styled').className).toBe('motion-reveal panel'); +}); + +it.each([ + ['a reversed row', { flexDirection: 'row-reverse' }], + ['a right-to-left row', { direction: 'rtl' }], +] as const)('slides toward the start in %s', (_, style) => { + function Reversed({ open }: { open: boolean }) { + return ( +
+ +
+ ); + } + const { rerender } = render(); + rerender(); + const panel = screen.getByText('panel'); + const enter = animations.find(({ el }) => el === panel); + // offsetLeft is 0 in jsdom, so it starts one width (100px) past the start + expect(enter?.keyframes[0].translate).toBe('-100px 0px'); +}); + +it('stops glides in flight when reduced motion turns on', () => { + const { rerender } = render(); + rerender(); + const enter = animations.find(({ el }) => el === screen.getByText('panel')); + window.matchMedia = jest.fn().mockReturnValue({ matches: true }); + rerender(); + expect(enter?.animation.cancel).toHaveBeenCalled(); +}); + +it('leaves once its entrance settles when the group ignores the close', async () => { + const { rerender } = render(); + rerender(); + const enter = animations.find(({ el }) => el === screen.getByText('panel')); + rerender(); + await settledReveals(); + expect(screen.getByText('panel')).toBeTruthy(); + await act(async () => enter?.animation.finish?.()); + expect(screen.queryByText('panel')).toBeNull(); +}); + +it('stays when reopened before a settling glide finishes', async () => { + const { rerender } = render(); + rerender(); + const enter = animations.find(({ el }) => el === screen.getByText('panel')); + rerender(); + rerender(); + await settledReveals(); + await act(async () => enter?.animation.finish?.()); + expect(screen.getByText('panel')).toBeTruthy(); +}); + +it('leaves once the glide that retargets its exit settles', async () => { + function Steps({ step }: { step: number }) { + return ( + + panel + + ); + } + const { rerender } = render(); + rerender(); + await settledReveals(); + rerender(); + await settledReveals(); + expect(screen.getByText('panel')).toBeTruthy(); + await act(async () => animations.at(-1)?.animation.finish?.()); + expect(screen.queryByText('panel')).toBeNull(); +}); + +it('rejoins the layout when reopened mid-exit under reduced motion', () => { + const { rerender } = render(); + rerender(); + const panel = screen.getByText('panel'); + expect(panel.style.position).toBe('absolute'); + window.matchMedia = jest.fn().mockReturnValue({ matches: true }); + rerender(); + expect(panel.style.position).toBe(''); +}); diff --git a/website/src/components/motion/__tests__/css.test.ts b/website/src/components/motion/__tests__/css.test.ts new file mode 100644 index 000000000000..39cb3d34e288 --- /dev/null +++ b/website/src/components/motion/__tests__/css.test.ts @@ -0,0 +1,14 @@ +import { motionCss } from '../css'; +import { springEasing } from '../spring'; +import { springs } from '../tokens'; + +it('declares every spring, instant under reduced motion', () => { + const css = motionCss(); + for (const [name, spring] of Object.entries(springs)) { + const { duration, easing } = springEasing(spring); + expect(css).toContain(`--motion-${name}: ${duration}ms ${easing};`); + expect(css).toMatch( + new RegExp(`prefers-reduced-motion: reduce.*--motion-${name}: 0s;`), + ); + } +}); diff --git a/website/src/components/motion/__tests__/spring.test.ts b/website/src/components/motion/__tests__/spring.test.ts new file mode 100644 index 000000000000..c1163945da59 --- /dev/null +++ b/website/src/components/motion/__tests__/spring.test.ts @@ -0,0 +1,74 @@ +import { sampleSpring, springAt, springEasing, SAMPLE_RATE } from '../spring'; + +const smooth = { duration: 0.4, bounce: 0.15 }; +const critical = { duration: 0.4, bounce: 0 }; + +describe('springAt', () => { + it.each([smooth, critical])('starts where it is told (%o)', spring => { + expect(springAt(spring, { offset: 100, velocity: -50 }, 0)).toEqual({ + offset: 100, + velocity: -50, + }); + }); + + it.each([smooth, critical])( + 'velocity is the derivative of offset (%o)', + spring => { + const start = { offset: 80, velocity: 300 }; + const h = 1e-6; + for (const t of [0.05, 0.13, 0.3]) { + const numeric = + (springAt(spring, start, t + h).offset - + springAt(spring, start, t - h).offset) / + (2 * h); + expect(springAt(spring, start, t).velocity).toBeCloseTo(numeric, 3); + } + }, + ); + + it('critically damped never overshoots from rest', () => { + const samples = sampleSpring(critical, { offset: 1, velocity: 0 }, 1e-4); + expect(Math.min(...samples)).toBeGreaterThanOrEqual(0); + }); + + it('bounce overshoots', () => { + const samples = sampleSpring(smooth, { offset: 1, velocity: 0 }, 1e-4); + expect(Math.min(...samples)).toBeLessThan(0); + }); + + it('carries momentum: a moving start keeps going before returning', () => { + const { offset } = springAt(smooth, { offset: 0, velocity: 500 }, 0.05); + expect(offset).toBeGreaterThan(0); + }); +}); + +describe('sampleSpring', () => { + it('ends exactly at the target after roughly the visual duration', () => { + const samples = sampleSpring(smooth, { offset: 300, velocity: 0 }, 0.25); + expect(samples[0]).toBe(300); + expect(samples.at(-1)).toBe(0); + const seconds = (samples.length - 1) / SAMPLE_RATE; + expect(seconds).toBeGreaterThan(smooth.duration); + expect(seconds).toBeLessThan(smooth.duration * 2); + }); + + it('lets a long spring settle instead of snapping to the end', () => { + const slow = { duration: 10, bounce: 0 }; + const samples = sampleSpring(slow, { offset: 300, velocity: 0 }, 0.25); + expect(Math.abs(samples.at(-2)!)).toBeLessThan(0.5); + }); + + it('is a single frame when already at rest', () => { + expect(sampleSpring(smooth, { offset: 0, velocity: 0 }, 0.25)).toEqual([ + 0, 0, + ]); + }); +}); + +describe('springEasing', () => { + it('is a CSS linear() from 0 to 1', () => { + const { easing, duration } = springEasing(smooth); + expect(easing).toMatch(/^linear\(0, .*, 1\)$/); + expect(duration).toBeGreaterThan(400); + }); +}); diff --git a/website/src/components/motion/css.ts b/website/src/components/motion/css.ts new file mode 100644 index 000000000000..159ecd188ad6 --- /dev/null +++ b/website/src/components/motion/css.ts @@ -0,0 +1,24 @@ +import { springEasing } from './spring'; +import { springs } from './tokens'; + +/** + * Global motion styles, injected into every page's by the + * `motion-css` plugin in docusaurus.config.ts: + * - each spring as a CSS custom property (`transition: rotate var(--motion-snappy)`), + * instant under reduced motion + * - ``'s box, which its contents fill + */ +export function motionCss() { + const tokens = Object.entries(springs); + const vars = tokens.map(([name, spring]) => { + const { duration, easing } = springEasing(spring); + return `--motion-${name}: ${duration}ms ${easing};`; + }); + const instant = tokens.map(([name]) => `--motion-${name}: 0s;`); + return [ + `:root { ${vars.join(' ')} }`, + `@media (prefers-reduced-motion: reduce) { :root { ${instant.join(' ')} } }`, + '.motion-reveal { display: flex; }', + '.motion-reveal > * { flex: 1 1 auto; min-width: 0; }', + ].join('\n'); +} diff --git a/website/src/components/motion/glide.ts b/website/src/components/motion/glide.ts new file mode 100644 index 000000000000..fa43b0287982 --- /dev/null +++ b/website/src/components/motion/glide.ts @@ -0,0 +1,85 @@ +import { + sampleSpring, + springAt, + SAMPLE_RATE, + type Spring, + type SpringState, +} from './spring'; + +export interface Point { + x: number; + y: number; +} +export const ORIGIN: Point = { x: 0, y: 0 }; + +interface Glide { + animation: Animation; + spring: Spring; + /** Where each axis started, relative to the target */ + start: [x: SpringState, y: SpringState]; +} +const glides = new WeakMap(); + +/** + * Springs `el`'s CSS `translate` from `from` to `to` (px) on the compositor. + * Keyframes are sampled from the spring per axis, so a glide that interrupts + * another keeps its momentum (`velocity`, px/s) in both directions. + */ +export function glide( + el: HTMLElement, + spring: Spring, + { from, to, velocity = ORIGIN }: { from: Point; to: Point; velocity?: Point }, +): Animation | undefined { + stop(el); + // no Web Animations (e.g. jsdom): land in place + if (typeof el.animate !== 'function') return; + if (from.x === to.x && from.y === to.y && !velocity.x && !velocity.y) return; + const start = (['x', 'y'] as const).map(axis => ({ + offset: from[axis] - to[axis], + velocity: velocity[axis], + })) as Glide['start']; + const [xs, ys] = start.map(axis => sampleSpring(spring, axis, PRECISION)); + const frames = Math.max(xs.length, ys.length); + const keyframes = Array.from({ length: frames }, (_, i) => ({ + translate: `${to.x + (xs[i] ?? 0)}px ${to.y + (ys[i] ?? 0)}px`, + })); + const animation = el.animate(keyframes, { + duration: ((frames - 1) / SAMPLE_RATE) * 1000, + // hold an off-target end (exits) until the caller removes the element + fill: to.x || to.y ? 'forwards' : 'none', + }); + glides.set(el, { animation, spring, start }); + return animation; +} + +/** How fast `el`'s glide is moving it right now (px/s) */ +export function velocityOf(el: Element): Point { + const current = glides.get(el); + if (!current || current.animation.playState === 'finished') return ORIGIN; + const { animation, spring, start } = current; + const t = Number(animation.currentTime ?? 0) / 1000; + const [x, y] = start.map(axis => springAt(spring, axis, t).velocity); + return { x, y }; +} + +/** + * Resolves once nothing moves `el`: now if it is still, else when its glide + * finishes. A glide cancelled for another (a retarget) hands over to it. + */ +export function settled(el: Element): Promise { + const animation = glides.get(el)?.animation; + if (animation?.playState !== 'running') return Promise.resolve(); + return animation.finished.then( + () => undefined, + () => settled(el), + ); +} + +/** Ends `el`'s glide, snapping it back to its layout position */ +export function stop(el: Element) { + glides.get(el)?.animation.cancel(); + glides.delete(el); +} + +/** Sub-pixel: settles once the remaining motion is invisible */ +const PRECISION = 0.25; diff --git a/website/src/components/motion/index.ts b/website/src/components/motion/index.ts new file mode 100644 index 000000000000..2b4b728085a6 --- /dev/null +++ b/website/src/components/motion/index.ts @@ -0,0 +1,6 @@ +/** Physical, compositor-driven motion. See README.md */ +export { default as MotionGroup, useLayoutMotion } from './MotionGroup'; +export { default as Reveal } from './Reveal'; + +export { springs } from './tokens'; +export type { Spring } from './spring'; diff --git a/website/src/components/motion/spring.ts b/website/src/components/motion/spring.ts new file mode 100644 index 000000000000..2ce286b0f3ed --- /dev/null +++ b/website/src/components/motion/spring.ts @@ -0,0 +1,88 @@ +/** + * Damped harmonic oscillator (mass 1) described the way people perceive it: + * how long the motion takes and how much it bounces. Same model as SwiftUI's + * and Motion's `visualDuration`/`bounce` springs. + */ +export interface Spring { + /** Seconds to (visually) reach the target, ignoring the bounce tail */ + readonly duration: number; + /** 0 = no overshoot (critically damped), towards 1 = springier */ + readonly bounce: number; +} + +/** Offset from the target and velocity (units per second) */ +export interface SpringState { + offset: number; + velocity: number; +} + +/** Position and velocity `t` seconds after starting at `offset` with `velocity` */ +export function springAt( + { duration, bounce }: Spring, + { offset, velocity }: SpringState, + t: number, +): SpringState { + const omega = (2 * Math.PI) / duration; + const zeta = 1 - bounce; + const decay = Math.exp(-zeta * omega * t); + if (zeta >= 1) { + const b = velocity + omega * offset; + return { + offset: decay * (offset + b * t), + velocity: decay * (b - omega * (offset + b * t)), + }; + } + const omegaD = omega * Math.sqrt(1 - zeta * zeta); + const b = (velocity + zeta * omega * offset) / omegaD; + const cos = Math.cos(omegaD * t); + const sin = Math.sin(omegaD * t); + return { + offset: decay * (offset * cos + b * sin), + velocity: + decay * + ((b * omegaD - zeta * omega * offset) * cos - + (offset * omegaD + zeta * omega * b) * sin), + }; +} + +/** Frame rate keyframes and `linear()` easings are sampled at */ +export const SAMPLE_RATE = 60; +/** Safety cap for springs that barely settle (bounce near 1), in durations */ +const MAX_DURATIONS = 12; + +/** + * Offsets sampled at SAMPLE_RATE until the spring rests within `precision` + * of the target; the last sample is exactly 0. + */ +export function sampleSpring( + spring: Spring, + start: SpringState, + precision: number, +): number[] { + const samples = [start.offset]; + const maxFrames = MAX_DURATIONS * spring.duration * SAMPLE_RATE; + for (let frame = 1; frame < maxFrames; frame++) { + const { offset, velocity } = springAt(spring, start, frame / SAMPLE_RATE); + if ( + Math.abs(offset) < precision && + Math.abs(velocity) < precision * SAMPLE_RATE + ) + break; + samples.push(offset); + } + samples.push(0); + return samples; +} + +/** CSS `linear()` easing that plays `spring` from rest, and its duration */ +export function springEasing(spring: Spring) { + const samples = sampleSpring(spring, { offset: 1, velocity: 0 }, 0.001); + return { + easing: `linear(${samples.map(offset => round(1 - offset)).join(', ')})`, + duration: Math.round(((samples.length - 1) / SAMPLE_RATE) * 1000), + }; +} + +function round(n: number) { + return Math.round(n * 1e4) / 1e4; +} diff --git a/website/src/components/motion/tokens.ts b/website/src/components/motion/tokens.ts new file mode 100644 index 000000000000..f04e4a88fe42 --- /dev/null +++ b/website/src/components/motion/tokens.ts @@ -0,0 +1,12 @@ +import type { Spring } from './spring'; + +/** + * The site's motion vocabulary. Pick by what moves, not by milliseconds; the + * same names are CSS custom properties (`var(--motion-snappy)`), see css.ts. + */ +export const springs = { + /** Small, light things: arrows, chips, toggles */ + snappy: { duration: 0.25, bounce: 0.15 }, + /** Panels and drawers that carry content; no overshoot past their edge */ + smooth: { duration: 0.4, bounce: 0 }, +} as const satisfies Record;