diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx index 936473245..f4b12ecbf 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -334,3 +334,135 @@ describe("FlatStyleSection — blur sliders", () => { act(() => root.unmount()); }); }); + +function getInsetSideInputOrNull(host: HTMLElement, label: "T" | "R" | "B" | "L") { + const span = Array.from(host.querySelectorAll("span")).find((el) => el.textContent === label); + return span?.parentElement?.querySelector("input") ?? null; +} + +function getInsetSideInput(host: HTMLElement, label: "T" | "R" | "B" | "L"): HTMLInputElement { + const input = getInsetSideInputOrNull(host, label); + if (!input) throw new Error(`expected an inset side input for "${label}"`); + return input; +} + +async function commitInsetSideInput( + host: HTMLElement, + label: "T" | "R" | "B" | "L", + nextValue: string, +) { + const input = getInsetSideInput(host, label); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, nextValue); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + input.dispatchEvent(new Event("focusout", { bubbles: true })); + await Promise.resolve(); + }); +} + +describe("FlatStyleSection — Overflow and Mask", () => { + it("renders Overflow and Mask selects, and inset-side rows when the mask is an inset", () => { + const { host, root } = renderSection({ + overflow: "hidden", + "clip-path": "inset(8px round 4px)", + }); + expect(host.textContent).toContain("Overflow"); + expect(host.textContent).toContain("Mask"); + expect(host.textContent).toContain("hidden"); + act(() => root.unmount()); + }); + + it("commits an overflow change through onSetStyle", () => { + const onSetStyle = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const overflowSelect = Array.from(host.querySelectorAll("select")).find((s) => + Array.from(s.options).some((o) => o.value === "scroll"), + ); + if (!overflowSelect) throw new Error("expected the overflow select"); + act(() => { + overflowSelect.value = "hidden"; + overflowSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onSetStyle).toHaveBeenCalledWith("overflow", "hidden"); + act(() => root.unmount()); + }); + + it("resets overflow back to visible via the reset button", () => { + const { host, root, onSetStyle } = renderSection({ overflow: "scroll" }); + const { resetButton } = getFlatSelectRow(host, "Overflow"); + if (!resetButton) throw new Error("expected the overflow reset button"); + act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("overflow", "visible"); + act(() => root.unmount()); + }); + + it("commits a mask preset change through onSetStyle, building an inset() clip-path", () => { + const { host, root, onSetStyle } = renderSection({}); + changeFlatSelectRow(host, "Mask", "inset"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(0 round 0px)"); + act(() => root.unmount()); + }); + + it("resets the mask back to none via the reset button", () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "circle(50% at 50% 50%)" }); + const { resetButton } = getFlatSelectRow(host, "Mask"); + if (!resetButton) throw new Error("expected the mask reset button"); + act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "none"); + act(() => root.unmount()); + }); + + it("does not render inset-side rows when the mask is not an inset", () => { + const { host, root } = renderSection({ "clip-path": "circle(50% at 50% 50%)" }); + expect(getInsetSideInputOrNull(host, "T")).toBeNull(); + act(() => root.unmount()); + }); + + it("commits a T inset-side edit through onSetStyle, preserving the other sides and radius", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "T", "10"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(10px 8px 8px 8px round 4px)"); + act(() => root.unmount()); + }); + + it("commits an L inset-side edit through onSetStyle", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "L", "2"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 8px 2px round 4px)"); + act(() => root.unmount()); + }); + + it("commits an R inset-side edit through onSetStyle", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "R", "3"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 3px 8px 8px round 4px)"); + act(() => root.unmount()); + }); + + it("commits a B inset-side edit through onSetStyle", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "B", "5"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 5px 8px round 4px)"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx index ca9241cba..8878409e1 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -7,18 +7,24 @@ import { BorderRadiusEditor } from "./BorderRadiusEditor"; import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers"; import { buildBoxShadowPresetValue, + buildClipPathValue, + buildInsetClipPathSides, buildStrokeStyleUpdates, buildStrokeWidthStyleUpdates, extractBackgroundImageUrl, formatNumericValue, formatPxMetricValue, + getClipPathInsetPx, getCssFilterFunctionPx, inferBoxShadowPreset, + inferClipPathPreset, normalizePanelPxValue, + parseInsetClipPathSides, parseNumericValue, parsePxMetricValue, setCssFilterFunctionPx, type BoxShadowPreset, + type ClipPathInsetSides, } from "./propertyPanelHelpers"; import { FlatRow, @@ -26,6 +32,7 @@ import { FlatSelectRow, FlatSlider, } from "./propertyPanelFlatPrimitives"; +import { MetricField } from "./propertyPanelPrimitives"; import { resolveValueTier } from "./propertyPanelValueTier"; import { ColorField } from "./propertyPanelColor"; import { GradientField, ImageFillField } from "./propertyPanelFill"; @@ -373,6 +380,103 @@ function FlatBlurSliders({ ); } +/* ------------------------------------------------------------------ */ +/* Flat Overflow + Mask rows (+ inset sides) */ +/* ------------------------------------------------------------------ */ + +function FlatOverflowMaskRows({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0; + const clipPathValue = styles["clip-path"] || "none"; + 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 ( + <> + void onSetStyle("overflow", next)} + onReset={() => void onSetStyle("overflow", "visible")} + /> + { + void onSetStyle( + "clip-path", + buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue), + ); + }} + onReset={() => void onSetStyle("clip-path", "none")} + /> + {showClipInsetSides && ( +
+ commitClipInsetSide("top", next)} + /> + commitClipInsetSide("right", next)} + /> + commitClipInsetSide("bottom", next)} + /> + commitClipInsetSide("left", next)} + /> +
+ )} + + ); +} + export function FlatStyleSection({ projectId, element, @@ -414,6 +518,11 @@ export function FlatStyleSection({ onSetStyle={onSetStyle} /> + ); }