feat(studio): wire the flat Style group into the one-open accordion

This commit is contained in:
Vance Ingalls
2026-07-14 15:50:39 -07:00
parent ff7a9c593a
commit fea582f2b4
3 changed files with 99 additions and 15 deletions
@@ -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<typeof baseElement> = 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,
);
});
@@ -268,6 +268,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
return (
<PropertyPanelFlat
{...props}
key={element.id ?? element.selector}
element={element}
styles={styles}
sections={sections}
@@ -7,6 +7,7 @@ import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { FlatGroup } from "./propertyPanelFlatPrimitives";
import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
import { TimingSection } from "./propertyPanelTimingSection";
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
@@ -102,15 +103,25 @@ export function PropertyPanelFlat({
clipboardCopied: boolean;
onCopyElementInfo: () => 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<string>("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 <PropertyPanelFlat> 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<string>(() =>
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "",
);
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
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 (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
@@ -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 ?? "")}
>
<FlatTextSection
@@ -159,6 +164,27 @@ export function PropertyPanelFlat({
</FlatGroup>
)}
{showEditableSections && (
<FlatGroup
title="Style"
isOpen={openGroupId === "style" || pinnedGroupIds.includes("style")}
isPinned={pinnedGroupIds.includes("style")}
onToggleOpen={() => 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)}%`}
>
<FlatStyleSection
projectId={projectId}
element={element}
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
/>
</FlatGroup>
)}
{sections.timing && (
<TimingSection
element={element}