mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(ui): image preview no longer blocks clicks after closing (#57324)
This commit is contained in:
committed by
GitHub
Unverified
parent
b1bfe84d62
commit
eadc7397d4
@@ -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"))
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
Reference in New Issue
Block a user