Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions packages/studio/src/components/editor/PropertyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<HTMLButtonElement>('[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<HTMLButtonElement>(
'[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<HTMLButtonElement>('[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,
);
});
26 changes: 25 additions & 1 deletion packages/studio/src/components/editor/PropertyPanelFlat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<PropertyPanelFlatHeader
Expand Down Expand Up @@ -486,12 +508,14 @@ export function PropertyPanelFlat({
</PinnedGroupRow>
))}
{pinned.length > 0 && unpinned.length > 0 && <PinnedZoneDivider />}
{unpinned.map((g) => (
{unpinnedWithOffsets.map((g) => (
<FlatGroup
key={g.id}
title={g.title}
isOpen={openGroupId === g.id}
isPinned={false}
stackSide={g.stackSide}
stackOffsetPx={g.stackOffsetPx}
onToggleOpen={() => toggleOpen(g.id)}
onTogglePin={() => togglePin(g.id)}
summary={g.summary}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ describe("FlatGroup", () => {
title="Text"
isOpen
isPinned={false}
stackSide="top"
stackOffsetPx={0}
onToggleOpen={onToggleOpen}
onTogglePin={onTogglePin}
>
Expand All @@ -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%"
Expand All @@ -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(
<FlatGroup
title="Layout"
isOpen={false}
isPinned={false}
stackSide="top"
stackOffsetPx={80}
onToggleOpen={vi.fn()}
onTogglePin={vi.fn()}
>
<div data-testid="body">body</div>
</FlatGroup>,
);
const row = host.querySelector<HTMLButtonElement>('[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(
<FlatGroup
title="Grade"
isOpen={false}
isPinned={false}
stackSide="bottom"
stackOffsetPx={80}
onToggleOpen={vi.fn()}
onTogglePin={vi.fn()}
>
<div data-testid="body">body</div>
</FlatGroup>,
);
const row = host.querySelector<HTMLButtonElement>('[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(
<FlatGroup
title="Motion"
isOpen
isPinned={false}
stackSide="top"
stackOffsetPx={120}
onToggleOpen={vi.fn()}
onTogglePin={vi.fn()}
>
<div data-testid="body">body</div>
</FlatGroup>,
);
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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export function FlatGroup({
title,
isOpen,
isPinned,
stackSide,
stackOffsetPx,
onToggleOpen,
onTogglePin,
accessory,
Expand All @@ -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;
Expand All @@ -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"
>
<span className="flex min-w-0 items-center gap-2">
<span className="text-[12px] font-medium text-panel-text-2">{title}</span>
Expand All @@ -186,7 +196,10 @@ export function FlatGroup({

return (
<div className="border-b border-panel-hairline px-4 py-3" data-flat-group-open="true">
<div className="mb-2.5 flex items-center justify-between">
<div
style={{ position: "sticky", [stackSide]: stackOffsetPx }}
className="z-10 -mx-4 mb-2.5 flex min-h-10 items-center justify-between bg-panel-bg px-4"
>
<span className="text-[12px] font-semibold text-panel-text-0">{title}</span>
<span className="flex items-center gap-2.5 text-panel-text-5">
{accessory}
Expand Down
Loading