diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d4f1afb2..e538a7ecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,8 +119,11 @@ jobs: - name: Diagram gallery (rendering and keyboard behaviour) run: bun run diagrams:test - # Build once, and reject development previews before the long browser suite. + # Build once, and reject development previews before the long browser suite. The browser + # suite reuses this build, so it carries the same child-motion toggle as scripts/e2e.ts. - run: bun run build + env: + VITE_CHILD_MOTION_TOGGLE: "on" - name: Verify design audit stays out of production run: bun scripts/check-design-production.ts diff --git a/apps/web/src/anchored-child-surface.tsx b/apps/web/src/anchored-child-surface.tsx index 9c566efd1..b71c8f4e9 100644 --- a/apps/web/src/anchored-child-surface.tsx +++ b/apps/web/src/anchored-child-surface.tsx @@ -1,10 +1,16 @@ import { childDocumentPath, documentPath } from "@chopin/protocol/document-url"; -import { useLayoutEffect, useRef } from "react"; +import { MagnifierIcon } from "@chopin/icons"; +import { Fragment, useLayoutEffect, useRef } from "react"; + +import { closeChild, openChild, rememberOpener } from "./child-motion-driver"; +import { openerRect } from "./child-origin"; export { childCloseAction, childFocusTransition, childHistoryState } from "./child-history"; +export { animationsSettled } from "./child-motion-driver"; export { childPresentation } from "./document-workspace-state"; -import type { ReactNode, Ref } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import type { ChildMotionStyle } from "./child-motion"; import type { ChildPresentation } from "./document-workspace-state"; export type { @@ -47,63 +53,93 @@ export function rebaseChildHistoryState( } export function AnchoredChildSurface( - { - child, - childLabel, - focusKey, - onBackdropClick, - parent, - parentRef, - presentation, - }: { - child?: ReactNode; - childLabel: string; + { entries, focusKey, motion, onClosed, origin, presentation }: { + entries: { id: string; label: string; node: ReactNode }[]; focusKey?: string; - onBackdropClick?: () => void; - parent: ReactNode; - parentRef?: Ref; + motion: ChildMotionStyle; + onClosed: () => void; + origin?: () => Element | undefined; presentation: ChildPresentation; }, ) { - let childVisible = presentation !== "closed"; - let surface = useRef(null); + let layer = useRef(null); let focused = useRef(undefined); + let closed = useRef(onClosed); + closed.current = onClosed; + let depth = entries.length; useLayoutEffect(() => { - if (presentation !== "open") { - if (presentation === "closed") focused.current = undefined; - return; - } - if (!focusKey || focused.current === focusKey) return; + if (presentation !== "open" || !focusKey || focused.current === focusKey) return; focused.current = focusKey; - surface.current?.focus({ preventScroll: true }); + layer.current?.querySelector(":scope > .anchored-child-card:last-of-type") + ?.focus({ preventScroll: true }); }, [focusKey, presentation]); + let latestOrigin = useRef(origin); + latestOrigin.current = origin; + let generation = useRef(0); + let opening = useRef<{ card: HTMLElement; opener?: Element; rect?: DOMRect; at: number }>( + undefined, + ); + let front = () => + layer.current?.querySelector(":scope > .anchored-child-card:last-of-type"); + useLayoutEffect(() => { + let card = front(); + if (!card) return; + let token = ++generation.current; + if (presentation === "open") { + let opener = latestOrigin.current?.(); + let rect = openerRect(opener); + opening.current = { at: performance.now(), card, opener, rect }; + rememberOpener(card, opener); + openChild(card, rect, motion); + return; + } + opening.current = undefined; + if (presentation !== "closing") return; + // The root may have replaced the opener since the open; prefer the one it shows now. + let live = latestOrigin.current?.(); + if (live?.isConnected) rememberOpener(card, live); + let done = () => { + if (generation.current === token) closed.current(); + }; + void closeChild(card, motion).then(done, done); + }, [presentation]); + let cardKey = entries.at(-1)?.id; + useLayoutEffect(() => { + // A canonical URL can rekey the open card; the new one carries on from the same moment. + let card = front(); + let open = opening.current; + if (!card || !open || open.card === card || presentation !== "open") return; + opening.current = { ...open, card }; + rememberOpener(card, open.opener); + openChild(card, open.rect, motion); + let elapsed = performance.now() - open.at; + for (let animation of layer.current?.getAnimations({ subtree: true }) ?? []) { + animation.currentTime = elapsed; + } + }, [cardKey]); return ( -
-
- {parent} -
- {presentation === "open" && onBackdropClick && ( -