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

This commit is contained in:
Vance Ingalls
2026-07-14 15:50:45 -07:00
parent ea463cdf8f
commit 5a3abdef36
2 changed files with 144 additions and 13 deletions
@@ -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 <div> 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,
);
});
@@ -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<typeof resolveEditingSections>;
@@ -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<string>(() =>
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "layout",
isTextEditableSelection(element)
? "text"
: showEditableSections
? "style"
: sections.media
? "media"
: "layout",
);
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
@@ -427,15 +433,24 @@ export function PropertyPanelFlat({
/>
)}
{sections.media && (
<MediaSection
projectDir={projectDir}
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>
<FlatGroup
title="Media"
isOpen={openGroupId === "media" || pinnedGroupIds.includes("media")}
isPinned={pinnedGroupIds.includes("media")}
onToggleOpen={() => toggleOpen("media")}
onTogglePin={() => togglePin("media")}
summary={element.tagName}
>
<FlatMediaSection
projectDir={projectDir}
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>
</FlatGroup>
)}
{showEditableSections && (
<StyleSections