Files
hyperframes/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx
T

509 lines
20 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import type { DomEditSelection } from "./domEditing";
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
function makeElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
return {
element: document.createElement("div"),
id: "stat-card",
selector: ".stat-card",
label: "Stat Card",
tagName: "div",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 24, y: 120, width: 420, height: 260 },
textContent: "",
dataAttributes: {},
inlineStyles: { "background-color": "#0D0C09" },
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
...overrides,
} as DomEditSelection;
}
function renderSection(
styles: Record<string, string> = {},
overrides: Partial<DomEditSelection> = {},
gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = null,
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const element = makeElement(overrides);
const onSetStyle = vi.fn();
const mergedStyles = { "background-color": "#0D0C09", "border-width": "0px", ...styles };
act(() => {
root.render(
<FlatStyleSection
projectId="proj-1"
element={element}
styles={mergedStyles}
assets={[]}
onSetStyle={onSetStyle}
gsapBorderRadius={gsapBorderRadius}
/>,
);
});
return { host, root, onSetStyle };
}
function clickSegment(host: HTMLElement, label: string) {
const segment = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find(
(el) => el.textContent === label,
);
act(() => segment?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
}
describe("FlatStyleSection — Fill", () => {
it("renders the Fill segmented control defaulting to Solid, and a mint Color row when a color is set", () => {
const { host, root } = renderSection();
expect(host.textContent).toContain("Fill");
expect(host.textContent).toContain("Solid");
const swatch = host.querySelector('[data-flat-color-trigger="true"]');
expect(swatch).not.toBeNull();
act(() => root.unmount());
});
it("switches to the Gradient field when Gradient is selected", () => {
const { host, root } = renderSection({
"background-image": "linear-gradient(90deg, #000, #fff)",
});
const gradientSegment = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find(
(el) => el.textContent === "Gradient",
);
expect(gradientSegment?.className).toContain("text-panel-text-0");
act(() => root.unmount());
});
it("clicking Gradient commits a serialized default gradient built from the current fill color", () => {
const { host, root, onSetStyle } = renderSection();
clickSegment(host, "Gradient");
const expectedGradient = serializeGradient(buildDefaultGradientModel("#0D0C09"));
expect(onSetStyle).toHaveBeenCalledWith("background-image", expectedGradient);
act(() => root.unmount());
});
it("clicking Solid clears the background-image back to none", () => {
const { host, root, onSetStyle } = renderSection({
"background-image": "linear-gradient(90deg, #000, #fff)",
});
clickSegment(host, "Solid");
expect(onSetStyle).toHaveBeenCalledWith("background-image", "none");
act(() => root.unmount());
});
it("clicking Image switches to the image-fill field without committing a style", () => {
const { host, root, onSetStyle } = renderSection();
clickSegment(host, "Image");
expect(host.textContent).toContain("Upload image");
expect(onSetStyle).not.toHaveBeenCalledWith("background-image", expect.anything());
act(() => root.unmount());
});
});
function getFlatRowInput(host: HTMLElement, label: string): HTMLInputElement {
const rows = Array.from(host.querySelectorAll<HTMLElement>(".group"));
const row = rows.find((el) => el.querySelector("span")?.textContent === label);
const input = row?.querySelector<HTMLInputElement>("input");
if (!input) throw new Error(`expected an input for row "${label}"`);
return input;
}
async function commitFlatRowInput(host: HTMLElement, label: string, nextValue: string) {
const input = getFlatRowInput(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();
await Promise.resolve();
await Promise.resolve();
});
}
const STROKE_STYLES = {
"border-width": "1px",
"border-style": "solid",
"border-color": "rgba(255,255,255,.12)",
};
describe("FlatStyleSection — Stroke and Radius", () => {
it("renders the combined stroke row and commits width+style together on blur", () => {
const { host, root } = renderSection(STROKE_STYLES);
expect(host.textContent).toContain("Stroke");
expect(getFlatRowInput(host, "Stroke").value).toBe("1px solid");
act(() => root.unmount());
});
it("commits the stroke row's new width and style together on blur", async () => {
const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
await commitFlatRowInput(host, "Stroke", "2px dashed");
expect(onSetStyle).toHaveBeenCalledWith("border-width", "2px");
expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed");
act(() => root.unmount());
});
it("renders a single Radius value with a Linked indicator when corners are uniform", () => {
const { host, root } = renderSection({ "border-radius": "12px" });
expect(host.textContent).toContain("Radius");
expect(getFlatRowInput(host, "Radius").value).toBe("12px");
expect(host.textContent).toContain("Linked");
act(() => root.unmount());
});
it("commits the radius row's new value to border-radius on blur when corners are uniform", async () => {
const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" });
await commitFlatRowInput(host, "Radius", "20px");
expect(onSetStyle).toHaveBeenCalledWith("border-radius", "20px");
act(() => root.unmount());
});
it("falls back to the legacy BorderRadiusEditor when corners are not uniform", () => {
const { host, root } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 });
expect(host.textContent).not.toContain("Linked");
act(() => root.unmount());
});
it("commits a per-corner radius update through the legacy BorderRadiusEditor when unlinked", () => {
const { host, root, onSetStyle } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 });
const trInput = Array.from(host.querySelectorAll<HTMLInputElement>("input")).find(
(el) => el.value === "12",
);
if (!trInput) throw new Error("expected the TR corner input");
act(() => {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(trInput, "18");
trInput.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => {
trInput.dispatchEvent(new Event("focusout", { bubbles: true }));
});
expect(onSetStyle).toHaveBeenCalledWith("border-top-right-radius", "18px");
act(() => root.unmount());
});
});
function getFlatSelectRow(host: HTMLElement, label: string) {
const rows = Array.from(host.querySelectorAll<HTMLElement>(".group"));
const row = rows.find((el) => el.querySelector("span")?.textContent === label);
if (!row) throw new Error(`expected a select row for "${label}"`);
const select = row.querySelector<HTMLSelectElement>("select");
if (!select) throw new Error(`expected a select for "${label}"`);
const resetButton = row.querySelector<HTMLButtonElement>('[data-flat-select-reset="true"]');
return { row, select, resetButton };
}
function changeFlatSelectRow(host: HTMLElement, label: string, nextValue: string) {
const { select } = getFlatSelectRow(host, label);
act(() => {
select.value = nextValue;
select.dispatchEvent(new Event("change", { bubbles: true }));
});
}
describe("FlatStyleSection — Shadow and Blend", () => {
it("renders Shadow with the inferred preset and a reset when set, Blend with a plain select", () => {
const { host, root } = renderSection({ "box-shadow": "0 8px 24px rgba(0,0,0,.35)" });
expect(host.textContent).toContain("Shadow");
expect(host.textContent).toContain("Blend");
const selects = host.querySelectorAll("select");
expect(selects.length).toBeGreaterThanOrEqual(2);
act(() => root.unmount());
});
it("commits a shadow preset change through onSetStyle", () => {
const { host, root, onSetStyle } = renderSection({});
changeFlatSelectRow(host, "Shadow", "soft");
expect(onSetStyle).toHaveBeenCalledWith("box-shadow", expect.any(String));
act(() => root.unmount());
});
it("commits a blend mode change through onSetStyle", () => {
const { host, root, onSetStyle } = renderSection({});
changeFlatSelectRow(host, "Blend", "multiply");
expect(onSetStyle).toHaveBeenCalledWith("mix-blend-mode", "multiply");
act(() => root.unmount());
});
it("resets the shadow preset back to none via the reset button", () => {
const { host, root, onSetStyle } = renderSection({
"box-shadow": "0 12px 36px rgba(0, 0, 0, 0.28)",
});
const { resetButton } = getFlatSelectRow(host, "Shadow");
if (!resetButton) throw new Error("expected the shadow reset button");
act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onSetStyle).toHaveBeenCalledWith("box-shadow", "none");
act(() => root.unmount());
});
it("resets the blend mode back to normal via the reset button", () => {
const { host, root, onSetStyle } = renderSection({ "mix-blend-mode": "multiply" });
const { resetButton } = getFlatSelectRow(host, "Blend");
if (!resetButton) throw new Error("expected the blend reset button");
act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onSetStyle).toHaveBeenCalledWith("mix-blend-mode", "normal");
act(() => root.unmount());
});
});
describe("FlatStyleSection — blur sliders", () => {
it("renders Layer blur and Backdrop sliders and commits 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={{ filter: "blur(4px)" }}
assets={[]}
onSetStyle={onSetStyle}
gsapBorderRadius={null}
/>,
);
});
expect(host.textContent).toContain("Layer blur");
expect(host.textContent).toContain("Backdrop");
expect(host.textContent).toContain("4px");
const track = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
});
// filterBlurValue=4 -> max=Math.max(40, 4)=40; clientX=50 of width 100 -> ratio 0.5 -> 20px.
expect(onSetStyle).toHaveBeenCalledWith("filter", "blur(20px)");
act(() => root.unmount());
});
it("renders the Backdrop slider from backdrop-filter and commits a new blur value on track click", () => {
const { host, root, onSetStyle } = renderSection({ "backdrop-filter": "blur(6px)" });
expect(host.textContent).toContain("Backdrop");
expect(host.textContent).toContain("6px");
const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
// Track order is Layer blur, Backdrop, Opacity — Backdrop is the second track.
const backdropTrack = tracks[1];
Object.defineProperty(backdropTrack, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
backdropTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
});
// backdropBlurValue=6 -> max=Math.max(60, 6)=60; clientX=50 of width 100 -> ratio 0.5 -> 30px.
expect(onSetStyle).toHaveBeenCalledWith("backdrop-filter", "blur(30px)");
act(() => root.unmount());
});
it("does not render a fill/knob highlight for a zero-value blur (default tier)", () => {
const { host, root } = renderSection({});
const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
// Only the first two tracks are the blur sliders (Layer blur, Backdrop); Opacity
// (the third track) always renders a fill by design, so it's excluded here.
const blurTracks = Array.from(tracks).slice(0, 2);
for (const track of blurTracks) {
expect(track.querySelectorAll('[data-flat-slider-fill="true"]')).toHaveLength(0);
}
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());
});
});
describe("FlatStyleSection — Opacity", () => {
it("renders the Opacity slider at 100% by default and commits a 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}
/>,
);
});
expect(host.textContent).toContain("Opacity");
expect(host.textContent).toContain("100%");
const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
const opacityTrack = tracks[tracks.length - 1];
Object.defineProperty(opacityTrack, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
});
act(() => {
opacityTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
});
expect(onSetStyle).toHaveBeenCalledWith("opacity", "0.5");
act(() => root.unmount());
});
});