diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx
index 4ca2cb36db..32e009d79d 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 455053891e..480faf90ff 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