Skip to content

Commit fa4d579

Browse files
vanceingallsclaude
andcommitted
fix(studio): restore capitalize, end-align, live-commit size, and autofocus in flat Text
Co-Authored-By: Claude Sonnet 5 <[email protected]>
1 parent 7b321ac commit fa4d579

2 files changed

Lines changed: 202 additions & 3 deletions

File tree

‎packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ const FIELDS = [
4646
];
4747

4848
describe("FlatTextLayerList", () => {
49+
it("falls back to a numbered label per index for empty fields, not a bare 'Text'", () => {
50+
const emptyFields = [
51+
{ ...FIELDS[0], value: "" },
52+
{ ...FIELDS[1], value: "" },
53+
];
54+
const { host, root } = renderInto(
55+
<FlatTextLayerList
56+
fields={emptyFields as never}
57+
activeFieldKey="a"
58+
styles={{}}
59+
onSelect={vi.fn()}
60+
onAdd={vi.fn()}
61+
onRemove={vi.fn()}
62+
/>,
63+
);
64+
expect(host.textContent).toContain("Text 1");
65+
expect(host.textContent).toContain("Text 2");
66+
act(() => root.unmount());
67+
});
68+
4969
it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => {
5070
const onSelect = vi.fn();
5171
const onAdd = vi.fn();
@@ -139,6 +159,114 @@ function makeMultiFieldElement(): DomEditSelection {
139159
} as DomEditSelection;
140160
}
141161

162+
function makeSingleFieldElement(overrides: Partial<DomEditTextField> = {}): DomEditSelection {
163+
const base = makeMultiFieldElement();
164+
return {
165+
...base,
166+
textFields: [
167+
{
168+
key: "a",
169+
label: "Text",
170+
value: "Headline",
171+
tagName: "div",
172+
attributes: [],
173+
inlineStyles: {},
174+
computedStyles: {},
175+
source: "self",
176+
...overrides,
177+
},
178+
],
179+
} as DomEditSelection;
180+
}
181+
182+
function segmentedRowButtons(host: HTMLElement, label: string): HTMLButtonElement[] {
183+
const labelSpan = Array.from(host.querySelectorAll("span")).find(
184+
(el) => el.textContent === label,
185+
);
186+
const row = labelSpan?.parentElement;
187+
return Array.from(row?.querySelectorAll<HTMLButtonElement>('[data-flat-segment="true"]') ?? []);
188+
}
189+
190+
describe("FlatTextFieldEditor controls", () => {
191+
it("commits text-transform: capitalize when the new 'Ag' case button is clicked", () => {
192+
const onSetTextFieldStyle = vi.fn();
193+
const { host, root } = renderInto(
194+
<FlatTextSection
195+
element={makeSingleFieldElement()}
196+
styles={{}}
197+
fontAssets={[]}
198+
onSetText={vi.fn()}
199+
onSetTextFieldStyle={onSetTextFieldStyle}
200+
onAddTextField={vi.fn()}
201+
onRemoveTextField={vi.fn()}
202+
/>,
203+
);
204+
const capitalizeButton = segmentedRowButtons(host, "Case · Style").find(
205+
(button) => button.textContent === "Ag",
206+
);
207+
expect(capitalizeButton).not.toBeUndefined();
208+
act(() => capitalizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
209+
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-transform", "capitalize");
210+
act(() => root.unmount());
211+
});
212+
213+
it("lights up 'right' for text-align: end and commits the concrete 'right' value on click", () => {
214+
const onSetTextFieldStyle = vi.fn();
215+
const { host, root } = renderInto(
216+
<FlatTextSection
217+
element={makeSingleFieldElement({ computedStyles: { "text-align": "end" } })}
218+
styles={{}}
219+
fontAssets={[]}
220+
onSetText={vi.fn()}
221+
onSetTextFieldStyle={onSetTextFieldStyle}
222+
onAddTextField={vi.fn()}
223+
onRemoveTextField={vi.fn()}
224+
/>,
225+
);
226+
const alignButtons = segmentedRowButtons(host, "Align");
227+
const rightButton = alignButtons.find((button) => button.textContent === "R");
228+
expect(rightButton).not.toBeUndefined();
229+
expect(rightButton?.className).toContain("border-panel-accent");
230+
act(() => rightButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
231+
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-align", "right");
232+
act(() => root.unmount());
233+
});
234+
235+
it("live-commits the Size field on input, without requiring blur/Enter", async () => {
236+
const onSetTextFieldStyle = vi.fn();
237+
const { host, root } = renderInto(
238+
<FlatTextSection
239+
element={makeSingleFieldElement()}
240+
styles={{}}
241+
fontAssets={[]}
242+
onSetText={vi.fn()}
243+
onSetTextFieldStyle={onSetTextFieldStyle}
244+
onAddTextField={vi.fn()}
245+
onRemoveTextField={vi.fn()}
246+
/>,
247+
);
248+
const sizeLabel = Array.from(host.querySelectorAll("span")).find(
249+
(el) => el.textContent === "Size",
250+
);
251+
const input = sizeLabel?.parentElement?.querySelector<HTMLInputElement>("input");
252+
if (!input) throw new Error("expected the Size row's input");
253+
act(() => {
254+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
255+
window.HTMLInputElement.prototype,
256+
"value",
257+
)?.set;
258+
nativeInputValueSetter?.call(input, "24px");
259+
input.dispatchEvent(new Event("input", { bubbles: true }));
260+
});
261+
// liveCommit debounces on a 120ms timer — no blur/Enter dispatched here.
262+
await act(async () => {
263+
await new Promise((resolve) => setTimeout(resolve, 160));
264+
});
265+
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
266+
act(() => root.unmount());
267+
});
268+
});
269+
142270
describe("FlatTextSection — multi-field", () => {
143271
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)", () => {
144272
const host = document.createElement("div");
@@ -249,4 +377,65 @@ describe("FlatTextSection — multi-field", () => {
249377

250378
act(() => root.unmount());
251379
});
380+
381+
it("auto-focuses the Content textarea when a new text field is added", async () => {
382+
let addResolved = false;
383+
384+
function Harness() {
385+
const [fields, setFields] = useState<DomEditTextField[]>(makeMultiFieldElement().textFields);
386+
const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields };
387+
return (
388+
<FlatTextSection
389+
element={element}
390+
styles={{}}
391+
fontAssets={[]}
392+
onSetText={vi.fn()}
393+
onSetTextFieldStyle={vi.fn()}
394+
onAddTextField={() =>
395+
Promise.resolve().then(() => {
396+
addResolved = true;
397+
setFields((prev) => [
398+
...prev,
399+
{
400+
key: "c",
401+
label: "Text",
402+
value: "",
403+
tagName: "div",
404+
attributes: [],
405+
inlineStyles: {},
406+
computedStyles: {},
407+
source: "self",
408+
},
409+
]);
410+
return "c";
411+
})
412+
}
413+
onRemoveTextField={vi.fn()}
414+
/>
415+
);
416+
}
417+
418+
const host = document.createElement("div");
419+
document.body.append(host);
420+
const root = createRoot(host);
421+
act(() => {
422+
root.render(<Harness />);
423+
});
424+
425+
const addButton = host.querySelector<HTMLButtonElement>('[data-flat-text-layer-add="true"]');
426+
// Wait for onAddTextField's promise to resolve (adds field "c" and makes it
427+
// active) before checking focus, mirroring the async add-field pattern above.
428+
await act(async () => {
429+
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
430+
await Promise.resolve();
431+
await Promise.resolve();
432+
});
433+
expect(addResolved).toBe(true);
434+
435+
const contentTextarea = host.querySelector("textarea");
436+
expect(contentTextarea).not.toBeNull();
437+
expect(document.activeElement).toBe(contentTextarea);
438+
439+
act(() => root.unmount());
440+
});
252441
});

‎packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const CASE_OPTIONS = [
3535
{ key: "none", node: "–" },
3636
{ key: "uppercase", node: "AG" },
3737
{ key: "lowercase", node: "ag" },
38+
{ key: "capitalize", node: "Ag" },
3839
];
3940

4041
function FlatTextFieldEditor({
@@ -44,13 +45,15 @@ function FlatTextFieldEditor({
4445
onImportFonts,
4546
onSetText,
4647
onSetTextFieldStyle,
48+
autoFocus = false,
4749
}: {
4850
field: DomEditSelection["textFields"][number];
4951
styles: Record<string, string>;
5052
fontAssets: ImportedFontAsset[];
5153
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
5254
onSetText: (value: string, fieldKey?: string) => void;
5355
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
56+
autoFocus?: boolean;
5457
}) {
5558
const weight = getTextStyleValue(field, styles, "font-weight", "400");
5659
const weightOptions = detectAvailableWeights(
@@ -66,6 +69,7 @@ function FlatTextFieldEditor({
6669
flat
6770
label="Content"
6871
value={field.value}
72+
autoFocus={autoFocus}
6973
onCommit={(next) => onSetText(next, field.key)}
7074
/>
7175
<FontFamilyField
@@ -79,6 +83,7 @@ function FlatTextFieldEditor({
7983
label="Size"
8084
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
8185
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
86+
liveCommit
8287
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
8388
/>
8489
<div className="flex min-h-[30px] items-center justify-between">
@@ -148,7 +153,10 @@ function FlatTextFieldEditor({
148153
options={ALIGN_OPTIONS.map((option) => ({
149154
key: option.key,
150155
node: option.node,
151-
active: align === option.key || (option.key === "left" && align === "start"),
156+
active:
157+
align === option.key ||
158+
(option.key === "left" && align === "start") ||
159+
(option.key === "right" && align === "end"),
152160
}))}
153161
onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)}
154162
/>
@@ -234,12 +242,14 @@ export function FlatTextSection({
234242
onRemove={onRemoveTextField}
235243
/>
236244
<FlatTextFieldEditor
245+
key={activeField.key}
237246
field={activeField}
238247
styles={styles}
239248
fontAssets={fontAssets}
240249
onImportFonts={onImportFonts}
241250
onSetText={onSetText}
242251
onSetTextFieldStyle={onSetTextFieldStyle}
252+
autoFocus
243253
/>
244254
</div>
245255
);
@@ -296,7 +306,7 @@ export function FlatTextLayerList({
296306
Text layers
297307
</div>
298308
<div className="space-y-1">
299-
{fields.map((field) => {
309+
{fields.map((field, index) => {
300310
const active = field.key === activeFieldKey;
301311
return (
302312
<div
@@ -313,7 +323,7 @@ export function FlatTextLayerList({
313323
style={{ backgroundColor: getTextFieldColor(field, styles) }}
314324
/>
315325
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
316-
{formatTextFieldPreview(field.value) || "Text"}
326+
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
317327
</span>
318328
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
319329
{field.tagName}

0 commit comments

Comments
 (0)