From fea582f2b4c772919f22e9f927d88b6fae9b5681 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 10:32:13 -0700 Subject: [PATCH] feat(studio): wire the flat Style group into the one-open accordion --- .../components/editor/PropertyPanel.test.tsx | 61 ++++++++++++++++++- .../src/components/editor/PropertyPanel.tsx | 1 + .../components/editor/PropertyPanelFlat.tsx | 52 ++++++++++++---- 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 5c3845e59..d38f67b6d 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -100,6 +100,20 @@ function multiFieldTextElement() { }; } +// Style-only fixture: no text fields (Text group must not render), but +// canEditStyles stays true (inherited from baseElement()) so the Style group +// is gated in. +function styleOnlyElement() { + return { + ...baseElement(), + id: "stat-card", + selector: ".stat-card", + label: "Stat Card", + textFields: [], + inlineStyles: { "background-color": "#0D0C09" }, + }; +} + async function renderPanel( flatEnabled: boolean, elementOverride: ReturnType = baseElement(), @@ -185,9 +199,19 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => { it( "renders no Text group at all for a non-text element (bug 1)", async () => { + // nonTextElement() inherits canEditStyles: true from baseElement(), so + // the Style group (Task 10) renders and opens by default here — the + // invariant under test is narrower than "no flat group at all": no + // group titled "Text" may appear, open or collapsed. const { host, root } = await renderPanel(true, nonTextElement()); - expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull(); - expect(host.querySelector('[data-flat-group-collapsed="true"]')).toBeNull(); + const openTitle = host.querySelector( + '[data-flat-group-open="true"] .text-panel-text-0', + )?.textContent; + const collapsedTitles = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'), + ).map((el) => el.textContent); + expect(openTitle).not.toBe("Text"); + expect(collapsedTitles).not.toContain("Text"); act(() => root.unmount()); }, RENDER_TIMEOUT_MS, @@ -209,3 +233,36 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => { RENDER_TIMEOUT_MS, ); }); + +describe("PropertyPanel — Style group (flag on)", () => { + it( + "renders the Style group for a style-editable, non-text element", + async () => { + const { host, root } = await renderPanel(true, styleOnlyElement()); + expect(host.textContent).toContain("Style"); + expect(host.textContent).toContain("Fill"); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "one-open accordion: opening Style closes Text", + async () => { + // baseElement() is text-editable and has capabilities.canEditStyles: + // true, so both the Text and Style groups render for it. + const { host, root } = await renderPanel(true); + const textGroup = () => host.querySelector('[data-flat-group-open="true"]'); + expect(textGroup()?.textContent).toContain("Text"); + const styleCollapsedRow = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"]'), + ).find((el) => el.textContent?.includes("Style")); + if (!styleCollapsedRow) throw new Error("expected a collapsed Style row"); + act(() => styleCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(textGroup()?.textContent).not.toContain("Text"); + expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Style"); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index e0c544eff..574a8d778 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -268,6 +268,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro return ( void; }) { - // Defaulting to "text" is harmless for a non-text element even though the - // Text FlatGroup won't render (nothing else reads openGroupId yet) — this - // only matters once a second FlatGroup exists (Plan 2+), at which point a - // non-text element should default-open that group instead. - const [openGroupId, setOpenGroupId] = useState("text"); + // Lazy initializer: pick whichever group actually renders for this element + // (Text if text-editable, else Style if style-editable, else none open) so a + // style-only element doesn't start with everything collapsed. Only runs on + // mount — PropertyPanel.tsx keys by element identity so + // switching the selection re-mounts this component and re-derives the + // default instead of preserving stale state across unrelated elements. + const [openGroupId, setOpenGroupId] = useState(() => + isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "", + ); const [pinnedGroupIds, setPinnedGroupIds] = useState([]); const isTextEditable = isTextEditableSelection(element); const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other"; + const toggleOpen = (groupId: string) => + setOpenGroupId((current) => (current === groupId ? "" : groupId)); + const togglePin = (groupId: string) => + setPinnedGroupIds((current) => + current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId], + ); return (
@@ -136,14 +147,8 @@ export function PropertyPanelFlat({ title="Text" isOpen={openGroupId === "text" || pinnedGroupIds.includes("text")} isPinned={pinnedGroupIds.includes("text")} - onToggleOpen={() => setOpenGroupId((current) => (current === "text" ? "" : "text"))} - onTogglePin={() => - setPinnedGroupIds((current) => - current.includes("text") - ? current.filter((id) => id !== "text") - : [...current, "text"], - ) - } + onToggleOpen={() => toggleOpen("text")} + onTogglePin={() => togglePin("text")} summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")} > )} + {showEditableSections && ( + toggleOpen("style")} + onTogglePin={() => togglePin("style")} + summary={`fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`} + > + + + )} + {sections.timing && (