From b0093835fd728ab91a4b7f922b774a5cf732f653 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 18:03:06 -0700 Subject: [PATCH] feat(studio): add FlatTextLayerList for multi-field text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Originated layout, no design mock exists — flag for design review. Co-Authored-By: Claude Sonnet 5 --- .../propertyPanelFlatTextSection.test.tsx | 84 ++++++++++++++++++ .../editor/propertyPanelFlatTextSection.tsx | 85 ++++++++++++++++++- 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx new file mode 100644 index 000000000..b3c5f10a8 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatTextLayerList } from "./propertyPanelFlatTextSection"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderInto(node: React.ReactElement) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { host, root }; +} + +const FIELDS = [ + { + key: "a", + label: "Text", + value: "Headline", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self" as const, + }, + { + key: "b", + label: "Text", + value: "Subhead", + tagName: "span", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self" as const, + }, +]; + +describe("FlatTextLayerList", () => { + it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => { + const onSelect = vi.fn(); + const onAdd = vi.fn(); + const onRemove = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Headline"); + expect(host.textContent).toContain("Subhead"); + + const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(2); + expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true"); + expect((rows[1] as HTMLElement).getAttribute("data-active")).toBe("false"); + + act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSelect).toHaveBeenCalledWith("b"); + + const addButton = host.querySelector('[data-flat-text-layer-add="true"]'); + act(() => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onAdd).toHaveBeenCalledTimes(1); + + const removeButton = host.querySelector( + '[data-flat-text-layer-remove="true"]', + ); + act(() => removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onRemove).toHaveBeenCalledWith("a"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx index 85685d591..fd0c14090 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -1,4 +1,4 @@ -import { Plus } from "../../icons/SystemIcons"; +import { Plus, X } from "../../icons/SystemIcons"; import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; import type { ImportedFontAsset } from "./fontAssets"; import { normalizeTextMetricValue } from "./propertyPanelHelpers"; @@ -12,6 +12,7 @@ import { } from "./propertyPanelValueTier"; import { detectAvailableWeights, + formatTextFieldPreview, getTextFieldColor, getTextStyleValue, TextAreaField, @@ -245,3 +246,85 @@ export function FlatTextSection({ ); } + +/* ------------------------------------------------------------------ */ +/* Multi-field layer list (design_handoff_studio_inspector, #10a — */ +/* no mock exists for this row; layout originated by this plan, */ +/* following the "left-rule nested content" convention established */ +/* by Text's own content block, Motion's effect cards, and Media's */ +/* cutout block. Flag for design review.) */ +/* ------------------------------------------------------------------ */ + +export function FlatTextLayerList({ + fields, + activeFieldKey, + styles, + onSelect, + onAdd, + onRemove, +}: { + fields: DomEditSelection["textFields"]; + activeFieldKey: string; + styles: Record; + onSelect: (fieldKey: string) => void; + onAdd: () => void; + onRemove: (fieldKey: string) => void; +}) { + return ( +
+
+ Text layers +
+
+ {fields.map((field) => { + const active = field.key === activeFieldKey; + return ( +
onSelect(field.key)} + className={`flex min-h-[26px] cursor-pointer items-center gap-2 rounded px-1 ${ + active ? "bg-panel-accent/10" : "hover:bg-panel-hover" + }`} + > + + + {formatTextFieldPreview(field.value) || "Text"} + + + {field.tagName} + + {fields.length > 1 && ( + + )} +
+ ); + })} +
+ +
+ ); +}