From 5a3abdef3650aff2c6600a88aea10a8a1b85d32a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 14:21:03 -0700 Subject: [PATCH] feat(studio): wire the flat Media group into the one-open accordion --- .../components/editor/PropertyPanel.test.tsx | 116 ++++++++++++++++++ .../components/editor/PropertyPanelFlat.tsx | 41 +++++-- 2 files changed, 144 insertions(+), 13 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 298773af5..a3d854c7b 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -615,3 +615,119 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f RENDER_TIMEOUT_MS, ); }); + +// Media fixtures (Plan 4 Task 7): the three tag values resolveEditingSections +// turns `media` on for (video/audio/img). Each carries no text fields (so the +// Text group never renders) and sets `element` to a real media node so the +// FlatMediaSection reads a live media element. `as never` casts around the +// element-type mismatch with baseElement()'s HTMLDivElement. +function videoElement() { + return { + ...baseElement(), + id: "s1-bg", + selector: "#s1-bg", + label: "S1 Background", + tagName: "video", + textFields: [], + element: document.createElement("video"), + }; +} + +function imageElement() { + return { + ...baseElement(), + id: "s1-img", + selector: "#s1-img", + label: "S1 Image", + tagName: "img", + textFields: [], + element: document.createElement("img"), + }; +} + +function audioElement() { + return { + ...baseElement(), + id: "s1-audio", + selector: "#s1-audio", + label: "S1 Audio", + tagName: "audio", + textFields: [], + element: document.createElement("audio"), + }; +} + +// All FlatGroup titles currently mounted (open row + every collapsed row). +function flatGroupTitles(host: HTMLElement): string[] { + const open = Array.from( + host.querySelectorAll('[data-flat-group-open="true"] .text-panel-text-0'), + ).map((el) => el.textContent ?? ""); + const collapsed = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'), + ).map((el) => el.textContent ?? ""); + return [...open, ...collapsed]; +} + +describe("PropertyPanel — Media group (Plan 4)", () => { + it( + "renders the flat Media group and not the legacy MediaSection, for a video element", + async () => { + const { host, root } = await renderPanel(true, videoElement() as never); + // A Media FlatGroup exists (open or collapsed). + expect(flatGroupTitles(host)).toContain("Media"); + // The legacy MediaSection renders its rows inside a `Section` whose + // data-panel-section slug is the media title ("video"/"image"/"audio"). + // On the flat path it's fully replaced, so none of those may appear. + expect(host.querySelector('[data-panel-section="video"]')).toBeNull(); + expect(host.querySelector('[data-panel-section="image"]')).toBeNull(); + expect(host.querySelector('[data-panel-section="audio"]')).toBeNull(); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "one-open accordion: opening Media closes whichever other group was open, and vice versa (5-way exclusivity)", + async () => { + // videoElement() has canEditStyles: true and no text fields, so Style is + // the default-open group; Layout and Media render collapsed alongside it. + const { host, root } = await renderPanel(true, videoElement() as never); + expect(openGroupText(host)).toContain("Style"); + + // Opening Media closes Style. + openFlatGroup(host, "Media"); + const afterMedia = openGroupText(host); + expect(afterMedia).toContain("Media"); + expect(afterMedia).not.toContain("Style"); + + // Reverse direction: opening Layout closes Media — same shared openGroupId. + openFlatGroup(host, "Layout"); + const afterLayout = openGroupText(host); + expect(afterLayout).toContain("Layout"); + expect(afterLayout).not.toContain("Media"); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "gates the Media group exactly like the legacy MediaSection: present for video/img/audio, absent for a plain div/text element", + async () => { + for (const fixture of [videoElement, imageElement, audioElement]) { + const { host, root } = await renderPanel(true, fixture() as never); + expect(flatGroupTitles(host)).toContain("Media"); + act(() => root.unmount()); + } + + // baseElement() is a plain
with text — sections.media is false, so + // no Media group (flat or legacy) may render. + const { host, root } = await renderPanel(true); + expect(flatGroupTitles(host)).not.toContain("Media"); + expect(host.querySelector('[data-panel-section="video"]')).toBeNull(); + expect(host.querySelector('[data-panel-section="image"]')).toBeNull(); + expect(host.querySelector('[data-panel-section="audio"]')).toBeNull(); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 762d50c19..cdb1c9b2a 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -11,12 +11,12 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; import { FlatMotionSection } from "./propertyPanelFlatMotionSection"; +import { FlatMediaSection } from "./propertyPanelFlatMediaSection"; import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; import { createGsapLivePreview } from "./gsapLivePreview"; import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections"; import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; import { ColorGradingSection } from "./propertyPanelColorGradingSection"; -import { MediaSection } from "./propertyPanelMediaSection"; type EditingSections = ReturnType; @@ -41,8 +41,8 @@ const EMPTY_GSAP_EFFECT_HANDLERS = { * (same one-directional-import precedent as FlatTextSection). Rendered only * when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open/pin group state. * - * The Text/Style/Layout/Motion groups share the one-open accordion. The legacy - * Media and Color-Grading sections render unchanged below the flat groups. + * The Text/Style/Layout/Motion/Media groups share the one-open accordion. The + * legacy Color-Grading section renders unchanged below the flat groups. */ // fallow-ignore-next-line complexity export function PropertyPanelFlat({ @@ -215,7 +215,13 @@ export function PropertyPanelFlat({ // 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" : "layout", + isTextEditableSelection(element) + ? "text" + : showEditableSections + ? "style" + : sections.media + ? "media" + : "layout", ); const [pinnedGroupIds, setPinnedGroupIds] = useState([]); @@ -427,15 +433,24 @@ export function PropertyPanelFlat({ /> )} {sections.media && ( - + toggleOpen("media")} + onTogglePin={() => togglePin("media")} + summary={element.tagName} + > + + )} {showEditableSections && (