From f6fa9b4ec38341b2ad086722aa545f9645f73a74 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 22:15:08 -0700 Subject: [PATCH] fix(studio): restore capitalize, end-align, live-commit size, and autofocus in flat Text Co-Authored-By: Claude Sonnet 5 --- .../propertyPanelFlatTextSection.test.tsx | 189 ++++++++++++++++++ .../editor/propertyPanelFlatTextSection.tsx | 16 +- 2 files changed, 202 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx index 4ca2cb36d..32e009d79 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx @@ -46,6 +46,26 @@ const FIELDS = [ ]; describe("FlatTextLayerList", () => { + it("falls back to a numbered label per index for empty fields, not a bare 'Text'", () => { + const emptyFields = [ + { ...FIELDS[0], value: "" }, + { ...FIELDS[1], value: "" }, + ]; + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Text 1"); + expect(host.textContent).toContain("Text 2"); + act(() => root.unmount()); + }); + it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => { const onSelect = vi.fn(); const onAdd = vi.fn(); @@ -139,6 +159,114 @@ function makeMultiFieldElement(): DomEditSelection { } as DomEditSelection; } +function makeSingleFieldElement(overrides: Partial = {}): DomEditSelection { + const base = makeMultiFieldElement(); + return { + ...base, + textFields: [ + { + key: "a", + label: "Text", + value: "Headline", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + ...overrides, + }, + ], + } as DomEditSelection; +} + +function segmentedRowButtons(host: HTMLElement, label: string): HTMLButtonElement[] { + const labelSpan = Array.from(host.querySelectorAll("span")).find( + (el) => el.textContent === label, + ); + const row = labelSpan?.parentElement; + return Array.from(row?.querySelectorAll('[data-flat-segment="true"]') ?? []); +} + +describe("FlatTextFieldEditor controls", () => { + it("commits text-transform: capitalize when the new 'Ag' case button is clicked", () => { + const onSetTextFieldStyle = vi.fn(); + const { host, root } = renderInto( + , + ); + const capitalizeButton = segmentedRowButtons(host, "Case · Style").find( + (button) => button.textContent === "Ag", + ); + expect(capitalizeButton).not.toBeUndefined(); + act(() => capitalizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-transform", "capitalize"); + act(() => root.unmount()); + }); + + it("lights up 'right' for text-align: end and commits the concrete 'right' value on click", () => { + const onSetTextFieldStyle = vi.fn(); + const { host, root } = renderInto( + , + ); + const alignButtons = segmentedRowButtons(host, "Align"); + const rightButton = alignButtons.find((button) => button.textContent === "R"); + expect(rightButton).not.toBeUndefined(); + expect(rightButton?.className).toContain("border-panel-accent"); + act(() => rightButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-align", "right"); + act(() => root.unmount()); + }); + + it("live-commits the Size field on input, without requiring blur/Enter", async () => { + const onSetTextFieldStyle = vi.fn(); + const { host, root } = renderInto( + , + ); + const sizeLabel = Array.from(host.querySelectorAll("span")).find( + (el) => el.textContent === "Size", + ); + const input = sizeLabel?.parentElement?.querySelector("input"); + if (!input) throw new Error("expected the Size row's input"); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, "24px"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + // liveCommit debounces on a 120ms timer — no blur/Enter dispatched here. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 160)); + }); + expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px"); + act(() => root.unmount()); + }); +}); + describe("FlatTextSection — multi-field", () => { it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => { const host = document.createElement("div"); @@ -249,4 +377,65 @@ describe("FlatTextSection — multi-field", () => { act(() => root.unmount()); }); + + it("auto-focuses the Content textarea when a new text field is added", async () => { + let addResolved = false; + + function Harness() { + const [fields, setFields] = useState(makeMultiFieldElement().textFields); + const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields }; + return ( + + Promise.resolve().then(() => { + addResolved = true; + setFields((prev) => [ + ...prev, + { + key: "c", + label: "Text", + value: "", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ]); + return "c"; + }) + } + onRemoveTextField={vi.fn()} + /> + ); + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + + const addButton = host.querySelector('[data-flat-text-layer-add="true"]'); + // Wait for onAddTextField's promise to resolve (adds field "c" and makes it + // active) before checking focus, mirroring the async add-field pattern above. + await act(async () => { + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(addResolved).toBe(true); + + const contentTextarea = host.querySelector("textarea"); + expect(contentTextarea).not.toBeNull(); + expect(document.activeElement).toBe(contentTextarea); + + act(() => root.unmount()); + }); }); diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx index 455053891..480faf90f 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -35,6 +35,7 @@ const CASE_OPTIONS = [ { key: "none", node: "–" }, { key: "uppercase", node: "AG" }, { key: "lowercase", node: "ag" }, + { key: "capitalize", node: "Ag" }, ]; function FlatTextFieldEditor({ @@ -44,6 +45,7 @@ function FlatTextFieldEditor({ onImportFonts, onSetText, onSetTextFieldStyle, + autoFocus = false, }: { field: DomEditSelection["textFields"][number]; styles: Record; @@ -51,6 +53,7 @@ function FlatTextFieldEditor({ onImportFonts?: (files: FileList | File[]) => Promise; onSetText: (value: string, fieldKey?: string) => void; onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; + autoFocus?: boolean; }) { const weight = getTextStyleValue(field, styles, "font-weight", "400"); const weightOptions = detectAvailableWeights( @@ -66,6 +69,7 @@ function FlatTextFieldEditor({ flat label="Content" value={field.value} + autoFocus={autoFocus} onCommit={(next) => onSetText(next, field.key)} /> onSetTextFieldStyle(field.key, "font-size", next)} />
@@ -148,7 +153,10 @@ function FlatTextFieldEditor({ options={ALIGN_OPTIONS.map((option) => ({ key: option.key, node: option.node, - active: align === option.key || (option.key === "left" && align === "start"), + active: + align === option.key || + (option.key === "left" && align === "start") || + (option.key === "right" && align === "end"), }))} onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)} /> @@ -234,12 +242,14 @@ export function FlatTextSection({ onRemove={onRemoveTextField} />
); @@ -296,7 +306,7 @@ export function FlatTextLayerList({ Text layers
- {fields.map((field) => { + {fields.map((field, index) => { const active = field.key === activeFieldKey; return (
- {formatTextFieldPreview(field.value) || "Text"} + {formatTextFieldPreview(field.value) || `Text ${index + 1}`} {field.tagName}