Initial Commit

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

View File

@@ -0,0 +1,12 @@
export {
RadioGroup,
RadioGroupItem,
radioGroupItemVariants,
radioGroupVariants,
} from "./radio-group.js";
export type {
RadioGroupItemProps,
RadioGroupItemVariantProps,
RadioGroupProps,
RadioGroupVariantProps,
} from "./radio-group.types.js";

View File

@@ -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.

View File

@@ -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();
});
}
});

View File

@@ -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>
),
};

View File

@@ -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);
}
});
});

View File

@@ -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 };

View File

@@ -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 {}

View File

@@ -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.

View File

@@ -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",
},
}
);