diff --git a/packages/app/component-tests/image-preview.spec.ts b/packages/app/component-tests/image-preview.spec.ts new file mode 100644 index 00000000000..34f7b575736 --- /dev/null +++ b/packages/app/component-tests/image-preview.spec.ts @@ -0,0 +1,44 @@ +import type { Page } from "@playwright/test" +import { expect, story } from "../../storybook/playwright/story" + +const paths = { + Escape: (page) => page.keyboard.press("Escape"), + "a backdrop click": (page) => page.mouse.click(8, 8), + "the close button": (page) => page.getByRole("button", { name: "Close" }).click(), +} satisfies Record Promise> + +for (const [path, close] of Object.entries(paths)) { + story(`takes the page's clicks as soon as the image preview closes with ${path}`, async ({ mount, page }) => { + await mount("components-image-preview--from-message") + await open(page) + await page.locator('[data-component="image-preview"][data-stage="rest"]').waitFor() + await close(page) + await expect(clickPage(page)).resolves.toBe(true) + }) +} + +story("takes the page's clicks as soon as the image preview closes mid-flight", async ({ mount, page }) => { + await mount("components-image-preview--from-message") + await open(page) + await page.locator('[data-component="image-preview"][data-stage="flying"]').waitFor() + await page.keyboard.press("Escape") + await expect(clickPage(page)).resolves.toBe(true) +}) + +async function open(page: Page) { + await page.locator('[data-story-image="inline"]').click() +} + +// A raw click, without Playwright waiting for the point to be clear, as a hand would make it while the image flies home. +async function clickPage(page: Page) { + const text = page.getByText("Here is the poster") + const box = await text.boundingBox() + + if (!box) throw new Error("the page text is not laid out") + await text.evaluate((element) => { + element.addEventListener("click", () => element.setAttribute("data-clicked", ""), { once: true }) + }) + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) + + return text.evaluate((element) => element.hasAttribute("data-clicked")) +} diff --git a/packages/ui/src/components/image-preview.css b/packages/ui/src/components/image-preview.css index 01100417a11..c895f24f467 100644 --- a/packages/ui/src/components/image-preview.css +++ b/packages/ui/src/components/image-preview.css @@ -102,9 +102,11 @@ outline: none; } - /* Kobalte unmounts closed content once its CSS animation ends, so this holds it for the closing flight. */ + /* Kobalte unmounts closed content once its CSS animation ends, so this holds it for the closing flight. Held, it + must not take the page's clicks; Kobalte's inline style is otherwise auto. */ &[data-closed] { animation: image-preview-exit var(--image-preview-exit); + pointer-events: none !important; } [data-slot="image-preview-header"] { @@ -165,6 +167,12 @@ background-color: transparent !important; } +/* A modal Kobalte dialog turns the body's pointer events off and only restores them when its content unmounts, which + the closing flight above holds back; the page takes clicks again as soon as the close starts. */ +body:has([data-component="image-preview"] [data-slot="image-preview-content"][data-closed]) { + pointer-events: auto !important; +} + [data-image-preview-origin] { visibility: hidden; } diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx index ed8d7179575..87e45bb6a42 100644 --- a/packages/ui/src/components/image-preview.tsx +++ b/packages/ui/src/components/image-preview.tsx @@ -33,10 +33,12 @@ const STEP = Math.SQRT2 const group = "Image open" -// One spring carries the open and the close, and the backdrop dims on it too. Snappy is Claude's measured preview. +// One spring carries the open and the close, and the backdrop dims on it too. Snappy is Claude's measured preview; Smooth +// ships, since opening an image is rare enough to take its time. const tuning = motionTuning.spring("imagePreview", { label: "Spring", group, + shipped: "smooth", presets: { snappy: FLIGHT_SPRING, smooth: { duration: 0.3, bounce: 0 }, diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts index ce7e7a2515a..320fdcc21a3 100644 --- a/packages/ui/src/motion-tuning-panel.ts +++ b/packages/ui/src/motion-tuning-panel.ts @@ -1,11 +1,11 @@ import type { DurationOptions } from "./motion" -import { motionPresets, motionTuning, type MotionKnob } from "./motion-tuning" +import { motionPresets, motionTuning, type MotionKnob, type MotionPreset } from "./motion-tuning" const global = "Global" /** - * Mounts the Motion workspace into `container`: a tab per surface under the Snappy, Smooth and Calm presets for every - * spring at once. Each tab shows one Global card (speed, slow motion and other settings every surface reads), then one + * Mounts the Motion workspace into `container`: a tab per surface under the shipped values and the Snappy, Smooth and + * Calm presets for every spring at once. Each tab shows one Global card (speed, slow motion and other settings every surface reads), then one * card for its surface: its springs and knobs, with its replay buttons under a Replay sub-heading. knobs loads on first * mount, so importing this module costs nothing until then. Works anywhere a surface registered its knobs, including * Storybook stories. Returns its removal. @@ -60,6 +60,7 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri const actions = motionTuning.actions() const mounted = identity() const springs = list.flatMap((item) => (item.kind === "spring" ? [item] : [])) + const preset = (id: MotionPreset) => Object.fromEntries(springs.map((item) => [item.id, item.presets[id]])) const sections = [...new Set([...list, ...actions].map((item) => item.group))].filter((group) => group !== global) const controls = defineControls( @@ -86,13 +87,10 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri enabled: true, ship: () => import("knobs/panel"), persist: "variants", - // The defaults are Snappy; the strip names them so rather than "default". - defaultLabel: motionPresets.snappy, - presetLabels: { smooth: motionPresets.smooth, calm: motionPresets.calm }, - presets: { - smooth: Object.fromEntries(springs.map((item) => [item.id, item.presets.smooth])), - calm: Object.fromEntries(springs.map((item) => [item.id, item.presets.calm])), - }, + // Springs ship different presets, so the defaults get their own chip. + defaultLabel: "Shipped", + presetLabels: motionPresets, + presets: { snappy: preset("snappy"), smooth: preset("smooth"), calm: preset("calm") }, onChange: (patch) => list.forEach((item) => { // SAFETY: each field was made from its knob above, so its value is that knob's kind. diff --git a/packages/ui/src/motion-tuning.test.ts b/packages/ui/src/motion-tuning.test.ts index 93c6a1c4290..893b9951085 100644 --- a/packages/ui/src/motion-tuning.test.ts +++ b/packages/ui/src/motion-tuning.test.ts @@ -19,7 +19,15 @@ describe("motionTuning.spring", () => { expect(spring()).toEqual(presets.snappy) expect(knob?.kind === "spring" && knob.initial).toEqual(presets.snappy) - expect(knob?.kind === "spring" && knob.presets).toEqual({ smooth: presets.smooth, calm: presets.calm }) + expect(knob?.kind === "spring" && knob.presets).toEqual(presets) + }) + + test("ships the preset a spring names", () => { + const spring = motionTuning.spring("test.smooth", { label: "Spring", group: "Test", shipped: "smooth", presets }) + const knob = motionTuning.list().find((item) => item.id === "test.smooth") + + expect(spring()).toEqual(presets.smooth) + expect(knob?.kind === "spring" && knob.initial).toEqual(presets.smooth) }) test("runs a preset applied to its knob, stretched by the global speed and slow motion", () => { diff --git a/packages/ui/src/motion-tuning.ts b/packages/ui/src/motion-tuning.ts index e926f4515b3..e643a52f0fc 100644 --- a/packages/ui/src/motion-tuning.ts +++ b/packages/ui/src/motion-tuning.ts @@ -13,13 +13,13 @@ type Knob = { value: Value } -/** The presets every spring offers. Its shipped value is Snappy; Smooth and Calm are slower, all without bounce. */ +/** The presets every spring offers, all without bounce. A spring ships Snappy unless it names another. */ export const motionPresets = { snappy: "Snappy", smooth: "Smooth", calm: "Calm" } as const export type MotionPreset = keyof typeof motionPresets export type MotionKnob = - | (Knob<"spring", DurationOptions> & { presets: Record, DurationOptions> }) + | (Knob<"spring", DurationOptions> & { presets: Record }) | Knob<"toggle", boolean> | (Knob<"number", number> & { min: number; max: number; step: number; unit?: string }) @@ -41,8 +41,8 @@ const saved = load() export const motionTuning = { /** The spring to run now: its tuned duration scaled by the global speed and slow motion. */ - spring(id: string, meta: Meta & { presets: Record }) { - const initial = meta.presets.snappy + spring(id: string, meta: Meta & { presets: Record; shipped?: MotionPreset }) { + const initial = meta.presets[meta.shipped ?? "snappy"] const knob = register({ kind: "spring", @@ -51,7 +51,7 @@ export const motionTuning = { group: meta.group, initial, value: { duration: number(`${id}.duration`, initial.duration), bounce: number(`${id}.bounce`, initial.bounce) }, - presets: { smooth: meta.presets.smooth, calm: meta.presets.calm }, + presets: meta.presets, }) return (): DurationOptions => ({