fix(ui): image preview no longer blocks clicks after closing (#57324)

This commit is contained in:
Kit Langton
2026-10-20 00:07:22 -07:00
committed by GitHub
Unverified
parent b1bfe84d62
commit eadc7397d4
6 changed files with 78 additions and 18 deletions
@@ -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<string, (page: Page) => Promise<void>>
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"))
}
+9 -1
View File
@@ -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;
}
+3 -1
View File
@@ -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 },
+8 -10
View File
@@ -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.
+9 -1
View File
@@ -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", () => {
+5 -5
View File
@@ -13,13 +13,13 @@ type Knob<Kind extends string, Value> = {
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<Exclude<MotionPreset, "snappy">, DurationOptions> })
| (Knob<"spring", DurationOptions> & { presets: Record<MotionPreset, DurationOptions> })
| 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<MotionPreset, DurationOptions> }) {
const initial = meta.presets.snappy
spring(id: string, meta: Meta & { presets: Record<MotionPreset, DurationOptions>; 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 => ({