feat(studio): add flat Overflow and Mask rows to the Style group

This commit is contained in:
Vance Ingalls
2026-07-09 11:52:15 -07:00
parent bc9651284e
commit 62b5fafca8
2 changed files with 241 additions and 0 deletions
@@ -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<HTMLInputElement>("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(
<FlatStyleSection
projectId="proj-1"
element={makeElement()}
styles={{}}
assets={[]}
onSetStyle={onSetStyle}
gsapBorderRadius={null}
/>,
);
});
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());
});
});
@@ -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<string, string>;
disabled: boolean;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
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 (
<>
<FlatSelectRow
label="Overflow"
value={styles.overflow || "visible"}
options={["visible", "hidden", "clip", "auto", "scroll"]}
tier={resolveValueTier(styles.overflow, "visible")}
disabled={disabled}
onChange={(next) => void onSetStyle("overflow", next)}
onReset={() => void onSetStyle("overflow", "visible")}
/>
<FlatSelectRow
label="Mask"
value={clipPathPreset === "custom" ? "none" : clipPathPreset}
options={["none", "inset", "circle"]}
tier={resolveValueTier(clipPathPreset === "none" ? undefined : clipPathPreset, "none")}
disabled={disabled}
onChange={(next) => {
void onSetStyle(
"clip-path",
buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue),
);
}}
onReset={() => void onSetStyle("clip-path", "none")}
/>
{showClipInsetSides && (
<div className="grid grid-cols-4 gap-2">
<MetricField
label="T"
value={formatPxMetricValue(clipInsetSides.top)}
disabled={disabled}
onCommit={(next) => commitClipInsetSide("top", next)}
/>
<MetricField
label="R"
value={formatPxMetricValue(clipInsetSides.right)}
disabled={disabled}
onCommit={(next) => commitClipInsetSide("right", next)}
/>
<MetricField
label="B"
value={formatPxMetricValue(clipInsetSides.bottom)}
disabled={disabled}
onCommit={(next) => commitClipInsetSide("bottom", next)}
/>
<MetricField
label="L"
value={formatPxMetricValue(clipInsetSides.left)}
disabled={disabled}
onCommit={(next) => commitClipInsetSide("left", next)}
/>
</div>
)}
</>
);
}
export function FlatStyleSection({
projectId,
element,
@@ -414,6 +518,11 @@ export function FlatStyleSection({
onSetStyle={onSetStyle}
/>
<FlatBlurSliders styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
<FlatOverflowMaskRows
styles={styles}
disabled={styleEditingDisabled}
onSetStyle={onSetStyle}
/>
</div>
);
}