diff --git a/src/components/ui/__tests__/sheet-section.test.tsx b/src/components/ui/__tests__/sheet-section.test.tsx new file mode 100644 index 000000000..b3f719c71 --- /dev/null +++ b/src/components/ui/__tests__/sheet-section.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { SheetSection, SheetSectionCount } from "../sheet-section"; + +describe("", () => { + it("renders the title and summary slot on the trigger row", () => { + const html = renderToStaticMarkup( + summary-badge}> +

section body

+
, + ); + 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( + +

section body

+
, + ); + expect(html).toContain("section body"); + }); + + it("starts collapsed by default and open when defaultOpen", () => { + const closed = renderToStaticMarkup( + +

x

+
, + ); + const open = renderToStaticMarkup( + +

x

+
, + ); + expect(closed).toContain('data-state="closed"'); + expect(open).toContain('data-state="open"'); + }); + + it("hides the leading icon from assistive tech", () => { + const html = renderToStaticMarkup( + }> +

x

+
, + ); + expect(html).toMatch(/aria-hidden="true"[^>]*>[^<]* { + const html = renderToStaticMarkup( + +

y

+
, + ); + expect(html).toContain("motion-reduce:animate-none"); + expect(html).toContain("motion-reduce:transition-none"); + }); +}); + +describe("", () => { + it("renders the count when positive", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("3"); + expect(html).toContain('data-slot="sheet-section-count"'); + }); + + it("renders nothing for zero or negative counts", () => { + expect(renderToStaticMarkup()).toBe(""); + expect(renderToStaticMarkup()).toBe(""); + }); +}); diff --git a/src/components/ui/sheet-section.tsx b/src/components/ui/sheet-section.tsx new file mode 100644 index 000000000..1eadf6028 --- /dev/null +++ b/src/components/ui/sheet-section.tsx @@ -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 ( + + + {icon ? ( + + ) : null} + + {title} + + {summary ? ( + + {summary} + + ) : null} + + +
{children}
+
+
+ ); +} + +/** + * 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 ( + + {count} + + ); +}