feat(studio): add FlatGroup accordion and PinnedZoneDivider primitives

This commit is contained in:
Vance Ingalls
2026-07-14 00:59:06 -07:00
parent c1916f1c25
commit b324a43ba0
2 changed files with 161 additions and 1 deletions
@@ -3,7 +3,12 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
import {
FlatGroup,
FlatRow,
FlatSegmentedRow,
PinnedZoneDivider,
} from "./propertyPanelFlatPrimitives";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -99,3 +104,56 @@ describe("FlatSegmentedRow", () => {
act(() => root.unmount());
});
});
describe("FlatGroup", () => {
it("renders the open header (name + pin + caret) and shows children", () => {
const onToggleOpen = vi.fn();
const onTogglePin = vi.fn();
const { host, root } = renderInto(
<FlatGroup
title="Text"
isOpen
isPinned={false}
onToggleOpen={onToggleOpen}
onTogglePin={onTogglePin}
>
<div data-testid="body">body</div>
</FlatGroup>,
);
expect(host.querySelector('[data-testid="body"]')).not.toBeNull();
const pin = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
act(() => pin?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onTogglePin).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("renders the collapsed row (name + summary + caret-right) and no children", () => {
const onToggleOpen = vi.fn();
const { host, root } = renderInto(
<FlatGroup
title="Style"
isOpen={false}
isPinned={false}
onToggleOpen={onToggleOpen}
onTogglePin={vi.fn()}
summary="fill none · 100%"
>
<div data-testid="body">body</div>
</FlatGroup>,
);
expect(host.querySelector('[data-testid="body"]')).toBeNull();
expect(host.textContent).toContain("fill none · 100%");
const row = host.querySelector<HTMLButtonElement>('[data-flat-group-collapsed="true"]');
act(() => row?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onToggleOpen).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
});
describe("PinnedZoneDivider", () => {
it("renders the 'one open below' label", () => {
const { host, root } = renderInto(<PinnedZoneDivider />);
expect(host.textContent).toContain("one open below");
act(() => root.unmount());
});
});
@@ -131,3 +131,105 @@ export function FlatSegmentedRow({
</div>
);
}
/* ------------------------------------------------------------------ */
/* FlatGroup — one-open-at-a-time accordion group (controlled) */
/* ------------------------------------------------------------------ */
export function FlatGroup({
title,
isOpen,
isPinned,
onToggleOpen,
onTogglePin,
accessory,
summary,
children,
}: {
title: string;
isOpen: boolean;
isPinned: boolean;
onToggleOpen: () => void;
onTogglePin: () => void;
accessory?: ReactNode;
summary?: string;
children: ReactNode;
}) {
if (!isOpen) {
return (
<button
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"
>
<span className="flex min-w-0 items-center gap-2">
<span className="text-[12px] font-medium text-panel-text-2">{title}</span>
{summary && (
<span className="min-w-0 truncate font-mono text-[9px] text-panel-text-4">
{summary}
</span>
)}
</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M4 2l4 4-4 4z" />
</svg>
</button>
);
}
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">
<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}
<button
type="button"
data-flat-group-pin="true"
title={isPinned ? "Unpin" : "Pin"}
onClick={onTogglePin}
className={isPinned ? "text-panel-accent" : "text-panel-text-5 hover:text-panel-text-3"}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M4 1h4v3.2l1.4 1.4V7H7v4L6 12l-1-1V7H2.6V5.6L4 4.2z" />
</svg>
</button>
<button
type="button"
onClick={onToggleOpen}
title="Collapse"
className="text-panel-text-3"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M2 4l4 4 4-4z" />
</svg>
</button>
</span>
</div>
{children}
</div>
);
}
/* ------------------------------------------------------------------ */
/* PinnedZoneDivider */
/* ------------------------------------------------------------------ */
export function PinnedZoneDivider() {
return (
<div className="flex items-center gap-3 px-4 py-2">
<span className="h-px flex-1 bg-panel-hairline" aria-hidden="true" />
<span className="text-[8px] font-semibold uppercase tracking-[0.14em] text-panel-text-5">
one open below
</span>
<span className="h-px flex-1 bg-panel-hairline" aria-hidden="true" />
</div>
);
}