diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 90d7ed869..fe914267d 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -516,7 +516,7 @@ export function PropertyPanelFlat({ onUngroup={onUngroup} showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)} /> -
+
{beforeOpen.map((g) => ( void) | null>(null); + useEffect( + () => () => { + releaseRef.current?.(); + releaseRef.current = null; + }, + [], + ); return ( @@ -50,7 +62,9 @@ export function FlatColorGradingAccessory({ window.removeEventListener("pointerup", release); window.removeEventListener("pointercancel", release); window.removeEventListener("blur", release); + releaseRef.current = null; }; + releaseRef.current = release; window.addEventListener("pointerup", release); window.addEventListener("pointercancel", release); window.addEventListener("blur", release); diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx index 0c7b0a2e4..60988350e 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx @@ -203,8 +203,8 @@ export function LayoutFlexBlock({ void onSetStyle("flex-direction", next)} diff --git a/packages/studio/src/components/editor/propertyPanelFlatMaskInsetRows.tsx b/packages/studio/src/components/editor/propertyPanelFlatMaskInsetRows.tsx new file mode 100644 index 000000000..1dfb77cb7 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMaskInsetRows.tsx @@ -0,0 +1,102 @@ +import { + buildInsetClipPathSides, + buildInsetClipPathValue, + formatNumericValue, + formatPxMetricValue, + getClipPathInsetPx, + inferClipPathPreset, + parseInsetClipPathSides, + parsePxMetricValue, + type ClipPathInsetSides, +} from "./propertyPanelHelpers"; +import { FlatSlider } from "./propertyPanelFlatPrimitives"; +import { MetricField } from "./propertyPanelPrimitives"; + +/* ------------------------------------------------------------------ */ +/* Flat Mask inset — uniform slider + per-side fields */ +/* (split out of propertyPanelFlatStyleSections.tsx to stay under the */ +/* 600-line file-size gate) */ +/* ------------------------------------------------------------------ */ + +export function FlatMaskInsetRows({ + clipPathValue, + radiusValue, + disabled, + onSetStyle, +}: { + clipPathValue: string; + radiusValue: number; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const clipPathPreset = inferClipPathPreset(clipPathValue); + const parsedClipInsets = parseInsetClipPathSides(clipPathValue); + const clipInsetValue = getClipPathInsetPx(clipPathValue); + const clipInsetSides = parsedClipInsets ?? { + top: clipInsetValue, + right: clipInsetValue, + bottom: clipInsetValue, + left: clipInsetValue, + radius: radiusValue, + }; + const showClipInsetSides = clipPathPreset === "inset" || parsedClipInsets != null; + + const commitClipInsetSide = (side: keyof ClipPathInsetSides, nextValue: string) => { + const next = parsePxMetricValue(nextValue); + if (next == null) return; + const sides: ClipPathInsetSides = { + top: clipInsetSides.top, + right: clipInsetSides.right, + bottom: clipInsetSides.bottom, + left: clipInsetSides.left, + }; + sides[side] = next; + void onSetStyle("clip-path", buildInsetClipPathSides(sides, clipInsetSides.radius)); + }; + + return ( + <> + 0 ? "explicitCustom" : "default"} + displayValue={`${formatNumericValue(clipInsetValue)}px`} + disabled={disabled} + onCommit={(next) => + void onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue)) + } + /> + {showClipInsetSides && ( +
+ commitClipInsetSide("top", next)} + /> + commitClipInsetSide("right", next)} + /> + commitClipInsetSide("bottom", next)} + /> + commitClipInsetSide("left", next)} + /> +
+ )} + + ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index 6fb1dad09..09b1e1038 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -89,8 +89,8 @@ describe("FlatSegmentedRow", () => { , @@ -105,6 +105,25 @@ describe("FlatSegmentedRow", () => { expect(onChange).toHaveBeenCalledWith("left"); act(() => root.unmount()); }); + + it("gives each option an accessible name and pressed state — glyphs alone (e.g. two 'A' buttons) aren't a valid accessible name", () => { + const { host, root } = renderInto( + , + ); + const options = host.querySelectorAll('[data-flat-segment="true"]'); + expect(options[0]?.getAttribute("aria-label")).toBe("upright"); + expect(options[0]?.getAttribute("aria-pressed")).toBe("true"); + expect(options[1]?.getAttribute("aria-label")).toBe("italic"); + expect(options[1]?.getAttribute("aria-pressed")).toBe("false"); + act(() => root.unmount()); + }); }); describe("FlatGroupHeader", () => { @@ -554,6 +573,130 @@ describe("FlatSlider — Grade extensions", () => { act(() => root.unmount()); }); + it("disables the reset button when the slider itself is disabled", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const resetButton = host.querySelector('[data-flat-slider-reset="true"]'); + expect(resetButton?.disabled).toBe(true); + act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("a trailing throttled commit uses the current render's onCommit, not the one captured when it was scheduled", () => { + vi.useFakeTimers(); + const onCommitA = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), + }); + act(() => { + // Leading-edge commit fires synchronously with onCommitA (clientX 150 + // on a -100..100 track maps to 50, distinct from the initial value 0). + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 150, pointerId: 1 }), + ); + }); + expect(onCommitA).toHaveBeenCalledTimes(1); + act(() => { + // Within the 40ms throttle window — queues a trailing commit (to 80, + // distinct from the just-committed 50) instead of firing immediately. + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 180, pointerId: 1 }), + ); + }); + expect(onCommitA).toHaveBeenCalledTimes(1); + // Simulate the real-world race: something else causes this slider to + // re-render with a NEW onCommit closure before the queued timer fires + // (e.g. Grade's per-detail onCommit spreads the render-time whole + // grading object, so a different control committing in between produces + // a fresh closure). The stale closure must not win. + const onCommitB = vi.fn(); + act(() => { + root.render( + , + ); + }); + act(() => { + vi.advanceTimersByTime(45); + }); + expect(onCommitB).toHaveBeenCalledTimes(1); + expect(onCommitB).toHaveBeenCalledWith(80); + expect(onCommitA).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + vi.useRealTimers(); + }); + + it("flushes a still-queued trailing commit on unmount instead of dropping it", () => { + vi.useFakeTimers(); + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }), + ); + }); + expect(onCommit).toHaveBeenCalledTimes(1); + act(() => { + // Queues a trailing commit that never gets to fire before unmount. + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }), + ); + }); + expect(onCommit).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + expect(onCommit).toHaveBeenCalledTimes(2); + expect(onCommit).toHaveBeenNthCalledWith(2, 80); + vi.useRealTimers(); + }); + it("supports keyboard operation: focusable, arrow keys step, Home/End clamp to range", () => { const onCommit = vi.fn(); const { host, root } = renderInto( @@ -728,6 +871,31 @@ describe("FlatSelectRow — label/value options", () => { expect(options).toEqual(["normal", "multiply", "screen"]); act(() => root.unmount()); }); + + it("preserves a valid authored value outside the preset list instead of misrepresenting it as the first option", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + // A native falls + // back to selectedIndex 0 when `value` matches no