// @vitest-environment happy-dom
import React, { act, useState } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection";
import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
(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("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();
const onRemove = vi.fn();
const { host, root } = renderInto(
,
);
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('[data-flat-text-layer-add="true"]');
act(() => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onAdd).toHaveBeenCalledTimes(1);
const removeButton = host.querySelector(
'[data-flat-text-layer-remove="true"]',
);
act(() => removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onRemove).toHaveBeenCalledWith("a");
// stopPropagation on the remove button must prevent the row's own onClick
// from also firing onSelect for the removed field's key.
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).not.toHaveBeenCalledWith("a");
act(() => root.unmount());
});
});
function makeMultiFieldElement(): DomEditSelection {
return {
element: document.createElement("div"),
id: "multi",
selector: ".multi",
label: "Multi",
tagName: "div",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
textContent: "Headline Subhead",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [
{
key: "a",
label: "Text",
value: "Headline",
tagName: "div",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "self",
},
{
key: "b",
label: "Text",
value: "Subhead",
tagName: "span",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "self",
},
],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
} 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 but re-clicking it is a no-op — preserves the logical value", () => {
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");
// Clicking the option that's already visually active for "end" must NOT
// rewrite it to the physical "right" — that would destroy the logical
// semantics and break RTL content, where "end" and "right" differ.
act(() => rightButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onSetTextFieldStyle).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("commits a genuine align change away from a logical value", () => {
const onSetTextFieldStyle = vi.fn();
const { host, root } = renderInto(
,
);
const alignButtons = segmentedRowButtons(host, "Align");
const centerButton = alignButtons.find((button) => button.textContent === "C");
expect(centerButton).not.toBeUndefined();
act(() => centerButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-align", "center");
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");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
,
);
});
expect(host.textContent).toContain("Headline");
expect(host.textContent).toContain("Subhead");
// Active field's editor rows are visible (Font/Weight/etc. from FlatTextFieldEditor).
expect(host.textContent).toContain("Weight");
// Exactly one "Text layers" micro-label — this component doesn't duplicate its own list.
const layerLabels = Array.from(host.querySelectorAll("div")).filter(
(el) => el.textContent === "Text layers",
);
expect(layerLabels.length).toBeLessThanOrEqual(1);
const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(host.textContent).toContain("Subhead");
act(() => root.unmount());
});
it("wires onAdd/onRemove end-to-end: async onAddTextField switches the active field once it appears in props, and the resync effect falls back to the first field when the active one disappears", 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: "Third",
tagName: "div",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "self",
},
]);
return "c";
})
}
onRemoveTextField={(fieldKey: string) =>
setFields((prev) => prev.filter((field) => field.key !== fieldKey))
}
/>
);
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render();
});
let rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
expect(rows).toHaveLength(2);
expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true");
const addButton = host.querySelector('[data-flat-text-layer-add="true"]');
await act(async () => {
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(addResolved).toBe(true);
rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
expect(rows).toHaveLength(3);
expect((rows[2] as HTMLElement).getAttribute("data-active")).toBe("true");
// Remove the active field ("c") through the wired onRemoveTextField — the
// resync useEffect must fall back to the first remaining field ("a")
// since "c" no longer exists in element.textFields.
const removeButtons = host.querySelectorAll(
'[data-flat-text-layer-remove="true"]',
);
act(() => {
removeButtons[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
expect(rows).toHaveLength(2);
expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true");
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());
});
});