feat(studio): add FlatStyleSection with the flat Fill sub-block

This commit is contained in:
Vance Ingalls
2026-07-09 11:52:14 -07:00
parent 5032c9cfe3
commit 7738a67ddb
2 changed files with 272 additions and 0 deletions
@@ -0,0 +1,124 @@
// @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> = {},
) {
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={null}
/>,
);
});
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());
});
});
@@ -0,0 +1,148 @@
// fallow-ignore-file code-duplication
import { useEffect, useState } from "react";
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
import { extractBackgroundImageUrl } from "./propertyPanelHelpers";
// oxlint-disable-next-line no-unused-vars
import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
// oxlint-disable-next-line no-unused-vars
import { resolveValueTier } from "./propertyPanelValueTier";
import { ColorField } from "./propertyPanelColor";
import { GradientField, ImageFillField } from "./propertyPanelFill";
/* ------------------------------------------------------------------ */
/* Flat Fill sub-block (design_handoff_studio_inspector, #11a) */
/* ------------------------------------------------------------------ */
// fallow-ignore-next-line complexity
function FlatFillFields({
projectId,
element,
styles,
assets,
onSetStyle,
onImportAssets,
}: {
projectId: string;
element: DomEditSelection;
styles: Record<string, string>;
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onImportAssets?: (files: FileList) => Promise<string[]>;
}) {
const styleEditingDisabled = !element.capabilities.canEditStyles;
const backgroundImage = styles["background-image"] ?? "none";
const hasTextControls = isTextEditableSelection(element);
const fillMode =
backgroundImage && backgroundImage !== "none"
? backgroundImage.includes("gradient")
? "Gradient"
: "Image"
: "Solid";
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
const imageUrl = extractBackgroundImageUrl(backgroundImage);
useEffect(() => {
setPreferredFillMode(fillMode);
}, [fillMode, element.id, element.selector, backgroundImage]);
const handleFillModeChange = (nextMode: string) => {
setPreferredFillMode(nextMode);
if (nextMode === "Solid") {
onSetStyle("background-image", "none");
return;
}
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
onSetStyle(
"background-image",
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
);
}
};
return (
<>
<FlatSegmentedRow
label="Fill"
options={[
{ key: "Solid", node: "Solid", active: preferredFillMode === "Solid" },
{ key: "Gradient", node: "Gradient", active: preferredFillMode === "Gradient" },
{ key: "Image", node: "Image", active: preferredFillMode === "Image" },
]}
disabled={styleEditingDisabled}
onChange={handleFillModeChange}
/>
{preferredFillMode === "Solid" ? (
<ColorField
flat
label="Color"
value={styles["background-color"] ?? "transparent"}
disabled={styleEditingDisabled}
onCommit={(next) => onSetStyle("background-color", next)}
/>
) : preferredFillMode === "Gradient" ? (
<GradientField
value={
backgroundImage !== "none"
? backgroundImage
: serializeGradient(buildDefaultGradientModel(styles["background-color"]))
}
fallbackColor={styles["background-color"]}
disabled={styleEditingDisabled}
onCommit={(next) => onSetStyle("background-image", next)}
/>
) : (
<ImageFillField
projectId={projectId}
sourceFile={element.sourceFile}
value={imageUrl}
assets={assets}
disabled={styleEditingDisabled}
onCommit={(next) => onSetStyle("background-image", next)}
onImportAssets={onImportAssets}
/>
)}
{!hasTextControls && (
<ColorField
flat
label="Text color"
value={styles.color ?? "rgb(0, 0, 0)"}
disabled={styleEditingDisabled}
onCommit={(next) => onSetStyle("color", next)}
/>
)}
</>
);
}
export function FlatStyleSection({
projectId,
element,
styles,
assets,
onSetStyle,
onImportAssets,
// oxlint-disable-next-line no-unused-vars
gsapBorderRadius,
}: {
projectId: string;
element: DomEditSelection;
styles: Record<string, string>;
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onImportAssets?: (files: FileList) => Promise<string[]>;
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
}) {
return (
<div className="space-y-1.5">
<FlatFillFields
projectId={projectId}
element={element}
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
/>
</div>
);
}