feat(studio): add FlatTextLayerList for multi-field text

Originated layout, no design mock exists — flag for design review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-14 15:51:54 -07:00
co-authored by Claude Sonnet 5
parent e6cf075363
commit b0093835fd
2 changed files with 168 additions and 1 deletions
@@ -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(
<FlatTextLayerList
fields={FIELDS as never}
activeFieldKey="a"
styles={{}}
onSelect={onSelect}
onAdd={onAdd}
onRemove={onRemove}
/>,
);
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<HTMLButtonElement>('[data-flat-text-layer-add="true"]');
act(() => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onAdd).toHaveBeenCalledTimes(1);
const removeButton = host.querySelector<HTMLButtonElement>(
'[data-flat-text-layer-remove="true"]',
);
act(() => removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onRemove).toHaveBeenCalledWith("a");
act(() => root.unmount());
});
});
@@ -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({
</div>
);
}
/* ------------------------------------------------------------------ */
/* 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<string, string>;
onSelect: (fieldKey: string) => void;
onAdd: () => void;
onRemove: (fieldKey: string) => void;
}) {
return (
<div className="mb-2 border-l-2 border-panel-border-input py-0.5 pl-[10px]">
<div className="mb-1.5 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Text layers
</div>
<div className="space-y-1">
{fields.map((field) => {
const active = field.key === activeFieldKey;
return (
<div
key={field.key}
data-flat-text-layer-row="true"
data-active={active}
onClick={() => 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"
}`}
>
<span
className="h-3 w-3 flex-shrink-0 rounded-sm"
style={{ backgroundColor: getTextFieldColor(field, styles) }}
/>
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
{formatTextFieldPreview(field.value) || "Text"}
</span>
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
{field.tagName}
</span>
{fields.length > 1 && (
<button
type="button"
data-flat-text-layer-remove="true"
aria-label="Remove text field"
onClick={(e) => {
e.stopPropagation();
onRemove(field.key);
}}
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
>
<X size={10} />
</button>
)}
</div>
);
})}
</div>
<button
type="button"
data-flat-text-layer-add="true"
onClick={onAdd}
className="mt-1 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
>
<Plus size={10} />
Add text field
</button>
</div>
);
}