Initial Commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
radioGroupItemVariants,
|
||||
radioGroupVariants,
|
||||
} from "./radio-group.js";
|
||||
export type {
|
||||
RadioGroupItemProps,
|
||||
RadioGroupItemVariantProps,
|
||||
RadioGroupProps,
|
||||
RadioGroupVariantProps,
|
||||
} from "./radio-group.types.js";
|
||||
@@ -0,0 +1,147 @@
|
||||
# RadioGroup
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - radio-group.variants — `radioGroupVariants` and `ra…
|
||||
- [radio-group.variants](./radio-group.variants.md) — `radioGroupVariants` and `radioGroupItemVariants` are the cva (class-variance-authority) configurations powering `<Radi…
|
||||
- radio-group.variants — `radioGroupVariants` and `ra…
|
||||
|
||||
- [radio-group.variants](./radio-group.variants.md) — `radioGroupVariants` and `radioGroupItemVariants` are the cva (class-variance-authority) configurations powering `<Radi…
|
||||
|
||||
- radio-group.variants — `radioGroupVariants` and `ra…
|
||||
|
||||
- [radio-group.variants](./radio-group.variants.md) — `radioGroupVariants` and `radioGroupItemVariants` are the cva (class-variance-authority) configurations powering `<Radi…
|
||||
|
||||
- radio-group.variants — `radioGroupVariants` and `ra…
|
||||
|
||||
- [radio-group.variants](./radio-group.variants.md) — `radioGroupVariants` and `radioGroupItemVariants` are the cva (class-variance-authority) configurations powering `<Radi…
|
||||
- [radio-group.variants](./radio-group.variants.md) — `radioGroupVariants` and `radioGroupItemVariants` are the cva (class-variance-authority) configurations powering `<Radi…
|
||||
|
||||
## Purpose
|
||||
|
||||
`<RadioGroup>` and `<RadioGroupItem>` are the chassis radio-group primitives built on `@radix-ui/react-radio-group`. Radix supplies the a11y semantics (`role="radiogroup"` on the container, `role="radio"` on each item, roving tabindex across items, arrow-key navigation, native `Space` activation, controlled `value` propagation); the chassis layers a vertical-stack flex/gap layout via cva and a circular indicator dot rendered with `<CircleIcon fill-current>` from `lucide-react`.
|
||||
|
||||
Per the shadcn convention, items always sit inside the group via `<RadioGroupItem>` — rendering an item without a group is unsupported. Both components forward refs to their underlying Radix elements so consumers can focus / measure / drive animations.
|
||||
|
||||
## Features
|
||||
|
||||
- Container renders a `<div role="radiogroup">` with `grid gap-2` so items stack vertically with a small uniform gap (NOT `space-y-*` per the shadcn skill rule).
|
||||
- Items render a `<button role="radio">` with the circular `border-primary` outline.
|
||||
- Three sizes for items (`sm`, `default`, `lg`) scaling the radio circle.
|
||||
- Indicator dot rendered via `<CircleIcon fill-current text-current>` so the dot inherits the surrounding text colour and the chassis switches it without an extra theme prop.
|
||||
- Controlled and uncontrolled use via Radix `value` / `defaultValue` / `onValueChange`.
|
||||
- `disabled` per-item or per-group blocks pointer + keyboard input.
|
||||
- Refs forwarded on both `<RadioGroup>` and `<RadioGroupItem>`.
|
||||
- `motion-reduce:transition-none` collapses transitions for `prefers-reduced-motion: reduce`.
|
||||
- Theme-driven variant overrides via `useThemedVariants("RadioGroup" | "RadioGroupItem")`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { RadioGroup, RadioGroupItem } from "@suaveplan/react-ui";
|
||||
|
||||
export function PlanPicker() {
|
||||
return (
|
||||
<RadioGroup defaultValue="pro">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="free" id="plan-free" />
|
||||
<label htmlFor="plan-free">Free</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="pro" id="plan-pro" />
|
||||
<label htmlFor="plan-pro">Pro</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="team" id="plan-team" />
|
||||
<label htmlFor="plan-team">Team</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { RadioGroup, RadioGroupItem, FieldSet, FieldLegend } from "@suaveplan/react-ui";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DeliverySpeed {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DeliveryPicker({ options }: { options: readonly DeliverySpeed[] }) {
|
||||
const [speed, setSpeed] = useState("standard");
|
||||
|
||||
return (
|
||||
<FieldSet>
|
||||
<FieldLegend>Delivery speed</FieldLegend>
|
||||
<RadioGroup value={speed} onValueChange={setSpeed}>
|
||||
{options.map((opt) => (
|
||||
<div key={opt.id} className="flex items-start gap-2">
|
||||
<RadioGroupItem
|
||||
value={opt.id}
|
||||
id={`delivery-${opt.id}`}
|
||||
disabled={opt.disabled}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor={`delivery-${opt.id}`}>{opt.label}</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{opt.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `<RadioGroup>`
|
||||
|
||||
`forwardRef<ComponentRef<typeof RadioGroupPrimitive.Root>, RadioGroupProps>` — renders the Radix Root.
|
||||
|
||||
#### `RadioGroupProps`
|
||||
|
||||
Inherits every prop from Radix `RadioGroupPrimitive.Root` except `asChild`. Common props:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `value` | `string` | _uncontrolled_ | Currently selected value. |
|
||||
| `defaultValue` | `string` | _none_ | Initial value for uncontrolled use. |
|
||||
| `onValueChange` | `(value: string) => void` | _none_ | Fires on value change. |
|
||||
| `disabled` | `boolean` | `false` | Disables every item. |
|
||||
| `name` | `string` | _none_ | Native form-field name. |
|
||||
| `orientation` | `"horizontal" \| "vertical"` | `"vertical"` | Arrow-key navigation axis. |
|
||||
|
||||
### `<RadioGroupItem>`
|
||||
|
||||
`forwardRef<ComponentRef<typeof RadioGroupPrimitive.Item>, RadioGroupItemProps>` — renders a Radix Item containing the indicator dot.
|
||||
|
||||
#### `RadioGroupItemProps`
|
||||
|
||||
Inherits every prop from Radix `RadioGroupPrimitive.Item` except `asChild`. Adds:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Visual variant scaling the radio circle. |
|
||||
| `value` | `string` | _required_ | Unique value within the surrounding group. |
|
||||
|
||||
#### `radioGroupVariants` / `radioGroupItemVariants`
|
||||
|
||||
Both bare cva resolvers exported for external composition.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The container uses `grid gap-2` rather than `flex flex-col gap-2` because `grid` collapses cross-axis layout in a way that keeps the items column-aligned even when they have wildly different content lengths (label + description blocks).
|
||||
- Per the shadcn skill rule, the chassis NEVER uses `space-y-*` — `grid gap-*` flows the same in RTL.
|
||||
- The indicator dot uses `fill-current text-current` so it inherits the surrounding text colour automatically; theme overrides do not need to special-case the dot.
|
||||
- Refs target the Radix Root + Item. The Item ref attaches to the rendered `<button>` so consumers can `.focus()` programmatically.
|
||||
- Items that require a description/help text typically wrap the radio + content in `<div className="flex items-start gap-2">` with `mt-1` on the radio so the dot aligns with the first line of text.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom";
|
||||
import * as Stories from "./radio-group.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("radio-group.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,315 @@
|
||||
/**
|
||||
* Storybook stories for `<RadioGroup>`.
|
||||
*
|
||||
* `@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 { RadioGroup, RadioGroupItem } from "./radio-group.js";
|
||||
import type { RadioGroupProps } from "./radio-group.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/RadioGroup",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<RadioGroupProps>;
|
||||
|
||||
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"
|
||||
>
|
||||
<RadioGroup defaultValue="a">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="a" id="rg-a" />
|
||||
<label htmlFor="rg-a">Option A</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="b" id="rg-b" />
|
||||
<label htmlFor="rg-b">Option B</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
render: (_args) => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<RadioGroup defaultValue="a">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="a" id="rg-pa" />
|
||||
<label htmlFor="rg-pa">A</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="b" id="rg-pb" />
|
||||
<label htmlFor="rg-pb">B</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{(["sm", "default", "lg"] as const).map((size) => (
|
||||
<RadioGroup key={size} defaultValue="a">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
value="a"
|
||||
id={`rg-v-${size}`}
|
||||
size={size}
|
||||
/>
|
||||
<label htmlFor={`rg-v-${size}`}>{size}</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
))}
|
||||
</div>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
export const States: Story = {
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<RadioGroup defaultValue="a">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="a" id="rg-s-a" />
|
||||
<label htmlFor="rg-s-a">Selected</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="b" id="rg-s-b" />
|
||||
<label htmlFor="rg-s-b">Not selected</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="c" id="rg-s-c" disabled />
|
||||
<label htmlFor="rg-s-c">Disabled</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</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"
|
||||
>
|
||||
<RadioGroup defaultValue="a">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="a" aria-label="a" />
|
||||
<RadioGroupItem value="b" aria-label="b" />
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</ProviderShellSync>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithRTL: Story = {
|
||||
render: () => (
|
||||
<div dir="rtl">
|
||||
<ProviderShellSync
|
||||
locale="ar-AE"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<RadioGroup defaultValue="a">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="a" aria-label="rtl-a" />
|
||||
<RadioGroupItem value="b" aria-label="rtl-b" />
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</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}
|
||||
>
|
||||
<RadioGroup defaultValue="a">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="a" aria-label="t-a" />
|
||||
<RadioGroupItem value="b" aria-label="t-b" />
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</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"
|
||||
>
|
||||
<RadioGroup defaultValue="email" aria-labelledby="contact-label">
|
||||
<p id="contact-label" className="text-sm font-medium">
|
||||
Preferred contact
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="email" id="rg-email" />
|
||||
<label htmlFor="rg-email">Email</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="phone" id="rg-phone" />
|
||||
<label htmlFor="rg-phone">Phone</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
export const Composition: Story = {
|
||||
name: "Composition (form field)",
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<fieldset className="flex flex-col gap-2 p-4 border rounded">
|
||||
<legend className="text-sm font-semibold">Plan</legend>
|
||||
<RadioGroup defaultValue="pro">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="free" id="rg-c-free" />
|
||||
<label htmlFor="rg-c-free">Free</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="pro" id="rg-c-pro" />
|
||||
<label htmlFor="rg-c-pro">Pro</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
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 { RadioGroup, RadioGroupItem } from "./radio-group.js";
|
||||
import {
|
||||
radioGroupItemVariants,
|
||||
radioGroupVariants,
|
||||
} from "./radio-group.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("<RadioGroup>", () => {
|
||||
it("renders a container with role=radiogroup", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<RadioGroup data-testid="g">
|
||||
<RadioGroupItem value="a" aria-label="a" />
|
||||
</RadioGroup>
|
||||
);
|
||||
const { getByTestId } = renderComponent(tree);
|
||||
const g = getByTestId("g");
|
||||
expect(g.getAttribute("role")).toBe("radiogroup");
|
||||
});
|
||||
|
||||
it("renders items with role=radio", { timeout: 5000 }, async () => {
|
||||
const tree = await withProviders(
|
||||
<RadioGroup>
|
||||
<RadioGroupItem value="a" aria-label="a" />
|
||||
<RadioGroupItem value="b" aria-label="b" />
|
||||
</RadioGroup>
|
||||
);
|
||||
const { getAllByRole } = renderComponent(tree);
|
||||
const items = getAllByRole("radio");
|
||||
expect(items.length).toBe(2);
|
||||
});
|
||||
|
||||
it("forwards refs on group and item", { timeout: 5000 }, async () => {
|
||||
const groupRef = createRef<HTMLDivElement>();
|
||||
const itemRef = createRef<HTMLButtonElement>();
|
||||
const tree = await withProviders(
|
||||
<RadioGroup ref={groupRef}>
|
||||
<RadioGroupItem ref={itemRef} value="a" aria-label="a" />
|
||||
</RadioGroup>
|
||||
);
|
||||
renderComponent(tree);
|
||||
expect(groupRef.current).not.toBeNull();
|
||||
expect(itemRef.current).not.toBeNull();
|
||||
});
|
||||
|
||||
it("controls value via onValueChange", { timeout: 5000 }, async () => {
|
||||
function Controlled(): React.ReactElement {
|
||||
const [value, setValue] = useState("a");
|
||||
return (
|
||||
<RadioGroup value={value} onValueChange={setValue}>
|
||||
<RadioGroupItem value="a" aria-label="a" />
|
||||
<RadioGroupItem value="b" aria-label="b" />
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
const tree = await withProviders(<Controlled />);
|
||||
const { getAllByRole, user } = renderComponent(tree);
|
||||
const items = getAllByRole("radio");
|
||||
expect(items[0]?.getAttribute("aria-checked")).toBe("true");
|
||||
await user.click(items[1] as HTMLElement);
|
||||
expect(items[1]?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders the indicator dot when item is checked", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<RadioGroup defaultValue="a">
|
||||
<RadioGroupItem value="a" aria-label="a" />
|
||||
</RadioGroup>
|
||||
);
|
||||
const { container } = renderComponent(tree);
|
||||
const dot = container.querySelector('[data-icon="dot"]');
|
||||
expect(dot).not.toBeNull();
|
||||
});
|
||||
|
||||
it("respects disabled on individual items", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let count = 0;
|
||||
const tree = await withProviders(
|
||||
<RadioGroup onValueChange={() => (count += 1)}>
|
||||
<RadioGroupItem value="a" aria-label="a" disabled />
|
||||
</RadioGroup>
|
||||
);
|
||||
const { getAllByRole, user } = renderComponent(tree);
|
||||
const item = getAllByRole("radio")[0] as HTMLElement;
|
||||
expect(item.hasAttribute("disabled")).toBe(true);
|
||||
await user.click(item);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("merges custom className on group + item", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<RadioGroup data-testid="g" className="group-extra">
|
||||
<RadioGroupItem
|
||||
value="a"
|
||||
aria-label="a"
|
||||
className="item-extra"
|
||||
/>
|
||||
</RadioGroup>
|
||||
);
|
||||
const { getByTestId, getAllByRole } = renderComponent(tree);
|
||||
expect(getByTestId("g").className).toContain("group-extra");
|
||||
const item = getAllByRole("radio")[0] as HTMLElement;
|
||||
expect(item.className).toContain("item-extra");
|
||||
});
|
||||
|
||||
it("applies the item size variant", { timeout: 5000 }, async () => {
|
||||
const tree = await withProviders(
|
||||
<RadioGroup>
|
||||
<RadioGroupItem value="a" aria-label="a" size="lg" />
|
||||
</RadioGroup>
|
||||
);
|
||||
const { getAllByRole } = renderComponent(tree);
|
||||
const item = getAllByRole("radio")[0] as HTMLElement;
|
||||
expect(item.className).toContain("h-5");
|
||||
expect(item.className).toContain("w-5");
|
||||
});
|
||||
|
||||
it("exposes radioGroupVariants and radioGroupItemVariants", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(radioGroupVariants().length).toBeGreaterThan(0);
|
||||
const sizes = ["sm", "default", "lg"] as const;
|
||||
for (const size of sizes) {
|
||||
expect(radioGroupItemVariants({ size }).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { CircleIcon } from "lucide-react";
|
||||
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 {
|
||||
RadioGroupItemProps,
|
||||
RadioGroupProps,
|
||||
} from "./radio-group.types.js";
|
||||
import {
|
||||
radioGroupItemVariants,
|
||||
radioGroupVariants,
|
||||
} from "./radio-group.variants.js";
|
||||
|
||||
/**
|
||||
* `<RadioGroup>` — chassis radio-group container built on
|
||||
* `@radix-ui/react-radio-group`.
|
||||
*
|
||||
* Radix supplies the a11y semantics (`role="radiogroup"`, roving
|
||||
* tabindex across items, arrow-key navigation, native `Space`
|
||||
* activation, controlled `value` propagation). The chassis layers a
|
||||
* vertical-stack flex/gap layout via cva.
|
||||
*
|
||||
* Per shadcn convention, items always sit inside the group via
|
||||
* `<RadioGroupItem>`; rendering an item without a group is
|
||||
* unsupported.
|
||||
*/
|
||||
export const RadioGroup = forwardRef<
|
||||
ComponentRef<typeof RadioGroupPrimitive.Root>,
|
||||
RadioGroupProps
|
||||
>(function RadioGroup(props, ref): ReactElement {
|
||||
const { className, ...rest } = props;
|
||||
|
||||
const variants = useThemedVariants("RadioGroup", radioGroupVariants);
|
||||
const classes = cn(variants(), className);
|
||||
|
||||
return <RadioGroupPrimitive.Root ref={ref} className={classes} {...rest} />;
|
||||
});
|
||||
|
||||
RadioGroup.displayName = "RadioGroup";
|
||||
|
||||
/**
|
||||
* `<RadioGroupItem>` — single radio inside `<RadioGroup>`. Renders a
|
||||
* Radix Item containing the circular indicator (`<CircleIcon>` filled
|
||||
* with the current text colour) when checked.
|
||||
*
|
||||
* Refs are forwarded to the Radix Item (the rendered `<button>`).
|
||||
*/
|
||||
export const RadioGroupItem = forwardRef<
|
||||
ComponentRef<typeof RadioGroupPrimitive.Item>,
|
||||
RadioGroupItemProps
|
||||
>(function RadioGroupItem(props, ref): ReactElement {
|
||||
const { className, size, ...rest } = props;
|
||||
|
||||
const variants = useThemedVariants(
|
||||
"RadioGroupItem",
|
||||
radioGroupItemVariants
|
||||
);
|
||||
const classes = cn(variants({ size }), className);
|
||||
|
||||
return (
|
||||
<RadioGroupPrimitive.Item ref={ref} className={classes} {...rest}>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
className="flex items-center justify-center"
|
||||
data-icon="indicator"
|
||||
>
|
||||
<CircleIcon
|
||||
className="h-2 w-2 fill-current text-current"
|
||||
data-icon="dot"
|
||||
/>
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
RadioGroupItem.displayName = "RadioGroupItem";
|
||||
|
||||
export { radioGroupItemVariants, radioGroupVariants };
|
||||
@@ -0,0 +1,50 @@
|
||||
import type * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import type { VariantProps } from "../../variants/variants.js";
|
||||
import type {
|
||||
radioGroupItemVariants,
|
||||
radioGroupVariants,
|
||||
} from "./radio-group.variants.js";
|
||||
|
||||
/**
|
||||
* Module augmentation registering `RadioGroup` and `RadioGroupItem` into
|
||||
* `ThemableVariants` so `useThemedVariants` is type-narrowed and a
|
||||
* `ThemePlugin` may declare overrides.
|
||||
*/
|
||||
declare module "../../theme/theme.types.js" {
|
||||
interface ThemableVariants {
|
||||
readonly RadioGroup: typeof radioGroupVariants;
|
||||
readonly RadioGroupItem: typeof radioGroupItemVariants;
|
||||
}
|
||||
}
|
||||
|
||||
export type RadioGroupVariantProps = VariantProps<typeof radioGroupVariants>;
|
||||
export type RadioGroupItemVariantProps = VariantProps<
|
||||
typeof radioGroupItemVariants
|
||||
>;
|
||||
|
||||
/**
|
||||
* Public props for `<RadioGroup>` — wraps the Radix Root which renders
|
||||
* a `role="radiogroup"` container. `value`/`onValueChange` for
|
||||
* controlled use; `defaultValue` for uncontrolled.
|
||||
*/
|
||||
export interface RadioGroupProps
|
||||
extends Omit<
|
||||
ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>,
|
||||
"asChild"
|
||||
>,
|
||||
RadioGroupVariantProps {}
|
||||
|
||||
/**
|
||||
* Public props for `<RadioGroupItem>` — wraps the Radix Item which
|
||||
* renders a `role="radio"` button bound to the surrounding group.
|
||||
*
|
||||
* Per the shadcn rule, items always sit inside their `<RadioGroup>`
|
||||
* container — the chassis does not provide a stand-alone item.
|
||||
*/
|
||||
export interface RadioGroupItemProps
|
||||
extends Omit<
|
||||
ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>,
|
||||
"asChild"
|
||||
>,
|
||||
RadioGroupItemVariantProps {}
|
||||
@@ -0,0 +1,61 @@
|
||||
# RadioGroup Variants (cva config)
|
||||
|
||||
## Purpose
|
||||
|
||||
`radioGroupVariants` and `radioGroupItemVariants` are the cva (class-variance-authority) configurations powering `<RadioGroup>` and `<RadioGroupItem>`. They are split from `radio-group.tsx` into their own module so an ejected source copy under `npx shadcn@latest add @suaveplan/radio-group` keeps the cva configurations in a dedicated file. Theme overrides flow through `useThemedVariants("RadioGroup" | "RadioGroupItem")`.
|
||||
|
||||
The container cva is intentionally minimal (`grid gap-2` only) — the layout is a vertical stack with uniform gap. The item cva carries the visual recipe (border, focus ring, disabled decay) and the `size` axis.
|
||||
|
||||
## Features
|
||||
|
||||
- Container layout via `grid gap-2` (NOT `space-y-*` per the shadcn skill rule).
|
||||
- Item border `border-primary` + `text-primary` so the indicator inherits the colour automatically via `fill-current text-current`.
|
||||
- Three item sizes (`sm` → `h-3.5 w-3.5`, `default` → `h-4 w-4`, `lg` → `h-5 w-5`).
|
||||
- `disabled:cursor-not-allowed 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 { radioGroupVariants, radioGroupItemVariants } from "@suaveplan/react-ui";
|
||||
|
||||
const containerClassName = radioGroupVariants();
|
||||
const itemClassName = radioGroupItemVariants({ size: "default" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { radioGroupItemVariants, cn } from "@suaveplan/react-ui";
|
||||
|
||||
export function CustomRadio() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked="false"
|
||||
className={cn(
|
||||
radioGroupItemVariants({ size: "lg" }),
|
||||
"ring-2 ring-accent"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `radioGroupVariants(): string`
|
||||
|
||||
Returns the container classes. Takes no arguments — the container cva exposes no axes.
|
||||
|
||||
### `radioGroupItemVariants(props?: { size?: "sm" | "default" | "lg" }): string`
|
||||
|
||||
Returns the item classes. Omitted size falls back to the cva `defaultVariants` (`size: "default"`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The container uses `grid` (not `flex flex-col`) because grid collapses cross-axis layout in a way that keeps items column-aligned even when content lengths differ — the `<RadioGroupItem>` + label + description pattern stacks predictably.
|
||||
- The item cva does NOT include layout properties — consumers compose layout (item beside label) at the call-site so the chassis primitive stays composable.
|
||||
- Per the shadcn skill rule, the cva NEVER uses `space-y-*` / `space-x-*` — `gap-*` flows the same in RTL.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineVariants } from "../../variants/variants.js";
|
||||
|
||||
/**
|
||||
* `radioGroupVariants` — base cva for the `<RadioGroup>` container.
|
||||
*
|
||||
* Uses `flex flex-col gap-2` (NOT `space-y-*` per shadcn skill rule)
|
||||
* so layout flows the same in RTL and consumer-applied flex utilities
|
||||
* stack predictably.
|
||||
*/
|
||||
export const radioGroupVariants = defineVariants("RadioGroup", "grid gap-2", {
|
||||
variants: {},
|
||||
defaultVariants: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* `radioGroupItemVariants` — base cva for `<RadioGroupItem>`.
|
||||
*
|
||||
* Mirrors the upstream shadcn `radio-group` item recipe: square box with
|
||||
* border, focus ring, primary border when checked. The chassis adds a
|
||||
* `size` axis so consumers can scale items inside dense or roomy
|
||||
* surfaces without overriding individual classes.
|
||||
*/
|
||||
export const radioGroupItemVariants = defineVariants(
|
||||
"RadioGroupItem",
|
||||
[
|
||||
"aspect-square rounded-full border border-primary",
|
||||
"text-primary shadow",
|
||||
"focus:outline-none focus-visible:ring-2",
|
||||
"focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"transition-colors duration-150 ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
].join(" "),
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-3.5 w-3.5",
|
||||
default: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user