mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(ui): add collapsible SheetSection with summary badge slot
This commit is contained in:
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("");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user