From 2d9989db3bc67435bc7930d88b4bb780582e99b9 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 00:43:05 -0700 Subject: [PATCH] feat(studio): add FlatStyleSection with the flat Fill sub-block --- .../propertyPanelFlatStyleSections.test.tsx | 124 +++++++++++++++ .../editor/propertyPanelFlatStyleSections.tsx | 148 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx new file mode 100644 index 000000000..515878f7c --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -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 { + 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 = {}, + overrides: Partial = {}, +) { + 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( + , + ); + }); + 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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx new file mode 100644 index 000000000..5f0e7a666 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -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; + assets: string[]; + onSetStyle: (prop: string, value: string) => void | Promise; + onImportAssets?: (files: FileList) => Promise; +}) { + 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 ( + <> + + {preferredFillMode === "Solid" ? ( + onSetStyle("background-color", next)} + /> + ) : preferredFillMode === "Gradient" ? ( + onSetStyle("background-image", next)} + /> + ) : ( + onSetStyle("background-image", next)} + onImportAssets={onImportAssets} + /> + )} + {!hasTextControls && ( + 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; + assets: string[]; + onSetStyle: (prop: string, value: string) => void | Promise; + onImportAssets?: (files: FileList) => Promise; + gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; +}) { + return ( +
+ +
+ ); +}