fix(studio): restore capitalize, end-align, live-commit size, and autofocus in flat Text

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-14 15:51:55 -07:00
co-authored by Claude Sonnet 5
parent 8a25f21cd6
commit f6fa9b4ec3
2 changed files with 202 additions and 3 deletions
@@ -46,6 +46,26 @@ const FIELDS = [
]; ];
describe("FlatTextLayerList", () => { 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(
<FlatTextLayerList
fields={emptyFields as never}
activeFieldKey="a"
styles={{}}
onSelect={vi.fn()}
onAdd={vi.fn()}
onRemove={vi.fn()}
/>,
);
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", () => { it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const onAdd = vi.fn(); const onAdd = vi.fn();
@@ -139,6 +159,114 @@ function makeMultiFieldElement(): DomEditSelection {
} as DomEditSelection; } as DomEditSelection;
} }
function makeSingleFieldElement(overrides: Partial<DomEditTextField> = {}): 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<HTMLButtonElement>('[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(
<FlatTextSection
element={makeSingleFieldElement()}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={onSetTextFieldStyle}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
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(
<FlatTextSection
element={makeSingleFieldElement({ computedStyles: { "text-align": "end" } })}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={onSetTextFieldStyle}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
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(
<FlatTextSection
element={makeSingleFieldElement()}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={onSetTextFieldStyle}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
const sizeLabel = Array.from(host.querySelectorAll("span")).find(
(el) => el.textContent === "Size",
);
const input = sizeLabel?.parentElement?.querySelector<HTMLInputElement>("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", () => { 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)", () => { 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"); const host = document.createElement("div");
@@ -249,4 +377,65 @@ describe("FlatTextSection — multi-field", () => {
act(() => root.unmount()); 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<DomEditTextField[]>(makeMultiFieldElement().textFields);
const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields };
return (
<FlatTextSection
element={element}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={vi.fn()}
onAddTextField={() =>
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(<Harness />);
});
const addButton = host.querySelector<HTMLButtonElement>('[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());
});
}); });
@@ -35,6 +35,7 @@ const CASE_OPTIONS = [
{ key: "none", node: "" }, { key: "none", node: "" },
{ key: "uppercase", node: "AG" }, { key: "uppercase", node: "AG" },
{ key: "lowercase", node: "ag" }, { key: "lowercase", node: "ag" },
{ key: "capitalize", node: "Ag" },
]; ];
function FlatTextFieldEditor({ function FlatTextFieldEditor({
@@ -44,6 +45,7 @@ function FlatTextFieldEditor({
onImportFonts, onImportFonts,
onSetText, onSetText,
onSetTextFieldStyle, onSetTextFieldStyle,
autoFocus = false,
}: { }: {
field: DomEditSelection["textFields"][number]; field: DomEditSelection["textFields"][number];
styles: Record<string, string>; styles: Record<string, string>;
@@ -51,6 +53,7 @@ function FlatTextFieldEditor({
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>; onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
onSetText: (value: string, fieldKey?: string) => void; onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
autoFocus?: boolean;
}) { }) {
const weight = getTextStyleValue(field, styles, "font-weight", "400"); const weight = getTextStyleValue(field, styles, "font-weight", "400");
const weightOptions = detectAvailableWeights( const weightOptions = detectAvailableWeights(
@@ -66,6 +69,7 @@ function FlatTextFieldEditor({
flat flat
label="Content" label="Content"
value={field.value} value={field.value}
autoFocus={autoFocus}
onCommit={(next) => onSetText(next, field.key)} onCommit={(next) => onSetText(next, field.key)}
/> />
<FontFamilyField <FontFamilyField
@@ -79,6 +83,7 @@ function FlatTextFieldEditor({
label="Size" label="Size"
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"} value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")} tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
liveCommit
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)} onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
/> />
<div className="flex min-h-[30px] items-center justify-between"> <div className="flex min-h-[30px] items-center justify-between">
@@ -148,7 +153,10 @@ function FlatTextFieldEditor({
options={ALIGN_OPTIONS.map((option) => ({ options={ALIGN_OPTIONS.map((option) => ({
key: option.key, key: option.key,
node: option.node, 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)} onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)}
/> />
@@ -234,12 +242,14 @@ export function FlatTextSection({
onRemove={onRemoveTextField} onRemove={onRemoveTextField}
/> />
<FlatTextFieldEditor <FlatTextFieldEditor
key={activeField.key}
field={activeField} field={activeField}
styles={styles} styles={styles}
fontAssets={fontAssets} fontAssets={fontAssets}
onImportFonts={onImportFonts} onImportFonts={onImportFonts}
onSetText={onSetText} onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle} onSetTextFieldStyle={onSetTextFieldStyle}
autoFocus
/> />
</div> </div>
); );
@@ -296,7 +306,7 @@ export function FlatTextLayerList({
Text layers Text layers
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{fields.map((field) => { {fields.map((field, index) => {
const active = field.key === activeFieldKey; const active = field.key === activeFieldKey;
return ( return (
<div <div
@@ -313,7 +323,7 @@ export function FlatTextLayerList({
style={{ backgroundColor: getTextFieldColor(field, styles) }} style={{ backgroundColor: getTextFieldColor(field, styles) }}
/> />
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1"> <span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
{formatTextFieldPreview(field.value) || "Text"} {formatTextFieldPreview(field.value) || `Text ${index + 1}`}
</span> </span>
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4"> <span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
{field.tagName} {field.tagName}