Initial Commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export { Toggle, toggleVariants } from "./toggle.js";
|
||||
export type { ToggleProps, ToggleVariantProps } from "./toggle.types.js";
|
||||
145
packages/ui/react/react-ui/src/components/toggle/toggle.md
Normal file
145
packages/ui/react/react-ui/src/components/toggle/toggle.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Toggle
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - toggle.variants — `toggleVariants` is the cva (clas…
|
||||
- [toggle.variants](./toggle.variants.md) — `toggleVariants` is the cva (class-variance-authority) configuration powering every `<Toggle>` render. It is split from…
|
||||
- toggle.variants — `toggleVariants` is the cva (clas…
|
||||
|
||||
- [toggle.variants](./toggle.variants.md) — `toggleVariants` is the cva (class-variance-authority) configuration powering every `<Toggle>` render. It is split from…
|
||||
|
||||
- toggle.variants — `toggleVariants` is the cva (clas…
|
||||
|
||||
- [toggle.variants](./toggle.variants.md) — `toggleVariants` is the cva (class-variance-authority) configuration powering every `<Toggle>` render. It is split from…
|
||||
|
||||
- toggle.variants — `toggleVariants` is the cva (clas…
|
||||
|
||||
- [toggle.variants](./toggle.variants.md) — `toggleVariants` is the cva (class-variance-authority) configuration powering every `<Toggle>` render. It is split from…
|
||||
- [toggle.variants](./toggle.variants.md) — `toggleVariants` is the cva (class-variance-authority) configuration powering every `<Toggle>` render. It is split from…
|
||||
|
||||
## Purpose
|
||||
|
||||
`<Toggle>` is the chassis two-state toggle button built on `@radix-ui/react-toggle`. Radix supplies the a11y semantics (`aria-pressed="true|false"`, native `Space` / `Enter` activation, `disabled` propagation); the chassis layers theme-driven variant resolution and the `data-icon` slot so child icons inherit sizing without consumer boilerplate.
|
||||
|
||||
Per the shadcn rule, multi-option pickers (2–7 choices) compose `<ToggleGroup>` rather than looping over standalone `<Toggle>` with manual `pressed` state — `<ToggleGroup>` provides roving-focus semantics that a hand-rolled toggle row does not.
|
||||
|
||||
Refs are forwarded to the Radix Root which renders a styled `<button>`.
|
||||
|
||||
## Features
|
||||
|
||||
- Two visual variants (`default`, `outline`) covering the upstream shadcn surface.
|
||||
- Three sizes (`sm`, `default`, `lg`) for use in dense toolbars or roomy headers.
|
||||
- Controlled and uncontrolled use via Radix `pressed` / `defaultPressed` / `onPressedChange`.
|
||||
- `disabled` blocks pointer + keyboard input and decays opacity.
|
||||
- `data-state="on|off"` mirrored on the rendered button so consumer cva configs / CSS can react.
|
||||
- `data-icon` slot rules (`[&_svg]:pointer-events-none size-4 shrink-0`) so child icons inherit sizing.
|
||||
- Refs forwarded to the Radix Root.
|
||||
- `motion-reduce:transition-none` collapses transitions for `prefers-reduced-motion: reduce`.
|
||||
- Theme-driven variant overrides via `useThemedVariants("Toggle", toggleVariants)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { Toggle } from "@suaveplan/react-ui";
|
||||
import { BoldIcon } from "lucide-react";
|
||||
|
||||
export function BoldToggle() {
|
||||
return (
|
||||
<Toggle aria-label="Toggle bold">
|
||||
<BoldIcon />
|
||||
</Toggle>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { Toggle } from "@suaveplan/react-ui";
|
||||
import { BoldIcon, ItalicIcon, UnderlineIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface MarkState {
|
||||
readonly bold: boolean;
|
||||
readonly italic: boolean;
|
||||
readonly underline: boolean;
|
||||
}
|
||||
|
||||
export function MarkToolbar() {
|
||||
const [marks, setMarks] = useState<MarkState>({
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
});
|
||||
|
||||
const set = (key: keyof MarkState, value: boolean) =>
|
||||
setMarks((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 p-2 border rounded">
|
||||
<Toggle
|
||||
aria-label="Toggle bold"
|
||||
size="sm"
|
||||
pressed={marks.bold}
|
||||
onPressedChange={(v) => set("bold", v)}
|
||||
>
|
||||
<BoldIcon />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Toggle italic"
|
||||
size="sm"
|
||||
pressed={marks.italic}
|
||||
onPressedChange={(v) => set("italic", v)}
|
||||
>
|
||||
<ItalicIcon />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Toggle underline"
|
||||
size="sm"
|
||||
pressed={marks.underline}
|
||||
onPressedChange={(v) => set("underline", v)}
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</Toggle>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** The example above is appropriate when each toggle is independent. For mutually-exclusive option sets (text alignment, view density), use `<ToggleGroup>` instead — it provides roving-focus and unified `value` propagation.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `<Toggle>`
|
||||
|
||||
`forwardRef<ComponentRef<typeof TogglePrimitive.Root>, ToggleProps>` — wraps Radix Root, renders a `<button>` with `aria-pressed` reflecting the current state.
|
||||
|
||||
#### `ToggleProps`
|
||||
|
||||
Inherits every prop from Radix `TogglePrimitive.Root` except `asChild`. Adds:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `variant` | `"default" \| "outline"` | `"default"` | Visual variant. |
|
||||
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Padding / height. |
|
||||
|
||||
Common Radix props consumers will use:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `pressed` | `boolean` | _uncontrolled_ | Controlled state. |
|
||||
| `defaultPressed` | `boolean` | `false` | Initial state for uncontrolled use. |
|
||||
| `onPressedChange` | `(pressed: boolean) => void` | _none_ | Fires on state change. |
|
||||
| `disabled` | `boolean` | `false` | Blocks input + decays opacity. |
|
||||
|
||||
#### `toggleVariants`
|
||||
|
||||
The bare cva resolver. Exported so external consumers can re-use the variants directly.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The variants module is intentionally separate (`toggle.variants.ts`) so an ejected source copy keeps the cva config in a dedicated file.
|
||||
- For option sets (2–7 mutually-exclusive choices), use `<ToggleGroup>` — the chassis enforces this convention via the shadcn skill rule.
|
||||
- The `data-icon` slot rules let consumers drop a Lucide icon as a child without specifying icon size; the cva config handles `size-4 shrink-0` automatically.
|
||||
- Refs target the Radix Root (the `<button>` element) — consumers can `.focus()` the toggle programmatically through the forwarded ref.
|
||||
- Icon-only toggles MUST supply `aria-label` — the chassis does not auto-inject a fallback because the toggle's purpose is domain-specific.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom";
|
||||
import * as Stories from "./toggle.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("toggle.stories", () => {
|
||||
it("exports every Form 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,282 @@
|
||||
/**
|
||||
* Storybook stories for `<Toggle>`.
|
||||
*
|
||||
* `@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 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 { Toggle } from "./toggle.js";
|
||||
import type { ToggleProps } from "./toggle.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/Form/Toggle",
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["default", "outline"],
|
||||
},
|
||||
size: { control: { type: "select" }, options: ["sm", "default", "lg"] },
|
||||
disabled: { control: "boolean" },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ToggleProps>;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: (_args) => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<Toggle aria-label="bold">B</Toggle>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
args: { variant: "default", size: "default" },
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
render: (args) => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<Toggle aria-label="play" {...args}>
|
||||
{(args.children as React.ReactNode) ?? "Toggle"}
|
||||
</Toggle>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
args: { variant: "default", size: "default" },
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{(["default", "outline"] as const).map((v) => (
|
||||
<div key={v} className="flex items-center gap-2">
|
||||
{(["sm", "default", "lg"] as const).map((s) => (
|
||||
<Toggle
|
||||
key={`${v}-${s}`}
|
||||
variant={v}
|
||||
size={s}
|
||||
aria-label={`${v}-${s}`}
|
||||
>
|
||||
{`${v}/${s}`}
|
||||
</Toggle>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
export const States: Story = {
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle aria-label="off">Idle</Toggle>
|
||||
<Toggle aria-label="on" defaultPressed>
|
||||
Pressed
|
||||
</Toggle>
|
||||
<Toggle aria-label="dis" disabled>
|
||||
Disabled
|
||||
</Toggle>
|
||||
<Toggle aria-label="dis-on" disabled defaultPressed>
|
||||
Disabled pressed
|
||||
</Toggle>
|
||||
</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">
|
||||
{LOCALES.map((locale) => (
|
||||
<div
|
||||
key={locale}
|
||||
dir={locale === "ar-AE" ? "rtl" : "ltr"}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span style={{ width: 80 }}>{locale}</span>
|
||||
<ProviderShellSync
|
||||
locale={locale}
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<Toggle aria-label={`tg-${locale}`}>{locale}</Toggle>
|
||||
</ProviderShellSync>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithRTL: Story = {
|
||||
render: () => (
|
||||
<div dir="rtl">
|
||||
<ProviderShellSync
|
||||
locale="ar-AE"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<Toggle aria-label="rtl">RTL</Toggle>
|
||||
</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">
|
||||
{THEMES.map(({ label, theme }) => (
|
||||
<div key={label} className="flex items-center gap-2 p-3 border">
|
||||
<span style={{ width: 120 }}>{label}</span>
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[theme]}
|
||||
defaultTheme={theme.name}
|
||||
>
|
||||
<Toggle aria-label={label} defaultPressed>
|
||||
{label}
|
||||
</Toggle>
|
||||
</ProviderShellSync>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const A11y: Story = {
|
||||
parameters: {
|
||||
a11y: {
|
||||
element: "#storybook-root",
|
||||
config: {
|
||||
rules: [
|
||||
{ id: "color-contrast", enabled: true },
|
||||
{ id: "label", enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<Toggle aria-label="Toggle italic">I</Toggle>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
export const Composition: Story = {
|
||||
name: "Composition (toolbar)",
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<div className="flex items-center gap-1 p-2 border rounded">
|
||||
<Toggle aria-label="bold" size="sm">
|
||||
B
|
||||
</Toggle>
|
||||
<Toggle aria-label="italic" size="sm">
|
||||
I
|
||||
</Toggle>
|
||||
<Toggle aria-label="underline" size="sm">
|
||||
U
|
||||
</Toggle>
|
||||
</div>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
154
packages/ui/react/react-ui/src/components/toggle/toggle.test.tsx
Normal file
154
packages/ui/react/react-ui/src/components/toggle/toggle.test.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { TranslationProvider } from "@suaveplan/react-i18n";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom";
|
||||
import { createRef, useState } 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 { Toggle } from "./toggle.js";
|
||||
import { toggleVariants } from "./toggle.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("<Toggle>", () => {
|
||||
it("renders a button with default classes", { timeout: 5000 }, async () => {
|
||||
const tree = await withProviders(<Toggle aria-label="bold">B</Toggle>);
|
||||
const { getByRole } = renderComponent(tree);
|
||||
const t = getByRole("button");
|
||||
expect(t.tagName).toBe("BUTTON");
|
||||
expect(t.className).toContain("rounded-md");
|
||||
});
|
||||
|
||||
it("forwards refs to the radix root", { timeout: 5000 }, async () => {
|
||||
const ref = createRef<HTMLButtonElement>();
|
||||
const tree = await withProviders(
|
||||
<Toggle ref={ref} aria-label="ref">
|
||||
R
|
||||
</Toggle>
|
||||
);
|
||||
renderComponent(tree);
|
||||
expect(ref.current).not.toBeNull();
|
||||
expect(ref.current?.tagName).toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("toggles pressed state when clicked (controlled)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
function Controlled(): React.ReactElement {
|
||||
const [pressed, setPressed] = useState(false);
|
||||
return (
|
||||
<Toggle
|
||||
aria-label="t"
|
||||
pressed={pressed}
|
||||
onPressedChange={setPressed}
|
||||
>
|
||||
T
|
||||
</Toggle>
|
||||
);
|
||||
}
|
||||
const tree = await withProviders(<Controlled />);
|
||||
const { getByRole, user } = renderComponent(tree);
|
||||
const t = getByRole("button");
|
||||
expect(t.getAttribute("aria-pressed")).toBe("false");
|
||||
await user.click(t);
|
||||
expect(t.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("activates via Enter key", { timeout: 5000 }, async () => {
|
||||
let count = 0;
|
||||
const tree = await withProviders(
|
||||
<Toggle
|
||||
aria-label="enter"
|
||||
onPressedChange={() => {
|
||||
count += 1;
|
||||
}}
|
||||
>
|
||||
E
|
||||
</Toggle>
|
||||
);
|
||||
const { getByRole, user } = renderComponent(tree);
|
||||
const t = getByRole("button");
|
||||
t.focus();
|
||||
await user.keyboard("[Enter]");
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("respects the disabled prop", { timeout: 5000 }, async () => {
|
||||
let count = 0;
|
||||
const tree = await withProviders(
|
||||
<Toggle
|
||||
aria-label="dis"
|
||||
disabled
|
||||
onPressedChange={() => {
|
||||
count += 1;
|
||||
}}
|
||||
>
|
||||
D
|
||||
</Toggle>
|
||||
);
|
||||
const { getByRole, user } = renderComponent(tree);
|
||||
const t = getByRole("button");
|
||||
expect(t.hasAttribute("disabled")).toBe(true);
|
||||
await user.click(t);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("renders every variant × size combination from cva", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const variants = ["default", "outline"] as const;
|
||||
const sizes = ["sm", "default", "lg"] as const;
|
||||
for (const v of variants) {
|
||||
for (const s of sizes) {
|
||||
const cls = toggleVariants({ variant: v, size: s });
|
||||
expect(typeof cls).toBe("string");
|
||||
expect(cls.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the outline variant", { timeout: 5000 }, async () => {
|
||||
const tree = await withProviders(
|
||||
<Toggle aria-label="o" variant="outline">
|
||||
O
|
||||
</Toggle>
|
||||
);
|
||||
const { getByRole } = renderComponent(tree);
|
||||
const t = getByRole("button");
|
||||
expect(t.className).toContain("border");
|
||||
expect(t.className).toContain("border-input");
|
||||
});
|
||||
|
||||
it("merges custom className alongside variant classes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Toggle aria-label="cls" className="custom-extra-class">
|
||||
C
|
||||
</Toggle>
|
||||
);
|
||||
const { getByRole } = renderComponent(tree);
|
||||
const t = getByRole("button");
|
||||
expect(t.className).toContain("custom-extra-class");
|
||||
expect(t.className).toContain("rounded-md");
|
||||
});
|
||||
|
||||
it("exposes toggleVariants as a callable resolver", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cls = toggleVariants({ variant: "default", size: "default" });
|
||||
expect(typeof cls).toBe("string");
|
||||
expect(cls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
33
packages/ui/react/react-ui/src/components/toggle/toggle.tsx
Normal file
33
packages/ui/react/react-ui/src/components/toggle/toggle.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
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 { ToggleProps } from "./toggle.types.js";
|
||||
import { toggleVariants } from "./toggle.variants.js";
|
||||
|
||||
/**
|
||||
* `<Toggle>` — chassis two-state toggle button built on
|
||||
* `@radix-ui/react-toggle`.
|
||||
*
|
||||
* Radix supplies the a11y semantics (`role="button"`,
|
||||
* `aria-pressed="true|false"`, native `Space` / `Enter` activation,
|
||||
* `disabled` propagation). The chassis layers theme-driven variant
|
||||
* resolution and the `data-icon` slot so child icons inherit sizing.
|
||||
*
|
||||
* Refs are forwarded to the Radix Root (the rendered `<button>`).
|
||||
*/
|
||||
export const Toggle = forwardRef<
|
||||
ComponentRef<typeof TogglePrimitive.Root>,
|
||||
ToggleProps
|
||||
>(function Toggle(props, ref): ReactElement {
|
||||
const { className, variant, size, ...rest } = props;
|
||||
|
||||
const variants = useThemedVariants("Toggle", toggleVariants);
|
||||
const classes = cn(variants({ variant, size }), className);
|
||||
|
||||
return <TogglePrimitive.Root ref={ref} className={classes} {...rest} />;
|
||||
});
|
||||
|
||||
Toggle.displayName = "Toggle";
|
||||
|
||||
export { toggleVariants };
|
||||
@@ -0,0 +1,35 @@
|
||||
import type * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import type { VariantProps } from "../../variants/variants.js";
|
||||
import type { toggleVariants } from "./toggle.variants.js";
|
||||
|
||||
/**
|
||||
* Module augmentation registering `Toggle` into `ThemableVariants` so
|
||||
* `useThemedVariants("Toggle")` is type-narrowed and a `ThemePlugin`
|
||||
* may declare `variantOverrides: { Toggle: <override> }`.
|
||||
*/
|
||||
declare module "../../theme/theme.types.js" {
|
||||
interface ThemableVariants {
|
||||
readonly Toggle: typeof toggleVariants;
|
||||
}
|
||||
}
|
||||
|
||||
export type ToggleVariantProps = VariantProps<typeof toggleVariants>;
|
||||
|
||||
/**
|
||||
* Public props accepted by `<Toggle>`.
|
||||
*
|
||||
* Wraps `@radix-ui/react-toggle` Root: `pressed`/`onPressedChange`
|
||||
* for controlled use, `defaultPressed` for uncontrolled, `disabled`
|
||||
* for the decay state. The chassis layers `variant` + `size`.
|
||||
*
|
||||
* Per the shadcn rule, multi-option pickers (2–7 choices) compose
|
||||
* `<ToggleGroup>` rather than looping over standalone `<Toggle>`
|
||||
* with manual `pressed` state.
|
||||
*/
|
||||
export interface ToggleProps
|
||||
extends Omit<
|
||||
ComponentPropsWithoutRef<typeof TogglePrimitive.Root>,
|
||||
"asChild"
|
||||
>,
|
||||
ToggleVariantProps {}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Toggle Variants (cva config)
|
||||
|
||||
## Purpose
|
||||
|
||||
`toggleVariants` is the cva (class-variance-authority) configuration powering every `<Toggle>` render. It is split from `toggle.tsx` into its own module so an ejected source copy under `npx shadcn@latest add @suaveplan/toggle` keeps the cva configuration in a dedicated file. Theme overrides flow through `useThemedVariants("Toggle", toggleVariants)`.
|
||||
|
||||
## Features
|
||||
|
||||
- Two visual variants (`default`, `outline`) covering the upstream shadcn surface.
|
||||
- Three sizes (`sm`, `default`, `lg`) for use in dense toolbars or roomy headers.
|
||||
- `data-[state=on]:bg-accent data-[state=on]:text-accent-foreground` flips the colour when pressed.
|
||||
- `hover:bg-muted hover:text-muted-foreground` for the pointer-hover affordance.
|
||||
- `data-icon` slot rules (`[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`) so child icons inherit sizing.
|
||||
- `disabled:pointer-events-none disabled:opacity-50` for the native disabled state.
|
||||
- `motion-reduce:transition-none` for users with `prefers-reduced-motion: reduce`.
|
||||
- `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2` for keyboard-only focus styling.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { toggleVariants } from "@suaveplan/react-ui";
|
||||
|
||||
const className = toggleVariants({ variant: "default", size: "default" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { toggleVariants, cn } from "@suaveplan/react-ui";
|
||||
|
||||
export function CustomToggle({ active }: { active: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
data-state={active ? "on" : "off"}
|
||||
className={cn(
|
||||
toggleVariants({ variant: "outline", size: "lg" }),
|
||||
active ? "ring-2 ring-accent" : ""
|
||||
)}
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `toggleVariants(props?: { variant?: "default" | "outline"; size?: "sm" | "default" | "lg" }): string`
|
||||
|
||||
Returns the cva-merged class string for the supplied variant + size combination. Both fields are optional; omitted fields fall back to the cva `defaultVariants` (`variant: "default"`, `size: "default"`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The resolver is tagged with `componentName: "Toggle"` (non-enumerable, non-writable) via `defineVariants`.
|
||||
- The cva config mirrors the visual surface of `<Button>` so toolbars combining `<Button>` + `<Toggle>` look coherent without per-component overrides.
|
||||
- The `data-icon` slot rules are duplicated between `<Button>` and `<Toggle>` because both surfaces accept the same icon-as-child pattern; consumers benefit from the consistent sizing.
|
||||
- Tokens reference theme-driven CSS custom properties (`bg-accent`, `text-accent-foreground`, `bg-muted`, `text-muted-foreground`) so the colour palette comes from the active `ThemePlugin.cssVariables` payload.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineVariants } from "../../variants/variants.js";
|
||||
|
||||
/**
|
||||
* `toggleVariants` — base cva config for `<Toggle>`.
|
||||
*
|
||||
* Mirrors the upstream shadcn `toggle` recipe: rounded button-shaped
|
||||
* surface that flips its background to `accent` when pressed. Two
|
||||
* visual `variant`s (`default`, `outline`) and three sizes for use in
|
||||
* dense toolbars.
|
||||
*
|
||||
* The cva exposes `data-icon` slot rules so child icons inherit
|
||||
* sizing automatically (mirrors `<Button>`).
|
||||
*/
|
||||
export const toggleVariants = defineVariants(
|
||||
"Toggle",
|
||||
[
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap",
|
||||
"rounded-md text-sm font-medium",
|
||||
"transition-colors duration-150 ease-out",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"hover:bg-muted hover:text-muted-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"motion-reduce:transition-none",
|
||||
].join(" "),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
sm: "h-8 px-2 text-xs",
|
||||
default: "h-9 px-3",
|
||||
lg: "h-10 px-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user