Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
# Collapsible
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - collapsible.variants — `collapsibleVariants`, `coll…
- [collapsible.variants](./collapsible.variants.md) — `collapsibleVariants`, `collapsibleTriggerVariants`, and `collapsibleContentVariants` are the cva (class-variance-autho…
- collapsible.variants — `collapsibleVariants`, `coll…
- [collapsible.variants](./collapsible.variants.md) — `collapsibleVariants`, `collapsibleTriggerVariants`, and `collapsibleContentVariants` are the cva (class-variance-autho…
- collapsible.variants — `collapsibleVariants`, `coll…
- [collapsible.variants](./collapsible.variants.md) — `collapsibleVariants`, `collapsibleTriggerVariants`, and `collapsibleContentVariants` are the cva (class-variance-autho…
- collapsible.variants — `collapsibleVariants`, `coll…
- [collapsible.variants](./collapsible.variants.md) — `collapsibleVariants`, `collapsibleTriggerVariants`, and `collapsibleContentVariants` are the cva (class-variance-autho…
- [collapsible.variants](./collapsible.variants.md) — `collapsibleVariants`, `collapsibleTriggerVariants`, and `collapsibleContentVariants` are the cva (class-variance-autho…
## Purpose
`<Collapsible>`, `<CollapsibleTrigger>`, and `<CollapsibleContent>` are the chassis single-section disclosure primitives built on `@radix-ui/react-collapsible`. Radix supplies the WAI-ARIA disclosure semantics: the trigger renders a `<button>` with `aria-expanded` reflecting the open state and `aria-controls` referencing the content panel; the content carries `role="region"` (when given an accessible name) and unmounts while closed. The chassis layers a small cva recipe for structural alignment plus a `data-[state=open]:rotate-180` hook so a sibling chevron icon flips when the panel opens.
Use this primitive for ONE open/closed pair (e.g. an "advanced settings" disclosure or an FAQ item that does not belong to a sibling-managed group). For sibling panels with mutual-exclusion (`type="single"`) or multi-open (`type="multiple"`) semantics, use `<Accordion>` instead.
## Features
- WAI-ARIA disclosure semantics auto-managed by Radix (trigger `aria-expanded` / `aria-controls`, content `role="region"` when titled).
- Keyboard activation: Space and Enter both toggle the trigger.
- Controlled (`open` / `onOpenChange`) and uncontrolled (`defaultOpen`) modes.
- `disabled` prop short-circuits the toggle while keeping the trigger focusable for parity with `<button disabled>`.
- Smooth open/close animation via `--radix-collapsible-content-height` (`animate-collapsible-down/up` keyframes); collapses to none under `prefers-reduced-motion: reduce`.
- Theme-driven cva overrides for `Collapsible`, `CollapsibleTrigger`, and `CollapsibleContent`.
- All sub-components forward refs to their underlying Radix elements.
- Trigger cva carries focus-visible ring + hover-bg recipes so the disclosure looks like the chassis Button family without being one.
## Usage: Basic Example
```tsx
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@suaveplan/react-ui";
export function AdvancedOptions() {
return (
<Collapsible>
<CollapsibleTrigger>Advanced options</CollapsibleTrigger>
<CollapsibleContent>
<p>Hidden content revealed when toggled.</p>
</CollapsibleContent>
</Collapsible>
);
}
```
## Usage: Advanced Example
```tsx
import {
Button,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@suaveplan/react-ui";
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
export function FilterPanel({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
{open ? "Hide filters" : "Show filters"}
<ChevronDownIcon
data-icon="chevron"
className="size-4 transition-transform data-[state=open]:rotate-180"
aria-hidden
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 rounded-md border p-3">
{children}
</CollapsibleContent>
</Collapsible>
);
}
```
## API Reference
### `<Collapsible>`
Renders the Radix Root that owns the open/closed state machine.
| Prop | Type | Default | Description |
|---|---|---|---|
| `open` | `boolean` | _uncontrolled_ | Controlled open state. |
| `defaultOpen` | `boolean` | `false` | Initial open state for uncontrolled use. |
| `onOpenChange` | `(open: boolean) => void` | _none_ | Fires on every open / close transition. |
| `disabled` | `boolean` | `false` | When true, the trigger no longer toggles state. |
### `<CollapsibleTrigger>`
Wraps the Radix Trigger; renders a `<button>` (or polymorphically the child via `asChild`).
| Prop | Type | Default | Description |
|---|---|---|---|
| `asChild` | `boolean` | `false` | Merge the chassis trigger props onto the rendered child instead of wrapping in a `<button>`. Useful for binding to a `<Button>`. |
### `<CollapsibleContent>`
Wraps the Radix Content. Children unmount while closed (Radix default).
## Implementation Notes
- The chassis NEVER assigns a numeric `z-index` (per the shadcn skill rule); the disclosure stays in flow and stacking is implicit.
- The animation classes (`animate-collapsible-down/up`) require the chassis Tailwind preset to register the corresponding keyframes (shipped in `@suaveplan/react-ui/styles/global.css`).
- Radix unmounts content when closed by default; render a wrapper around the trigger if you need persistent layout (e.g. a `<section>` element that always appears in the DOM).
- The trigger cva matches the chassis `<Button variant="ghost">` recipe so a disclosure used alone looks like a Button without being one. Use `asChild` to literally render a `<Button>` when consistency with the surrounding button cluster matters.
- `disabled` flows through to the underlying `<button>` so screen-readers announce the disabled state correctly via the native attribute.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom";
import * as Stories from "./collapsible.stories.js";
const STORY_KEYS = [
"Default",
"Playground",
"Variants",
"States",
"WithI18n",
"WithRTL",
"WithTheme",
"A11y",
"Composition",
] as const;
type StoryKey = (typeof STORY_KEYS)[number];
interface StoryRecord {
readonly args?: Record<string, unknown>;
readonly render?: (args: Record<string, unknown>) => React.ReactNode;
}
describe("collapsible.stories", () => {
it("exports every Layout story", { timeout: 5000 }, () => {
for (const key of STORY_KEYS) {
expect(
(Stories as unknown as Record<string, unknown>)[key],
`missing story export: ${key}`
).toBeDefined();
}
});
for (const key of STORY_KEYS) {
it(`renders the ${key} story without throwing`, {
timeout: 10000,
}, () => {
const story = (Stories as unknown as Record<StoryKey, StoryRecord>)[
key
];
expect(story).toBeDefined();
const render = story.render;
expect(typeof render).toBe("function");
if (render === undefined) return;
const tree = render(story.args ?? {});
const ui = (
<div data-testid="story-host">{tree as React.ReactNode}</div>
);
const { container } = renderComponent(ui);
expect(
container.querySelector("[data-testid='story-host']")
).not.toBeNull();
});
}
});

View File

@@ -0,0 +1,366 @@
/**
* Storybook stories for `<Collapsible>`.
*
* `@storybook/react` is NOT a dep of `@suaveplan/react-ui`; story-meta
* shapes are inlined.
*/
import {
createI18n as createI18nNarrow,
type I18nInstance,
} from "@suaveplan/i18n/plugin";
import { TranslationProvider } from "@suaveplan/react-i18n";
import { ChevronDownIcon } from "lucide-react";
import type { ReactElement, ReactNode } from "react";
import type { SupportedLocale } from "../../locales/locales.types.js";
import type { ThemePlugin } from "../../theme/theme.types.js";
import { ThemeProvider } from "../../theme/theme-provider/theme-provider.js";
import { darkTheme } from "../../theme/themes/dark.js";
import { highContrastTheme } from "../../theme/themes/high-contrast.js";
import { lightTheme } from "../../theme/themes/light.js";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./collapsible.js";
import type { CollapsibleProps } from "./collapsible.types.js";
function createI18n(
options: Parameters<typeof createI18nNarrow>[0]
): I18nInstance {
return createI18nNarrow(options) as unknown as I18nInstance;
}
interface Meta<T> {
readonly component: T;
readonly title: string;
readonly tags?: readonly string[];
readonly parameters?: Record<string, unknown>;
readonly argTypes?: Record<string, unknown>;
}
interface StoryObj<TArgs> {
readonly args?: Partial<TArgs>;
readonly render?: (args: Partial<TArgs>) => ReactNode;
readonly name?: string;
readonly parameters?: Record<string, unknown>;
}
const meta: Meta<unknown> = {
component: undefined as unknown as never,
title: "react-ui/Layout/Collapsible",
tags: ["autodocs"],
argTypes: {
defaultOpen: { control: { type: "boolean" } },
disabled: { control: { type: "boolean" } },
},
};
export default meta;
type Story = StoryObj<CollapsibleProps>;
interface ProviderShellProps {
readonly locale: SupportedLocale;
readonly themes: ReadonlyArray<ThemePlugin>;
readonly defaultTheme: string;
readonly children: ReactNode;
}
function ProviderShellSync(props: ProviderShellProps): ReactElement {
const i18n = createI18n({
defaultLocale: props.locale,
fallbackLocale: "en-GB",
});
return (
<TranslationProvider i18n={i18n}>
<ThemeProvider
themes={props.themes}
defaultTheme={props.defaultTheme}
>
{props.children}
</ThemeProvider>
</TranslationProvider>
);
}
function Chevron(): ReactElement {
return (
<ChevronDownIcon
className="size-4 transition-transform data-[state=open]:rotate-180"
data-icon="chevron"
aria-hidden
/>
);
}
export const Default: Story = {
render: () => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<div style={{ width: 320 }}>
<Collapsible>
<CollapsibleTrigger>
<span>Advanced options</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">Hidden content body.</div>
</CollapsibleContent>
</Collapsible>
</div>
</ProviderShellSync>
),
};
export const Playground: Story = {
render: (args) => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<div style={{ width: 320 }}>
<Collapsible {...args}>
<CollapsibleTrigger>
<span>Toggle me</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">Body content.</div>
</CollapsibleContent>
</Collapsible>
</div>
</ProviderShellSync>
),
args: { defaultOpen: false, disabled: false },
};
export const Variants: Story = {
render: () => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<div className="flex flex-col gap-3" style={{ width: 320 }}>
<Collapsible defaultOpen>
<CollapsibleTrigger>
<span>Open by default</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">Visible from mount.</div>
</CollapsibleContent>
</Collapsible>
<Collapsible>
<CollapsibleTrigger>
<span>Closed by default</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">Hidden until clicked.</div>
</CollapsibleContent>
</Collapsible>
</div>
</ProviderShellSync>
),
};
export const States: Story = {
render: () => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<div className="flex flex-col gap-3" style={{ width: 320 }}>
<Collapsible disabled>
<CollapsibleTrigger>
<span>Disabled</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">Cannot toggle.</div>
</CollapsibleContent>
</Collapsible>
<Collapsible defaultOpen>
<CollapsibleTrigger>
<span>Open</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">Open state.</div>
</CollapsibleContent>
</Collapsible>
</div>
</ProviderShellSync>
),
};
const LOCALES: ReadonlyArray<SupportedLocale> = [
"en-GB",
"zh-CN",
"ar-AE",
"cy-GB",
"ru-RU",
];
export const WithI18n: Story = {
render: () => (
<div className="flex flex-col gap-3" style={{ width: 320 }}>
{LOCALES.map((locale) => (
<ProviderShellSync
key={locale}
locale={locale}
themes={[lightTheme]}
defaultTheme="light"
>
<div dir={locale === "ar-AE" ? "rtl" : "ltr"}>
<Collapsible>
<CollapsibleTrigger>
<span>{locale}</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">{locale}</div>
</CollapsibleContent>
</Collapsible>
</div>
</ProviderShellSync>
))}
</div>
),
};
export const WithRTL: Story = {
render: () => (
<div dir="rtl" style={{ width: 320 }}>
<ProviderShellSync
locale="ar-AE"
themes={[lightTheme]}
defaultTheme="light"
>
<Collapsible>
<CollapsibleTrigger>
<span>توسيع</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">المحتوى</div>
</CollapsibleContent>
</Collapsible>
</ProviderShellSync>
</div>
),
};
const THEMES: ReadonlyArray<{
readonly label: string;
readonly theme: ThemePlugin;
}> = [
{ label: "light", theme: lightTheme },
{ label: "dark", theme: darkTheme },
{ label: "highContrast", theme: highContrastTheme },
];
export const WithTheme: Story = {
render: () => (
<div className="flex flex-col gap-3" style={{ width: 320 }}>
{THEMES.map(({ label, theme }) => (
<ProviderShellSync
key={label}
locale="en-GB"
themes={[theme]}
defaultTheme={theme.name}
>
<Collapsible>
<CollapsibleTrigger>
<span>{label}</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">Theme: {label}</div>
</CollapsibleContent>
</Collapsible>
</ProviderShellSync>
))}
</div>
),
};
export const A11y: Story = {
parameters: {
a11y: {
element: "#storybook-root",
config: {
rules: [{ id: "color-contrast", enabled: true }],
},
},
},
render: () => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<div style={{ width: 320 }}>
<Collapsible>
<CollapsibleTrigger aria-controls="a11y-content">
<span>Frequently asked questions</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent id="a11y-content">
<div className="px-3 py-2">
All disclosures expose `aria-expanded` and
`aria-controls` automatically.
</div>
</CollapsibleContent>
</Collapsible>
</div>
</ProviderShellSync>
),
};
export const Composition: Story = {
name: "Composition (filter sidebar)",
render: () => (
<ProviderShellSync
locale="en-GB"
themes={[lightTheme]}
defaultTheme="light"
>
<div className="flex flex-col gap-2" style={{ width: 320 }}>
<Collapsible>
<CollapsibleTrigger>
<span>Status</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">Active / Archived</div>
</CollapsibleContent>
</Collapsible>
<Collapsible>
<CollapsibleTrigger>
<span>Owner</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">Me / Team / Anyone</div>
</CollapsibleContent>
</Collapsible>
<Collapsible>
<CollapsibleTrigger>
<span>Date range</span>
<Chevron />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 py-2">7 / 30 / 90 days</div>
</CollapsibleContent>
</Collapsible>
</div>
</ProviderShellSync>
),
};

View File

@@ -0,0 +1,203 @@
import { TranslationProvider } from "@suaveplan/react-i18n";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom";
import { createRef } from "react";
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
import { ThemeProvider } from "../../theme/theme-provider/theme-provider.js";
import { lightTheme } from "../../theme/themes/light.js";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./collapsible.js";
import {
collapsibleContentVariants,
collapsibleTriggerVariants,
collapsibleVariants,
} from "./collapsible.variants.js";
async function withProviders(
node: React.ReactElement
): Promise<React.ReactElement> {
const i18n = await createTestI18n({ locale: "en-GB" });
return (
<TranslationProvider i18n={i18n}>
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
{node}
</ThemeProvider>
</TranslationProvider>
);
}
describe("<Collapsible>", () => {
it("renders the trigger and is closed by default", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Collapsible>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Body</CollapsibleContent>
</Collapsible>
);
const { getByText, queryByText } = renderComponent(tree);
expect(getByText("Toggle")).not.toBeNull();
expect(queryByText("Body")).toBeNull();
});
it("opens when the trigger is clicked", { timeout: 5000 }, async () => {
const tree = await withProviders(
<Collapsible>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Body content</CollapsibleContent>
</Collapsible>
);
const { getByText, findByText, user } = renderComponent(tree);
await user.click(getByText("Toggle"));
const body = await findByText("Body content");
expect(body).not.toBeNull();
});
it("closes when the trigger is clicked twice", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Collapsible defaultOpen>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Body</CollapsibleContent>
</Collapsible>
);
const { getByText, queryByText, user } = renderComponent(tree);
expect(queryByText("Body")).not.toBeNull();
await user.click(getByText("Toggle"));
// Radix unmounts content when closed.
expect(queryByText("Body")).toBeNull();
});
it("toggles via Space key on the trigger", { timeout: 5000 }, async () => {
const tree = await withProviders(
<Collapsible>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Keyboard body</CollapsibleContent>
</Collapsible>
);
const { getByText, findByText, user } = renderComponent(tree);
const trigger = getByText("Toggle") as HTMLButtonElement;
trigger.focus();
await user.keyboard(" ");
expect(await findByText("Keyboard body")).not.toBeNull();
});
it("toggles via Enter key on the trigger", { timeout: 5000 }, async () => {
const tree = await withProviders(
<Collapsible>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Enter body</CollapsibleContent>
</Collapsible>
);
const { getByText, findByText, user } = renderComponent(tree);
const trigger = getByText("Toggle") as HTMLButtonElement;
trigger.focus();
await user.keyboard("{Enter}");
expect(await findByText("Enter body")).not.toBeNull();
});
it("supports controlled open state", { timeout: 5000 }, async () => {
const tree = await withProviders(
<Collapsible open onOpenChange={() => undefined}>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Controlled body</CollapsibleContent>
</Collapsible>
);
const { findByText } = renderComponent(tree);
expect(await findByText("Controlled body")).not.toBeNull();
});
it("fires onOpenChange when toggled", { timeout: 5000 }, async () => {
let lastOpen: boolean | undefined;
const tree = await withProviders(
<Collapsible
onOpenChange={(open) => {
lastOpen = open;
}}
>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Body</CollapsibleContent>
</Collapsible>
);
const { getByText, user } = renderComponent(tree);
await user.click(getByText("Toggle"));
expect(lastOpen).toBe(true);
});
it("respects `disabled`", { timeout: 5000 }, async () => {
const tree = await withProviders(
<Collapsible disabled>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Should stay hidden</CollapsibleContent>
</Collapsible>
);
const { getByText, queryByText, user } = renderComponent(tree);
await user.click(getByText("Toggle"));
expect(queryByText("Should stay hidden")).toBeNull();
});
it("forwards refs on every sub-component", { timeout: 5000 }, async () => {
const rootRef = createRef<HTMLDivElement>();
const triggerRef = createRef<HTMLButtonElement>();
const tree = await withProviders(
<Collapsible defaultOpen ref={rootRef}>
<CollapsibleTrigger ref={triggerRef}>Toggle</CollapsibleTrigger>
<CollapsibleContent>Body</CollapsibleContent>
</Collapsible>
);
renderComponent(tree);
expect(rootRef.current).not.toBeNull();
expect(triggerRef.current).not.toBeNull();
});
it("renders aria-expanded on the trigger reflecting the state", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Collapsible>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Body</CollapsibleContent>
</Collapsible>
);
const { getByText, user } = renderComponent(tree);
const trigger = getByText("Toggle") as HTMLButtonElement;
expect(trigger.getAttribute("aria-expanded")).toBe("false");
await user.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
});
it("merges custom className on every slot", {
timeout: 5000,
}, async () => {
const tree = await withProviders(
<Collapsible defaultOpen className="root-extra" data-testid="root">
<CollapsibleTrigger className="trigger-extra">
Toggle
</CollapsibleTrigger>
<CollapsibleContent
className="content-extra"
data-testid="content"
>
Body
</CollapsibleContent>
</Collapsible>
);
const { getByText, getByTestId } = renderComponent(tree);
expect(getByTestId("root").className).toContain("root-extra");
expect((getByText("Toggle") as HTMLButtonElement).className).toContain(
"trigger-extra"
);
expect(getByTestId("content").className).toContain("content-extra");
});
it("exposes every collapsible cva resolver", { timeout: 5000 }, () => {
expect(collapsibleVariants().length).toBeGreaterThan(0);
expect(collapsibleTriggerVariants().length).toBeGreaterThan(0);
expect(collapsibleContentVariants().length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,114 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { type ComponentRef, forwardRef, type ReactElement } from "react";
import { useThemedVariants } from "../../theme/use-themed-variants/use-themed-variants.js";
import { cn } from "../../utils/cn/cn.js";
import type {
CollapsibleContentProps,
CollapsibleProps,
CollapsibleTriggerProps,
} from "./collapsible.types.js";
import {
collapsibleContentVariants,
collapsibleTriggerVariants,
collapsibleVariants,
} from "./collapsible.variants.js";
/**
* `<Collapsible>` — chassis single-section expand/collapse primitive
* built on `@radix-ui/react-collapsible`.
*
* Radix supplies the WAI-ARIA disclosure semantics: the trigger is a
* `<button>` with `aria-expanded` reflecting open state and
* `aria-controls` pointing at the content panel; the content carries
* `role="region"` (when given an accessible name) and is unmounted
* while closed by default. The chassis layers a tiny cva recipe for
* structural alignment plus chevron-rotation hooks driven by Radix's
* `data-state="open" | "closed"` attribute.
*
* Use this for ONE open/closed pair (e.g. an "advanced settings"
* panel). For sibling panels with mutual-exclusion or multi-open
* semantics, use `<Accordion>` instead.
*/
export const Collapsible = forwardRef<
ComponentRef<typeof CollapsiblePrimitive.Root>,
CollapsibleProps
>(function Collapsible(props, ref): ReactElement {
const { className, ...rest } = props;
const variants = useThemedVariants("Collapsible", collapsibleVariants);
return (
<CollapsiblePrimitive.Root
ref={ref}
className={cn(variants(), className)}
{...rest}
/>
);
});
Collapsible.displayName = "Collapsible";
/**
* `<CollapsibleTrigger>` — toggles the open/closed state when activated.
*
* Wraps the Radix Trigger which renders a `<button>` by default. With
* `asChild`, the child is merged into the Radix-provided trigger so a
* custom element (e.g. a `<Button>`) becomes the toggle. The chassis
* cva carries focus / hover styling and a `data-[state=open]:rotate-180`
* hook so a sibling `<svg data-icon="chevron">` rotates when open.
*
* Forwards refs to the rendered `<button>`.
*/
export const CollapsibleTrigger = forwardRef<
ComponentRef<typeof CollapsiblePrimitive.Trigger>,
CollapsibleTriggerProps
>(function CollapsibleTrigger(props, ref): ReactElement {
const { className, ...rest } = props;
const variants = useThemedVariants(
"CollapsibleTrigger",
collapsibleTriggerVariants
);
return (
<CollapsiblePrimitive.Trigger
ref={ref}
className={cn(variants(), className)}
{...rest}
/>
);
});
CollapsibleTrigger.displayName = "CollapsibleTrigger";
/**
* `<CollapsibleContent>` — the panel that mounts when the disclosure is
* open. Radix unmounts children when closed and animates the
* open/close cycle via `--radix-collapsible-content-height`. The
* chassis cva attaches the `animate-collapsible-down/up` keyframes
* registered in the chassis Tailwind preset and `motion-reduce:animate-none`
* for `prefers-reduced-motion: reduce`.
*
* Forwards refs to the rendered `<div>`.
*/
export const CollapsibleContent = forwardRef<
ComponentRef<typeof CollapsiblePrimitive.Content>,
CollapsibleContentProps
>(function CollapsibleContent(props, ref): ReactElement {
const { className, ...rest } = props;
const variants = useThemedVariants(
"CollapsibleContent",
collapsibleContentVariants
);
return (
<CollapsiblePrimitive.Content
ref={ref}
className={cn(variants(), className)}
{...rest}
/>
);
});
CollapsibleContent.displayName = "CollapsibleContent";
export {
collapsibleContentVariants,
collapsibleTriggerVariants,
collapsibleVariants,
};

View File

@@ -0,0 +1,45 @@
import type * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import type { ComponentPropsWithoutRef } from "react";
import type {
collapsibleContentVariants,
collapsibleTriggerVariants,
collapsibleVariants,
} from "./collapsible.variants.js";
/**
* Module augmentation registering every Collapsible cva slot into
* `ThemableVariants` so `useThemedVariants` is type-narrowed and a
* `ThemePlugin` may declare overrides.
*/
declare module "../../theme/theme.types.js" {
interface ThemableVariants {
readonly Collapsible: typeof collapsibleVariants;
readonly CollapsibleTrigger: typeof collapsibleTriggerVariants;
readonly CollapsibleContent: typeof collapsibleContentVariants;
}
}
/**
* Public props for `<Collapsible>` — the Radix Root that owns the
* open/close state machine. Renders a `<div>`; pairs
* `<CollapsibleTrigger>` with `<CollapsibleContent>`.
*/
export interface CollapsibleProps
extends ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Root> {}
/**
* Public props for `<CollapsibleTrigger>` — wraps the Radix Trigger
* which renders a `<button>` (or polymorphically the child via
* `asChild`). Carries the chassis chevron rotation classes that flip
* the icon when the trigger is in the open state.
*/
export interface CollapsibleTriggerProps
extends ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Trigger> {}
/**
* Public props for `<CollapsibleContent>` — wraps the Radix Content
* which animates open/close via the `--radix-collapsible-content-height`
* CSS variable. Children are unmounted when closed (Radix default).
*/
export interface CollapsibleContentProps
extends ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content> {}

View File

@@ -0,0 +1,70 @@
# Collapsible Variants (cva config)
## Purpose
`collapsibleVariants`, `collapsibleTriggerVariants`, and `collapsibleContentVariants` are the cva (class-variance-authority) configurations powering every `<Collapsible>` slot. They are split from `collapsible.tsx` into their own module so an ejected source copy under `npx shadcn@latest add @suaveplan/collapsible` keeps the cva configurations in a dedicated file. Theme overrides flow through `useThemedVariants("Collapsible<Slot>")`.
The root cva is intentionally minimal (`flex flex-col`) so the disclosure groups its trigger and content as a column. The trigger cva matches the chassis Button-ghost recipe so a stand-alone disclosure looks consistent with surrounding buttons. The content cva carries the open/close animation hooks driven by Radix's `data-state` attribute.
## Features
- Root: `flex flex-col` so the trigger and content stack vertically by default.
- Trigger: full-width flex row with `items-center justify-between gap-2`, hover-bg, focus-visible ring, disabled-opacity, and `motion-reduce:transition-none`.
- Content: `overflow-hidden text-sm` plus `data-[state=open]:animate-collapsible-down` / `data-[state=closed]:animate-collapsible-up` so Radix's `--radix-collapsible-content-height` CSS variable animates cleanly. `motion-reduce:animate-none` collapses the transition under `prefers-reduced-motion: reduce`.
- NEVER carries a numeric `z-index` (the disclosure stays in flow).
## Usage: Basic Example
```ts
import {
collapsibleContentVariants,
collapsibleTriggerVariants,
collapsibleVariants,
} from "@suaveplan/react-ui";
const rootClassName = collapsibleVariants();
const triggerClassName = collapsibleTriggerVariants();
const contentClassName = collapsibleContentVariants();
```
## Usage: Advanced Example
```ts
import { collapsibleTriggerVariants, cn } from "@suaveplan/react-ui";
export function CustomTrigger() {
return (
<button
type="button"
aria-expanded
className={cn(
collapsibleTriggerVariants(),
"border border-primary text-primary"
)}
>
Toggle
</button>
);
}
```
## API Reference
### `collapsibleVariants(): string`
Returns the root grouping classes; takes no arguments.
### `collapsibleTriggerVariants(): string`
Returns the trigger button classes; takes no arguments.
### `collapsibleContentVariants(): string`
Returns the content panel classes; takes no arguments.
## Implementation Notes
- The trigger uses `focus-visible:ring-2 focus-visible:ring-ring` instead of `focus:ring-2` so mouse-only consumers do not see a focus ring on click — this matches the chassis Button family.
- The content animation requires the `animate-collapsible-down/up` keyframes registered in the chassis Tailwind preset; consumers ejecting the source must keep those keyframes in their own Tailwind config (or register them via `tailwindcss-animate`'s `accordion-down/up` recipe under matching names).
- Per the shadcn skill rule, the cva NEVER assigns a numeric `z-index`. The disclosure renders inline in the document flow.
- The trigger's `gap-2` class assumes a single icon child (the chevron) sits next to the label; consumers can override via `cn(...)` when an alternate layout is required.

View File

@@ -0,0 +1,63 @@
import { defineVariants } from "../../variants/variants.js";
/**
* `collapsibleVariants` — base cva for the `<Collapsible>` root.
*
* The Radix Root renders a `<div>` and is intentionally unstyled. The
* chassis attaches a tiny grouping recipe so the root sits as a flex
* column and consumer styling stays predictable.
*/
export const collapsibleVariants = defineVariants(
"Collapsible",
"flex flex-col",
{
variants: {},
defaultVariants: {},
}
);
/**
* `collapsibleTriggerVariants` — base cva for `<CollapsibleTrigger>`.
*
* The trigger renders a `<button>`. The chassis attaches a flex layout
* so a chevron icon can sit alongside the trigger label, plus
* `data-[state=open]:rotate-180` rotation hooks. Accessible focus ring
* uses `focus-visible:ring-2` + `focus-visible:ring-ring`.
*/
export const collapsibleTriggerVariants = defineVariants(
"CollapsibleTrigger",
[
"flex w-full items-center justify-between gap-2",
"rounded-md px-3 py-2 text-sm font-medium",
"transition-colors hover:bg-accent",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
"motion-reduce:transition-none",
].join(" "),
{
variants: {},
defaultVariants: {},
}
);
/**
* `collapsibleContentVariants` — base cva for `<CollapsibleContent>`.
*
* Radix uses `data-state="open"` / `"closed"` to drive the open/close
* cycle. The cva carries the `data-[state=open]:animate-in /
* data-[state=closed]:animate-out` recipe and `overflow-hidden` so the
* `--radix-collapsible-content-height` CSS var animates cleanly.
*/
export const collapsibleContentVariants = defineVariants(
"CollapsibleContent",
[
"overflow-hidden text-sm",
"data-[state=closed]:animate-collapsible-up",
"data-[state=open]:animate-collapsible-down",
"motion-reduce:animate-none",
].join(" "),
{
variants: {},
defaultVariants: {},
}
);

View File

@@ -0,0 +1,13 @@
export {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
collapsibleContentVariants,
collapsibleTriggerVariants,
collapsibleVariants,
} from "./collapsible.js";
export type {
CollapsibleContentProps,
CollapsibleProps,
CollapsibleTriggerProps,
} from "./collapsible.types.js";

View File

@@ -0,0 +1,23 @@
{
"componentDirName": "collapsible",
"exports": {
"values": [
"Collapsible",
"CollapsibleContent",
"CollapsibleTrigger",
"collapsibleContentVariants",
"collapsibleTriggerVariants",
"collapsibleVariants"
],
"types": [
"CollapsibleContentProps",
"CollapsibleProps",
"CollapsibleTriggerProps"
]
},
"subpathExports": ["./components/collapsible/collapsible"],
"catalogDeps": {
"@radix-ui/react-collapsible": "^1.1.12"
},
"translationKeys": {}
}