Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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> {}
|
||||
@@ -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.
|
||||
@@ -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: {},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
collapsibleContentVariants,
|
||||
collapsibleTriggerVariants,
|
||||
collapsibleVariants,
|
||||
} from "./collapsible.js";
|
||||
export type {
|
||||
CollapsibleContentProps,
|
||||
CollapsibleProps,
|
||||
CollapsibleTriggerProps,
|
||||
} from "./collapsible.types.js";
|
||||
@@ -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": {}
|
||||
}
|
||||
Reference in New Issue
Block a user