diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 46223d193b..66dd876ff8 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -165,6 +165,22 @@ function inferredMotionElement() { // A single "to" tween running from t=2 to t=5 (position 2, duration 3), with // keyframes on "x" at 0/50/100% — enough to drive both FlatTimingRow's // inference and the Layout "x" row's keyframe-seek gutter. +// Six-group fixture (sticky-header-stack worked example): tagName "img" turns +// on both Media and Grade (resolveEditingSections), on top of baseElement()'s +// text-editable + style-editable + timing-eligible (data-start) defaults — +// yielding all six groups in the same order as the brief's worked example: +// [text, style, layout, motion, grade, media]. +function sixGroupElement() { + return { + ...baseElement(), + id: "six-group", + selector: "#six-group", + label: "Six Group", + tagName: "img", + dataAttributes: { start: "0", duration: "4" }, + }; +} + const INFERRED_TIMING_ANIMATION = { id: "a1", targetSelector: "#inferred-anim", @@ -837,3 +853,101 @@ describe("PropertyPanel — pinning", () => { RENDER_TIMEOUT_MS, ); }); + +// design_handoff sticky-header-stack: collapsed headers above the open group +// stack from the panel's top edge in list order; collapsed headers below it +// stack from the bottom edge in list order; the open group's own header +// sticks just below the top stack. Worked example from the brief: 6 groups +// [text, style, layout, motion, grade, media], motion open (index 3) -> +// text/style/layout top-stack at 0/40/80, motion's own header sticks at top +// 120 (below the 3 collapsed-above headers), grade/media bottom-stack at +// 40/0 (media flush to the very bottom). +describe("PropertyPanel — sticky header stack (Plan 9)", () => { + it( + "stacks collapsed headers top-above / bottom-below the open group, in list order", + async () => { + const { host, root } = await renderPanel(true, sixGroupElement()); + // sixGroupElement() opens Text by default; open Motion (index 3) to + // match the brief's worked example. + openFlatGroup(host, "Motion"); + expect(openGroupText(host)).toContain("Motion"); + + const collapsedRows = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"]'), + ); + const byTitle = (title: string) => { + const row = collapsedRows.find((el) => el.textContent?.includes(title)); + if (!row) throw new Error(`expected a collapsed ${title} row`); + return row; + }; + + const text = byTitle("Text"); + expect(text.style.position).toBe("sticky"); + expect(text.style.top).toBe("0px"); + expect(text.style.bottom).toBe(""); + + const style = byTitle("Style"); + expect(style.style.top).toBe("40px"); + + const layout = byTitle("Layout"); + expect(layout.style.top).toBe("80px"); + + const grade = byTitle("Grade"); + expect(grade.style.position).toBe("sticky"); + expect(grade.style.bottom).toBe("40px"); + expect(grade.style.top).toBe(""); + + const media = byTitle("Media"); + expect(media.style.bottom).toBe("0px"); + + // The open Motion group's own title bar sticks at top: 120 (below the + // 3 collapsed-above headers: text/style/layout at 40px each). + const openGroup = host.querySelector('[data-flat-group-open="true"]'); + const titleBar = openGroup?.firstElementChild as HTMLElement | null; + expect(titleBar?.style.position).toBe("sticky"); + expect(titleBar?.style.top).toBe("120px"); + expect(titleBar?.style.bottom).toBe(""); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "falls back to top-stacking every header in list order when no group is open", + async () => { + const { host, root } = await renderPanel(true, sixGroupElement()); + // Collapse the default-open Text group so openGroupId becomes "". + const collapseButton = host.querySelector( + '[data-flat-group-open="true"] button[title="Collapse"]', + ); + act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull(); + + const collapsedRows = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"]'), + ); + const byTitle = (title: string) => { + const row = collapsedRows.find((el) => el.textContent?.includes(title)); + if (!row) throw new Error(`expected a collapsed ${title} row`); + return row; + }; + + const expectedOffsets: Array<[string, number]> = [ + ["Text", 0], + ["Style", 40], + ["Layout", 80], + ["Motion", 120], + ["Grade", 160], + ["Media", 200], + ]; + for (const [title, offsetPx] of expectedOffsets) { + const row = byTitle(title); + expect(row.style.position).toBe("sticky"); + expect(row.style.top).toBe(`${offsetPx}px`); + expect(row.style.bottom).toBe(""); + } + 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 238d96f897..b158d455c2 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -33,6 +33,10 @@ type FlatGroupDescriptor = { content: ReactNode; }; +// Header height for the sticky stacking math below — matches FlatGroup's +// collapsed `min-h-10` (2.5rem) and the open title bar's matching `min-h-10`. +const HEADER_HEIGHT_PX = 40; + // Type-only fallback for the Motion effect-card callbacks. Used solely to // satisfy FlatMotionSection's required-callback shape when the effect list is // gated off (showEffects === false, so none of these are ever invoked). Keeps @@ -456,6 +460,24 @@ export function PropertyPanelFlat({ const pinned = groups.filter((g) => pinnedGroupIds.includes(g.id)); const unpinned = groups.filter((g) => !pinnedGroupIds.includes(g.id)); + // Per-group sticky stacking offsets (design_handoff sticky-header-stack): + // collapsed headers above the open group stack from the panel's top edge in + // order; collapsed headers below it stack from the bottom edge in order; + // the open group's own header sticks just below the top stack. When no + // group is open, every header stacks from the top in list order. + const openIndex = unpinned.findIndex((g) => g.id === openGroupId); + const unpinnedWithOffsets = unpinned.map((g, index) => { + if (openIndex === -1 || index <= openIndex) { + return { ...g, stackSide: "top" as const, stackOffsetPx: index * HEADER_HEIGHT_PX }; + } + const distanceFromEnd = unpinned.length - 1 - index; + return { + ...g, + stackSide: "bottom" as const, + stackOffsetPx: distanceFromEnd * HEADER_HEIGHT_PX, + }; + }); + return (
))} {pinned.length > 0 && unpinned.length > 0 && } - {unpinned.map((g) => ( + {unpinnedWithOffsets.map((g) => ( toggleOpen(g.id)} onTogglePin={() => togglePin(g.id)} summary={g.summary} diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index dccf1aa232..37094931c3 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -118,6 +118,8 @@ describe("FlatGroup", () => { title="Text" isOpen isPinned={false} + stackSide="top" + stackOffsetPx={0} onToggleOpen={onToggleOpen} onTogglePin={onTogglePin} > @@ -138,6 +140,8 @@ describe("FlatGroup", () => { title="Style" isOpen={false} isPinned={false} + stackSide="top" + stackOffsetPx={0} onToggleOpen={onToggleOpen} onTogglePin={vi.fn()} summary="fill none · 100%" @@ -152,6 +156,68 @@ describe("FlatGroup", () => { expect(onToggleOpen).toHaveBeenCalledTimes(1); act(() => root.unmount()); }); + + it("sticks a collapsed header to the top at its offset", () => { + const { host, root } = renderInto( + +
body
+
, + ); + const row = host.querySelector('[data-flat-group-collapsed="true"]'); + expect(row?.style.position).toBe("sticky"); + expect(row?.style.top).toBe("80px"); + act(() => root.unmount()); + }); + + it("sticks a collapsed header to the bottom at its offset, with top unset", () => { + const { host, root } = renderInto( + +
body
+
, + ); + const row = host.querySelector('[data-flat-group-collapsed="true"]'); + expect(row?.style.position).toBe("sticky"); + expect(row?.style.bottom).toBe("80px"); + expect(row?.style.top).toBe(""); + act(() => root.unmount()); + }); + + it("sticks the open group's title bar too, not just the collapsed button", () => { + const { host, root } = renderInto( + +
body
+
, + ); + const openGroup = host.querySelector('[data-flat-group-open="true"]'); + const titleBar = openGroup?.firstElementChild as HTMLElement | null; + expect(titleBar?.style.position).toBe("sticky"); + expect(titleBar?.style.top).toBe("120px"); + act(() => root.unmount()); + }); }); describe("PinnedZoneDivider", () => { diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 21f02295f5..54ce348228 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -140,6 +140,8 @@ export function FlatGroup({ title, isOpen, isPinned, + stackSide, + stackOffsetPx, onToggleOpen, onTogglePin, accessory, @@ -149,6 +151,10 @@ export function FlatGroup({ title: string; isOpen: boolean; isPinned: boolean; + /** Which panel edge this group's header sticks to while stacked. */ + stackSide: "top" | "bottom"; + /** Pixel offset from `stackSide`, so multiple stacked headers stand in order. */ + stackOffsetPx: number; onToggleOpen: () => void; onTogglePin: () => void; accessory?: ReactNode; @@ -161,7 +167,11 @@ export function FlatGroup({ type="button" data-flat-group-collapsed="true" onClick={onToggleOpen} - className="flex min-h-10 w-full items-center justify-between gap-2 border-b border-panel-hairline px-4 text-left" + style={{ + position: "sticky", + [stackSide]: stackOffsetPx, + }} + className="z-10 flex min-h-10 w-full flex-shrink-0 items-center justify-between gap-2 border-b border-panel-hairline bg-panel-bg px-4 text-left" > {title} @@ -186,7 +196,10 @@ export function FlatGroup({ return (
-
+
{title} {accessory}