From 5289f335f5afda245aee601ae9b44a4038d1dcca Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 23:22:57 -0700 Subject: [PATCH] feat(studio): stack flat inspector headers as sticky top/bottom rails Collapsed group headers above the open group stick to the top of the panel in order, collapsed headers below stick to the bottom in order, and the open group's own header sticks just below the top stack, so headers never scroll out of view or overlap. --- .../components/editor/PropertyPanel.test.tsx | 114 ++++++++++++++++++ .../components/editor/PropertyPanelFlat.tsx | 26 +++- .../propertyPanelFlatPrimitives.test.tsx | 66 ++++++++++ .../editor/propertyPanelFlatPrimitives.tsx | 17 ++- 4 files changed, 220 insertions(+), 3 deletions(-) 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}