feat(studio): add flat Stroke and Radius rows to the Style group

This commit is contained in:
Vance Ingalls
2026-07-09 11:52:14 -07:00
parent 7738a67ddb
commit fbc3036a54
3 changed files with 259 additions and 5 deletions
@@ -47,6 +47,7 @@ function makeElement(overrides: Partial<DomEditSelection> = {}): DomEditSelectio
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);
@@ -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<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());
});
});
@@ -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<string, string>;
disabled: boolean;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
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 (
<FlatRow
label="Stroke"
value={summary}
tier={tier}
disabled={disabled}
onCommit={async (next) => {
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={
<>
<span
className="h-4 w-4 flex-shrink-0 rounded border border-panel-border-input"
style={{ backgroundColor: borderColorValue }}
/>
<span className="font-mono text-[10px] text-panel-text-3">{borderColorValue}</span>
</>
}
/>
);
}
/* ------------------------------------------------------------------ */
/* Flat Radius row — uniform case; legacy fallback otherwise */
/* ------------------------------------------------------------------ */
// fallow-ignore-next-line complexity
function FlatRadiusRow({
styles,
gsapBorderRadius,
disabled,
onSetStyle,
}: {
styles: Record<string, string>;
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
disabled: boolean;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
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 (
<BorderRadiusEditor
tl={radiusTL}
tr={radiusTR}
br={radiusBR}
bl={radiusBL}
disabled={disabled}
onCommit={commit}
/>
);
}
return (
<FlatRow
label="Radius"
value={`${formatNumericValue(radiusTL)}px`}
tier={resolveValueTier(styles["border-radius"], "0px")}
disabled={disabled}
onCommit={(next) => {
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={
<span className="flex items-center gap-1 text-[10px] text-panel-text-4">
<LinkIcon size={10} />
Linked
</span>
}
/>
);
}
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<string[]>;
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
}) {
const styleEditingDisabled = !element.capabilities.canEditStyles;
return (
<div className="space-y-1.5">
<FlatFillFields
@@ -143,6 +296,13 @@ export function FlatStyleSection({
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
/>
<FlatStrokeRow styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
<FlatRadiusRow
styles={styles}
gsapBorderRadius={gsapBorderRadius}
disabled={styleEditingDisabled}
onSetStyle={onSetStyle}
/>
</div>
);
}
@@ -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);