From b86717d7583259620289e13a17ee46c5c4f8ecc4 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 01:06:23 -0700 Subject: [PATCH] feat(studio): add flat Stroke and Radius rows to the Style group --- .../propertyPanelFlatStyleSections.test.tsx | 94 +++++++++- .../editor/propertyPanelFlatStyleSections.tsx | 168 +++++++++++++++++- packages/studio/src/icons/SystemIcons.tsx | 2 + 3 files changed, 259 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx index 515878f7c..81f46b46d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -47,6 +47,7 @@ function makeElement(overrides: Partial = {}): DomEditSelectio function renderSection( styles: Record = {}, overrides: Partial = {}, + gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = null, ) { const host = document.createElement("div"); document.body.append(host); @@ -62,7 +63,7 @@ function renderSection( styles={mergedStyles} assets={[]} onSetStyle={onSetStyle} - gsapBorderRadius={null} + gsapBorderRadius={gsapBorderRadius} />, ); }); @@ -122,3 +123,94 @@ describe("FlatStyleSection — Fill", () => { act(() => root.unmount()); }); }); + +function getFlatRowInput(host: HTMLElement, label: string): HTMLInputElement { + const rows = Array.from(host.querySelectorAll(".group")); + const row = rows.find((el) => el.querySelector("span")?.textContent === label); + const input = row?.querySelector("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("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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx index 5f0e7a666..24a586e29 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -2,10 +2,20 @@ 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 { Link as LinkIcon } from "../../icons/SystemIcons"; +import { BorderRadiusEditor } from "./BorderRadiusEditor"; +import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers"; +import { + buildStrokeStyleUpdates, + buildStrokeWidthStyleUpdates, + extractBackgroundImageUrl, + formatNumericValue, + formatPxMetricValue, + normalizePanelPxValue, + parseNumericValue, + parsePxMetricValue, +} from "./propertyPanelHelpers"; 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"; @@ -115,6 +125,149 @@ function FlatFillFields({ ); } +/* ------------------------------------------------------------------ */ +/* Flat Stroke row — combined width+style+color */ +/* ------------------------------------------------------------------ */ + +// fallow-ignore-next-line complexity +function FlatStrokeRow({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const borderWidthValue = + parsePxMetricValue(styles["border-width"] ?? "") ?? + parsePxMetricValue(styles["border-top-width"] ?? "") ?? + 0; + const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none"; + const borderColorValue = + styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)"; + const summary = formatStrokeSummary(borderWidthValue, borderStyleValue); + const tier = resolveValueTier( + styles["border-width"] != null || styles["border-style"] != null ? summary : undefined, + formatStrokeSummary(0, "none"), + ); + + return ( + { + const parsed = parseStrokeSummary(next); + if (!parsed) return; + for (const [property, value] of buildStrokeWidthStyleUpdates( + formatPxMetricValue(parsed.widthPx), + parsed.style, + )) { + await onSetStyle(property, value); + } + for (const [property, value] of buildStrokeStyleUpdates( + parsed.style, + formatPxMetricValue(parsed.widthPx), + )) { + await onSetStyle(property, value); + } + }} + suffix={ + <> + + {borderColorValue} + + } + /> + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Radius row — uniform case; legacy fallback otherwise */ +/* ------------------------------------------------------------------ */ + +// fallow-ignore-next-line complexity +function FlatRadiusRow({ + styles, + gsapBorderRadius, + disabled, + onSetStyle, +}: { + styles: Record; + gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0; + const radiusTL = + gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue; + const radiusTR = + gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue; + const radiusBR = + gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue; + const radiusBL = + gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue; + const uniform = radiusTL === radiusTR && radiusTR === radiusBR && radiusBR === radiusBL; + + const commit = (corner: "all" | "tl" | "tr" | "br" | "bl", value: number) => { + const px = `${formatNumericValue(value)}px`; + if (corner === "all") { + void onSetStyle("border-radius", px); + return; + } + const prop = { + tl: "border-top-left-radius", + tr: "border-top-right-radius", + br: "border-bottom-right-radius", + bl: "border-bottom-left-radius", + }[corner]; + void onSetStyle(prop, px); + }; + + if (!uniform) { + return ( + + ); + } + + return ( + { + const parsed = parsePxMetricValue(next.endsWith("px") ? next : `${next}px`); + if (parsed == null) return; + const normalized = normalizePanelPxValue(`${parsed}px`, { + min: 0, + max: 400, + fallback: radiusTL, + }); + commit("all", normalized != null ? (parsePxMetricValue(normalized) ?? radiusTL) : radiusTL); + }} + suffix={ + + + Linked + + } + /> + ); +} + export function FlatStyleSection({ projectId, element, @@ -122,7 +275,6 @@ export function FlatStyleSection({ assets, onSetStyle, onImportAssets, - // oxlint-disable-next-line no-unused-vars gsapBorderRadius, }: { projectId: string; @@ -133,6 +285,7 @@ export function FlatStyleSection({ onImportAssets?: (files: FileList) => Promise; gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; }) { + const styleEditingDisabled = !element.capabilities.canEditStyles; return (
+ +
); } diff --git a/packages/studio/src/icons/SystemIcons.tsx b/packages/studio/src/icons/SystemIcons.tsx index 4f47d415b..ca3f24d95 100644 --- a/packages/studio/src/icons/SystemIcons.tsx +++ b/packages/studio/src/icons/SystemIcons.tsx @@ -21,6 +21,7 @@ import { ArrowClockwise, Gear, Scissors as PhScissors, + Link as PhLink, } from "@phosphor-icons/react"; import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react"; @@ -69,3 +70,4 @@ export const Camera = makeIcon(PhCamera); export const RotateCw = makeIcon(ArrowClockwise); export const Settings = makeIcon(Gear); export const Scissors = makeIcon(PhScissors); +export const Link = makeIcon(PhLink);