feat(ui): add collapsible SheetSection with summary badge slot

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 18:44:51 +02:00
1 parent 9f8f4167e4
commit 079be9d5af
2 files changed
+181

No files matched your search

@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { SheetSection, SheetSectionCount } from "../sheet-section";
describe("<SheetSection>", () => {
it("renders the title and summary slot on the trigger row", () => {
const html = renderToStaticMarkup(
<SheetSection title="More tags" summary={<span>summary-badge</span>}>
<p>section body</p>
</SheetSection>,
);
expect(html).toContain("More tags");
expect(html).toContain("summary-badge");
});
it("renders children inside an open section", () => {
// Radix Collapsible omits the content subtree while collapsed, so the
// body is asserted on an expanded section.
const html = renderToStaticMarkup(
<SheetSection title="More tags" defaultOpen>
<p>section body</p>
</SheetSection>,
);
expect(html).toContain("section body");
});
it("starts collapsed by default and open when defaultOpen", () => {
const closed = renderToStaticMarkup(
<SheetSection title="Factors">
<p>x</p>
</SheetSection>,
);
const open = renderToStaticMarkup(
<SheetSection title="Factors" defaultOpen>
<p>x</p>
</SheetSection>,
);
expect(closed).toContain('data-state="closed"');
expect(open).toContain('data-state="open"');
});
it("hides the leading icon from assistive tech", () => {
const html = renderToStaticMarkup(
<SheetSection title="Note" icon={<svg data-testid="icon" />}>
<p>x</p>
</SheetSection>,
);
expect(html).toMatch(/aria-hidden="true"[^>]*>[^<]*<svg/);
});
it("respects reduced motion on the content and chevron", () => {
const html = renderToStaticMarkup(
<SheetSection title="x">
<p>y</p>
</SheetSection>,
);
expect(html).toContain("motion-reduce:animate-none");
expect(html).toContain("motion-reduce:transition-none");
});
});
describe("<SheetSectionCount>", () => {
it("renders the count when positive", () => {
const html = renderToStaticMarkup(<SheetSectionCount count={3} />);
expect(html).toContain("3");
expect(html).toContain('data-slot="sheet-section-count"');
});
it("renders nothing for zero or negative counts", () => {
expect(renderToStaticMarkup(<SheetSectionCount count={0} />)).toBe("");
expect(renderToStaticMarkup(<SheetSectionCount count={-1} />)).toBe("");
});
});
+107
View File
@@ -0,0 +1,107 @@
"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* v1.17.0 — `SheetSection` is the collapsible disclosure primitive that
* powers the sectioned single-sheet add flows (mood, cycle). Each section
* carries a title, an optional leading icon, and a `summary` slot that
* communicates what is selected inside it while collapsed (e.g. a count
* badge) — so a closed section never hides its contents silently.
*
* Built on Radix Collapsible. The chevron rotates and the content height
* animates open/closed via the `tw-animate-css` `collapsible-down/up`
* keyframes; both motions are suppressed under `prefers-reduced-motion`.
* Matches the shadcn new-york + zinc token language used across the app.
*/
interface SheetSectionProps {
/** Section heading shown on the always-visible trigger row. */
title: string;
/**
* A node rendered on the trigger row's trailing edge — typically a count
* badge that reports what is selected inside while the section is
* collapsed. Stays visible whether the section is open or closed.
*/
summary?: React.ReactNode;
/** Optional leading icon (a Lucide component or any node). */
icon?: React.ReactNode;
/** Whether the section starts expanded. Defaults to collapsed. */
defaultOpen?: boolean;
/** Optional className merged onto the section wrapper. */
className?: string;
children: React.ReactNode;
}
export function SheetSection({
title,
summary,
icon,
defaultOpen = false,
className,
children,
}: SheetSectionProps) {
return (
<CollapsiblePrimitive.Root
defaultOpen={defaultOpen}
data-slot="sheet-section"
className={cn("border-border/60 border-t pt-3", className)}
>
<CollapsiblePrimitive.Trigger
data-slot="sheet-section-trigger"
className="group focus-visible:ring-ring/50 -mx-1 flex w-[calc(100%+0.5rem)] items-center gap-2 rounded-md px-1 py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
{icon ? (
<span
className="text-muted-foreground flex h-4 w-4 shrink-0 items-center justify-center [&>svg]:h-4 [&>svg]:w-4"
aria-hidden="true"
>
{icon}
</span>
) : null}
<span className="text-foreground flex-1 text-sm font-medium">
{title}
</span>
{summary ? (
<span
data-slot="sheet-section-summary"
className="flex items-center"
>
{summary}
</span>
) : null}
<ChevronDown
aria-hidden="true"
className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180 motion-reduce:transition-none"
/>
</CollapsiblePrimitive.Trigger>
<CollapsiblePrimitive.Content
data-slot="sheet-section-content"
className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none"
>
<div className="space-y-3 pt-3 pb-1">{children}</div>
</CollapsiblePrimitive.Content>
</CollapsiblePrimitive.Root>
);
}
/**
* A compact "(N)" count badge for a `SheetSection` summary slot. Renders
* nothing when `count <= 0`, so a section with no selections shows a bare
* chevron rather than a "(0)".
*/
export function SheetSectionCount({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span
data-slot="sheet-section-count"
className="bg-primary/15 text-primary inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
>
{count}
</span>
);
}