@primitiv-ui/react 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,7 @@ import { Tabs } from "@primitiv-ui/react";
41
41
  | [Modal](src/Modal/README.md) | WAI-ARIA Modal Dialog pattern — native `<dialog>`-based compound component with Portal, bounding-rect click-outside, controlled/uncontrolled state, escape hatches, imperative API, `asChild`, and `forceMount` animation hooks |
42
42
  | [Portal](src/Portal/README.md) | DOM-escape utility — renders children into `document.body` (or a consumer-supplied container) via `createPortal`; compose with any component that needs to break out of the current stacking context |
43
43
  | [Progress](src/Progress/README.md) | WAI-ARIA progressbar pattern — display-only compound component reflecting a consumer-supplied `value`, with determinate/indeterminate states, configurable `max`, `getValueLabel` for `aria-valuetext`, `data-*` styling hooks on the fill `Indicator`, and `asChild` composition |
44
+ | [Radio](src/Radio/README.md) | WAI-ARIA Radio pattern, standalone — native `<button role="radio">` with one-way single-selection state (no toggle-off), controlled/uncontrolled state, `Indicator` slot, `disabled` with `data-disabled` styling hook, and `asChild` composition; pair with your own grouping (use `RadioGroup` for managed roving-tabindex sets) |
44
45
  | [RadioCard](src/RadioCard/README.md) | WAI-ARIA Radio Group pattern in a card/tile layout — the entire card surface is the interactive element, with single-selection state, roving tabindex, arrow-key navigation, disabled-item skipping, `Indicator` slot, and `asChild` composition |
45
46
  | [RadioGroup](src/RadioGroup/README.md) | WAI-ARIA Radio Group pattern — native `<button role="radio">` items with single-selection state, roving tabindex, arrow-key navigation, disabled-item skipping, `Indicator` slot, and `asChild` composition |
46
47
  | [Select](src/Select/README.md) | Headless wrapper over native `<select>` / `<option>` / `<optgroup>` — controlled/uncontrolled state via `onValueChange`, `Select.Placeholder` with auto-`defaultValue` inference, `Select.Group` for optgroup labelling, `data-disabled` styling hook, `asChild` composition, and Field-aware (opts into `FieldContext` for auto-wired `id` / `aria-describedby` / `aria-invalid` / `disabled` / `required`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitiv-ui/react",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Headless, accessible React components built on the WAI-ARIA authoring patterns. Zero styles ship with this package — bring your own (CSS, Tailwind, CSS-in-JS, design tokens).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,117 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ import { render, screen } from "@testing-library/react";
6
+
7
+ import { Checkbox } from "../Checkbox";
8
+
9
+ /**
10
+ * Auto-verification of the Checkbox styling contract (RFC 0004 §3.4 / D15). The
11
+ * `data-*` half of `registry/components/checkbox/contract.json` is *derived from
12
+ * and asserted against the rendered headless component* so it can never drift
13
+ * from what the component actually emits. Checkbox is tri-state: its surface is
14
+ * `data-state="checked" | "unchecked" | "indeterminate"` (always present) plus
15
+ * `data-disabled=""` when disabled. The authored half (root class, parts, custom
16
+ * properties) is a styling convention the headless layer does not emit and is
17
+ * checked by the generator drift-guards instead.
18
+ */
19
+ const contractPath = resolve(
20
+ dirname(fileURLToPath(import.meta.url)),
21
+ "../../../../../registry/components/checkbox/contract.json",
22
+ );
23
+
24
+ const contract = JSON.parse(readFileSync(contractPath, "utf8"));
25
+
26
+ /** Every `data-*` attribute name present on an element. */
27
+ function dataAttributeNames(element: Element): string[] {
28
+ return [...element.attributes]
29
+ .map((attribute) => attribute.name)
30
+ .filter((name) => name.startsWith("data-"))
31
+ .sort();
32
+ }
33
+
34
+ describe("Checkbox styling contract", () => {
35
+ const autoAttributes = contract.dataAttributes.filter(
36
+ (attribute: { source: string }) => attribute.source === "auto",
37
+ );
38
+
39
+ it("declares every data-* attribute as auto-verified against the component", () => {
40
+ // The Checkbox has no authored data-* surface; the whole contract is derived.
41
+ expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
42
+ });
43
+
44
+ it("emits exactly the contracted data-* attribute names across its states", () => {
45
+ const emitted = new Set<string>();
46
+ for (const ui of [
47
+ <Checkbox.Root key="checked" checked onCheckedChange={() => {}} aria-label="x">
48
+ <Checkbox.Indicator />
49
+ </Checkbox.Root>,
50
+ <Checkbox.Root
51
+ key="indeterminate"
52
+ checked="indeterminate"
53
+ onCheckedChange={() => {}}
54
+ aria-label="x"
55
+ >
56
+ <Checkbox.Indicator />
57
+ </Checkbox.Root>,
58
+ <Checkbox.Root key="disabled" disabled aria-label="x">
59
+ <Checkbox.Indicator />
60
+ </Checkbox.Root>,
61
+ ]) {
62
+ const { unmount } = render(ui);
63
+ for (const name of dataAttributeNames(screen.getByRole("checkbox"))) {
64
+ emitted.add(name);
65
+ }
66
+ unmount();
67
+ }
68
+
69
+ const contracted = new Set(
70
+ autoAttributes.map((attribute: { name: string }) => attribute.name),
71
+ );
72
+ expect([...emitted].sort()).toEqual([...contracted].sort());
73
+ });
74
+
75
+ it("emits data-state with the documented value for each state", () => {
76
+ const stateEntries = autoAttributes.filter(
77
+ (attribute: { name: string }) => attribute.name === "data-state",
78
+ );
79
+
80
+ for (const entry of stateEntries) {
81
+ const checked =
82
+ entry.value === "indeterminate"
83
+ ? ("indeterminate" as const)
84
+ : entry.value === "checked";
85
+ const { unmount } = render(
86
+ <Checkbox.Root checked={checked} onCheckedChange={() => {}} aria-label="x">
87
+ <Checkbox.Indicator />
88
+ </Checkbox.Root>,
89
+ );
90
+ expect(screen.getByRole("checkbox")).toHaveAttribute("data-state", entry.value);
91
+ unmount();
92
+ }
93
+ });
94
+
95
+ it("emits data-disabled exactly as the contract documents it", () => {
96
+ const entry = autoAttributes.find(
97
+ (attribute: { name: string }) => attribute.name === "data-disabled",
98
+ );
99
+
100
+ render(
101
+ <Checkbox.Root disabled aria-label="x">
102
+ <Checkbox.Indicator />
103
+ </Checkbox.Root>,
104
+ );
105
+ expect(screen.getByRole("checkbox")).toHaveAttribute(entry.name, entry.value);
106
+ });
107
+
108
+ it("omits data-disabled when the documented condition does not hold", () => {
109
+ render(
110
+ <Checkbox.Root aria-label="x">
111
+ <Checkbox.Indicator />
112
+ </Checkbox.Root>,
113
+ );
114
+
115
+ expect(screen.getByRole("checkbox")).not.toHaveAttribute("data-disabled");
116
+ });
117
+ });
@@ -0,0 +1,157 @@
1
+ # Radio
2
+
3
+ A headless, accessible, **standalone** compound component implementing the
4
+ single-selection half of the
5
+ [WAI-ARIA Radio pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/).
6
+
7
+ Radio renders a native `<button role="radio">` so it gets keyboard
8
+ activation (`Space` / `Enter`), focus ring, and disabled semantics for free
9
+ from the browser. The React layer adds one-way selection, `Indicator`
10
+ mounting driven by the selected state, and the `asChild` composition every
11
+ primitive in this package supports.
12
+
13
+ > **Radio vs. RadioGroup.** `Radio` is the lone control for when you own the
14
+ > grouping (a native form `name`, a bespoke layout, a single opt-in). For a
15
+ > managed set with roving-tabindex keyboard navigation, arrow-key selection,
16
+ > and disabled-item skipping, reach for
17
+ > [`RadioGroup`](../RadioGroup/README.md) instead — it composes `role="radio"`
18
+ > items the same way but owns the shared selected value.
19
+
20
+ ```tsx
21
+ import { Radio } from "@primitiv-ui/react";
22
+
23
+ <Radio.Root defaultChecked aria-label="Compact">
24
+ <Radio.Indicator>
25
+ <DotIcon />
26
+ </Radio.Indicator>
27
+ </Radio.Root>;
28
+ ```
29
+
30
+ ## Sub-components
31
+
32
+ | Export | Element | Notes |
33
+ | ----------------- | ---------- | --------------------------------------------------------------------------- |
34
+ | `Radio.Root` | `<button>` | `role="radio"`, `aria-checked`, `data-state`, `data-disabled`. `asChild` |
35
+ | `Radio.Indicator` | `<span>` | `aria-hidden="true"`. Renders only while selected. `asChild`, `forceMount` |
36
+
37
+ ## Selected state
38
+
39
+ Radio is binary. The `checked` / `defaultChecked` value is a `boolean`:
40
+
41
+ | Value | `aria-checked` | `data-state` | Indicator renders? |
42
+ | ------- | -------------- | ------------- | ------------------------ |
43
+ | `true` | `"true"` | `"checked"` | Yes |
44
+ | `false` | `"false"` | `"unchecked"` | No (unless `forceMount`) |
45
+
46
+ Selection is **one-way**: clicking an unselected radio selects it, but
47
+ clicking the already-selected radio is a no-op — a lone radio never toggles
48
+ itself off, and `onCheckedChange` does not fire for a re-select. De-selection
49
+ happens when a sibling is chosen, which is the grouping consumer's concern.
50
+
51
+ ## State modes
52
+
53
+ - **Uncontrolled** — pass `defaultChecked` (or omit for unselected on mount).
54
+ - **Controlled** — pass `checked` **and** `onCheckedChange` together.
55
+
56
+ The two shapes are statically discriminated at the type level; TypeScript
57
+ rejects mixing them.
58
+
59
+ ```tsx
60
+ // Uncontrolled
61
+ <Radio.Root defaultChecked>…</Radio.Root>;
62
+
63
+ // Controlled — the consumer owns the group's selected value.
64
+ const [value, setValue] = useState("comfortable");
65
+ <Radio.Root
66
+ checked={value === "compact"}
67
+ onCheckedChange={() => setValue("compact")}
68
+ >
69
+ …
70
+ </Radio.Root>;
71
+ ```
72
+
73
+ ## Keyboard interaction
74
+
75
+ | Key | Behaviour |
76
+ | ------- | -------------------------------------- |
77
+ | `Space` | Selects the radio (native `<button>`) |
78
+ | `Enter` | Selects the radio (native `<button>`) |
79
+
80
+ Keyboard handling comes from the underlying `<button>` element — no custom
81
+ `keydown` listeners are needed. (Arrow-key navigation across a set is a
82
+ `RadioGroup` concern, not a standalone `Radio` one.)
83
+
84
+ ## Disabled
85
+
86
+ Passing `disabled` forwards the native `disabled` attribute to the button
87
+ (removing it from the tab order and suppressing clicks) **and** sets
88
+ `data-disabled=""` on the root so CSS can target `[data-disabled]` without
89
+ reaching for `:disabled`.
90
+
91
+ ```tsx
92
+ <Radio.Root disabled aria-label="Unavailable plan">
93
+ <Radio.Indicator>
94
+ <DotIcon />
95
+ </Radio.Indicator>
96
+ </Radio.Root>
97
+ ```
98
+
99
+ ## `asChild` composition
100
+
101
+ Both `Radio.Root` and `Radio.Indicator` accept an `asChild` boolean. When
102
+ set, the component delegates rendering to its single child element and merges
103
+ its own ARIA attributes, data-state, composed event handlers, and ref onto
104
+ the child (the asChild contract: the child's handler runs first, the
105
+ library's runs second unless the child calls `preventDefault`).
106
+
107
+ ```tsx
108
+ // Menu-item radio — the same state machinery, different element + role.
109
+ <Radio.Root asChild aria-label="Sort by name">
110
+ <li role="menuitemradio">Sort by name</li>
111
+ </Radio.Root>
112
+
113
+ // Icon-only indicator — the svg itself becomes the indicator.
114
+ <Radio.Indicator asChild>
115
+ <svg viewBox="0 0 10 10">
116
+ <circle cx="5" cy="5" r="3" />
117
+ </svg>
118
+ </Radio.Indicator>
119
+ ```
120
+
121
+ ## Animation hooks
122
+
123
+ `Radio.Indicator` accepts a `forceMount` boolean. When set, the indicator
124
+ stays in the DOM regardless of selected state so a CSS animation can play
125
+ against `data-state="unchecked"`:
126
+
127
+ ```tsx
128
+ <Radio.Indicator forceMount>
129
+ <DotIcon />
130
+ </Radio.Indicator>
131
+ ```
132
+
133
+ ```css
134
+ [data-state="checked"] {
135
+ animation: dot-in 120ms ease-out;
136
+ }
137
+ [data-state="unchecked"] {
138
+ animation: dot-out 100ms ease-in forwards;
139
+ }
140
+ ```
141
+
142
+ Consumers using `forceMount` own the exit timing themselves.
143
+
144
+ ## Styling hooks
145
+
146
+ `data-state="checked" | "unchecked"` is set on both `Radio.Root` and
147
+ `Radio.Indicator`, letting any CSS system target both phases.
148
+
149
+ ```css
150
+ button[data-state="checked"] {
151
+ border-color: oklch(55% 0.2 265);
152
+ }
153
+ button[data-disabled] {
154
+ opacity: 0.5;
155
+ cursor: not-allowed;
156
+ }
157
+ ```
@@ -0,0 +1,213 @@
1
+ import { useMemo } from "react";
2
+ import type { ReactElement } from "react";
3
+
4
+ import { Slot, composeEventHandlers } from "../Slot/index.ts";
5
+
6
+ import { RadioContext } from "./RadioContext";
7
+ import { useRadioContext, useRadioRoot } from "./hooks/index.ts";
8
+ import { RadioIndicatorProps, RadioRootProps } from "./types";
9
+
10
+ function dataStateOf(checked: boolean) {
11
+ return checked ? ("checked" as const) : ("unchecked" as const);
12
+ }
13
+
14
+ /**
15
+ * The root of a Radio — a native `<button role="radio">` that owns a
16
+ * single boolean selected value and provides
17
+ * {@link RadioContext | `RadioContext`} to descendant
18
+ * {@link RadioIndicator | `Radio.Indicator`}s.
19
+ *
20
+ * Unlike a checkbox, selection is **one-way**: clicking an unselected
21
+ * radio selects it, but clicking the already-selected radio is a no-op —
22
+ * a lone radio never toggles itself off. De-selection happens when a
23
+ * sibling is chosen, which is the grouping consumer's concern. For a
24
+ * managed set with roving-tabindex keyboard navigation, reach for
25
+ * `RadioGroup` instead; `Radio` is the standalone control for when you
26
+ * own the grouping (e.g. a native form `name`, or a bespoke layout).
27
+ *
28
+ * Supports two state modes, statically discriminated at the type level:
29
+ *
30
+ * - **Uncontrolled** — pass
31
+ * {@link RadioRootProps.defaultChecked | `defaultChecked`} (or omit for
32
+ * unselected-on-mount). The component owns the value internally.
33
+ * - **Controlled** — pass {@link RadioRootProps.checked | `checked`} *and*
34
+ * {@link RadioRootProps.onCheckedChange | `onCheckedChange`} together.
35
+ * The parent owns the value; the component defers selection back through
36
+ * the callback.
37
+ *
38
+ * **ARIA.** `role="radio"` and `aria-checked` are set automatically.
39
+ *
40
+ * **Styling hooks.** `data-state="checked" | "unchecked"` on the root,
41
+ * plus `data-disabled=""` when disabled.
42
+ *
43
+ * **`asChild` prop.** Pass `asChild` to render any consumer-supplied
44
+ * element (e.g. `<li role="menuitemradio">` for menu composition) with
45
+ * the radio's ARIA attributes, data-state, composed onClick, and ref
46
+ * merged in. The native `<button>` is dropped; consumers who want keyboard
47
+ * activation on a non-button element are responsible for providing it.
48
+ *
49
+ * @example Uncontrolled
50
+ * ```tsx
51
+ * <Radio.Root defaultChecked aria-label="Compact">
52
+ * <Radio.Indicator />
53
+ * </Radio.Root>
54
+ * ```
55
+ *
56
+ * @example Controlled (consumer owns the group)
57
+ * ```tsx
58
+ * const [value, setValue] = useState("comfortable");
59
+ *
60
+ * <Radio.Root
61
+ * checked={value === "compact"}
62
+ * onCheckedChange={() => setValue("compact")}
63
+ * aria-label="Compact"
64
+ * >
65
+ * <Radio.Indicator />
66
+ * </Radio.Root>
67
+ * ```
68
+ */
69
+ export function RadioRoot(props: RadioRootProps): ReactElement {
70
+ const {
71
+ defaultChecked,
72
+ checked,
73
+ onCheckedChange,
74
+ onClick,
75
+ disabled,
76
+ asChild = false,
77
+ children,
78
+ ...rest
79
+ } = props;
80
+ const { checked: isChecked, select } = useRadioRoot({
81
+ defaultChecked,
82
+ checked,
83
+ onCheckedChange,
84
+ });
85
+ const contextValue = useMemo(() => ({ checked: isChecked }), [isChecked]);
86
+ const rootProps = {
87
+ ...rest,
88
+ role: "radio" as const,
89
+ "aria-checked": isChecked,
90
+ "data-state": dataStateOf(isChecked),
91
+ "data-disabled": disabled ? "" : undefined,
92
+ disabled,
93
+ onClick: composeEventHandlers(onClick, select),
94
+ };
95
+ return (
96
+ <RadioContext.Provider value={contextValue}>
97
+ {asChild ? (
98
+ <Slot {...rootProps}>{children}</Slot>
99
+ ) : (
100
+ <button type="button" {...rootProps}>
101
+ {children}
102
+ </button>
103
+ )}
104
+ </RadioContext.Provider>
105
+ );
106
+ }
107
+
108
+ /** @internal */
109
+ RadioRoot.displayName = "RadioRoot";
110
+
111
+ /**
112
+ * A decorative `<span aria-hidden="true">` that renders its children only
113
+ * while the parent {@link RadioRoot | `Radio.Root`} is **selected** —
114
+ * typically the filled dot inside a radio control. The radio's accessible
115
+ * state is already conveyed by `aria-checked` on the root, so the indicator
116
+ * is purely visual.
117
+ *
118
+ * **Styling hook.** Mirrors the root's
119
+ * `data-state="checked" | "unchecked"` so the same CSS rule can target
120
+ * both.
121
+ *
122
+ * **`asChild` prop.** Pass `asChild` to render the consumer's own element
123
+ * (typically an `<svg>` dot) as the indicator itself, with `aria-hidden`
124
+ * and `data-state` merged onto that element rather than a wrapper.
125
+ *
126
+ * **`forceMount` prop.** Pass `forceMount` to keep the indicator in the
127
+ * DOM while unchecked so a CSS exit animation can play against
128
+ * `data-state="unchecked"`. Consumers who use `forceMount` own the exit
129
+ * lifecycle themselves.
130
+ *
131
+ * @example Default span wrapper
132
+ * ```tsx
133
+ * <Radio.Root aria-label="Compact">
134
+ * <Radio.Indicator />
135
+ * Compact
136
+ * </Radio.Root>
137
+ * ```
138
+ *
139
+ * @example Dot as the indicator via `asChild`
140
+ * ```tsx
141
+ * <Radio.Indicator asChild>
142
+ * <svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="3" /></svg>
143
+ * </Radio.Indicator>
144
+ * ```
145
+ *
146
+ * @throws if rendered outside a `Radio.Root`.
147
+ */
148
+ export function RadioIndicator({
149
+ children,
150
+ forceMount,
151
+ asChild = false,
152
+ ...rest
153
+ }: RadioIndicatorProps): ReactElement | null {
154
+ const { checked } = useRadioContext();
155
+ if (!checked && !forceMount) return null;
156
+ const indicatorProps = {
157
+ ...rest,
158
+ "aria-hidden": "true" as const,
159
+ "data-state": dataStateOf(checked),
160
+ };
161
+ if (asChild) {
162
+ return <Slot {...indicatorProps}>{children}</Slot>;
163
+ }
164
+ return <span {...indicatorProps}>{children}</span>;
165
+ }
166
+
167
+ /** @internal */
168
+ RadioIndicator.displayName = "RadioIndicator";
169
+
170
+ /** Type of the {@link Radio} compound: the root callable plus its attached sub-components. */
171
+ export type TRadioCompound = typeof RadioRoot & {
172
+ Root: typeof RadioRoot;
173
+ Indicator: typeof RadioIndicator;
174
+ };
175
+
176
+ /**
177
+ * Headless, accessible **Radio** — a standalone compound component built
178
+ * on a native `<button role="radio">` that implements the single-selection
179
+ * half of the
180
+ * [WAI-ARIA Radio pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/).
181
+ * Zero styles ship.
182
+ *
183
+ * `Radio` is the lone control for when you own the grouping; for a managed
184
+ * set with roving-tabindex keyboard navigation use `RadioGroup`.
185
+ *
186
+ * `Radio` is both callable (an alias of {@link RadioRoot | `Radio.Root`})
187
+ * and carries its sub-components as static properties. Prefer the
188
+ * namespaced form in application code for readability and grep-ability.
189
+ *
190
+ * - {@link RadioRoot | `Radio.Root`} — state owner, context provider, select button.
191
+ * - {@link RadioIndicator | `Radio.Indicator`} — decorative dot, conditional on selected state.
192
+ *
193
+ * @example Minimal usage
194
+ * ```tsx
195
+ * import { Radio } from "@primitiv-ui/react";
196
+ *
197
+ * <Radio.Root aria-label="Compact">
198
+ * <Radio.Indicator />
199
+ * </Radio.Root>;
200
+ * ```
201
+ *
202
+ * @see {@link RadioRoot} for state modes and one-way selection semantics.
203
+ * @see {@link RadioIndicator} for the mount gate and animation hooks.
204
+ */
205
+ const RadioCompound: TRadioCompound = Object.assign(RadioRoot, {
206
+ Root: RadioRoot,
207
+ Indicator: RadioIndicator,
208
+ });
209
+
210
+ /** @internal */
211
+ RadioCompound.displayName = "Radio";
212
+
213
+ export { RadioCompound as Radio };
@@ -0,0 +1,10 @@
1
+ import { createStrictContext } from "../utils/index.ts";
2
+
3
+ export type RadioContextValue = {
4
+ checked: boolean;
5
+ };
6
+
7
+ export const [RadioContext, useRadioContext] =
8
+ createStrictContext<RadioContextValue>(
9
+ "Radio sub-components must be rendered inside a <Radio.Root>.",
10
+ );
@@ -0,0 +1,63 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { Radio } from "../Radio";
5
+
6
+ describe("Radio asChild composition", () => {
7
+ it("Root asChild delegates to the child element while keeping ARIA and select wiring", async () => {
8
+ // Arrange
9
+ const user = userEvent.setup();
10
+ const onCheckedChange = vi.fn();
11
+ render(
12
+ <Radio.Root asChild onCheckedChange={onCheckedChange} aria-label="Compact">
13
+ <li>Compact</li>
14
+ </Radio.Root>,
15
+ );
16
+ const radio = screen.getByRole("radio", { name: "Compact" });
17
+
18
+ // Assert element is the consumer's <li>
19
+ expect(radio.tagName).toBe("LI");
20
+ expect(radio).toHaveAttribute("aria-checked", "false");
21
+ expect(radio).toHaveAttribute("data-state", "unchecked");
22
+
23
+ // Act
24
+ await user.click(radio);
25
+
26
+ // Assert select still fires through composed onClick
27
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
28
+ expect(radio).toHaveAttribute("aria-checked", "true");
29
+ });
30
+
31
+ it("Root asChild lets the consumer override the role for menu-item contexts", () => {
32
+ // Arrange & Act
33
+ render(
34
+ <Radio.Root asChild aria-label="Compact">
35
+ <li role="menuitemradio">Compact</li>
36
+ </Radio.Root>,
37
+ );
38
+
39
+ // Assert
40
+ const item = screen.getByRole("menuitemradio", { name: "Compact" });
41
+ expect(item).toHaveAttribute("aria-checked", "false");
42
+ expect(item).toHaveAttribute("data-state", "unchecked");
43
+ });
44
+
45
+ it("Indicator asChild delegates rendering to the consumer's element", () => {
46
+ // Arrange & Act
47
+ render(
48
+ <Radio.Root defaultChecked aria-label="Compact">
49
+ <Radio.Indicator asChild>
50
+ <svg data-testid="dot" viewBox="0 0 10 10">
51
+ <circle cx="5" cy="5" r="3" />
52
+ </svg>
53
+ </Radio.Indicator>
54
+ </Radio.Root>,
55
+ );
56
+
57
+ // Assert
58
+ const dot = screen.getByTestId("dot");
59
+ expect(dot.tagName.toLowerCase()).toBe("svg");
60
+ expect(dot).toHaveAttribute("aria-hidden", "true");
61
+ expect(dot).toHaveAttribute("data-state", "checked");
62
+ });
63
+ });
@@ -0,0 +1,41 @@
1
+ import { render, screen } from "@testing-library/react";
2
+
3
+ import { Radio } from "../Radio";
4
+
5
+ describe("Radio basic rendering", () => {
6
+ it('renders a <button> with role="radio"', () => {
7
+ // Arrange & Act
8
+ render(<Radio.Root aria-label="Compact" />);
9
+ const radio = screen.getByRole("radio", { name: "Compact" });
10
+
11
+ // Assert
12
+ expect(radio.tagName).toBe("BUTTON");
13
+ });
14
+
15
+ it('defaults aria-checked to "false"', () => {
16
+ // Arrange & Act
17
+ render(<Radio.Root aria-label="Compact" />);
18
+ const radio = screen.getByRole("radio", { name: "Compact" });
19
+
20
+ // Assert
21
+ expect(radio).toHaveAttribute("aria-checked", "false");
22
+ });
23
+
24
+ it('defaults type="button" so the radio never submits an enclosing form', () => {
25
+ // Arrange & Act
26
+ render(<Radio.Root aria-label="Compact" />);
27
+ const radio = screen.getByRole("radio", { name: "Compact" });
28
+
29
+ // Assert
30
+ expect(radio).toHaveAttribute("type", "button");
31
+ });
32
+
33
+ it('sets data-state="unchecked" on the root when unchecked', () => {
34
+ // Arrange & Act
35
+ render(<Radio.Root aria-label="Compact" />);
36
+ const radio = screen.getByRole("radio", { name: "Compact" });
37
+
38
+ // Assert
39
+ expect(radio).toHaveAttribute("data-state", "unchecked");
40
+ });
41
+ });
@@ -0,0 +1,112 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ import { render, screen } from "@testing-library/react";
6
+
7
+ import { Radio } from "../Radio";
8
+
9
+ /**
10
+ * Auto-verification of the Radio styling contract (RFC 0004 §3.4 / D15). The
11
+ * `data-*` half of `registry/components/radio/contract.json` is *derived from
12
+ * and asserted against the rendered headless component* so it can never drift
13
+ * from what the component actually emits. Radio is binary: its surface is
14
+ * `data-state="checked" | "unchecked"` (always present) plus `data-disabled=""`
15
+ * when disabled. The authored half (root class, parts, custom properties) is a
16
+ * styling convention the headless layer does not emit and is checked by the
17
+ * generator drift-guards instead.
18
+ */
19
+ const contractPath = resolve(
20
+ dirname(fileURLToPath(import.meta.url)),
21
+ "../../../../../registry/components/radio/contract.json",
22
+ );
23
+
24
+ const contract = JSON.parse(readFileSync(contractPath, "utf8"));
25
+
26
+ /** Every `data-*` attribute name present on an element. */
27
+ function dataAttributeNames(element: Element): string[] {
28
+ return [...element.attributes]
29
+ .map((attribute) => attribute.name)
30
+ .filter((name) => name.startsWith("data-"))
31
+ .sort();
32
+ }
33
+
34
+ describe("Radio styling contract", () => {
35
+ const autoAttributes = contract.dataAttributes.filter(
36
+ (attribute: { source: string }) => attribute.source === "auto",
37
+ );
38
+
39
+ it("declares every data-* attribute as auto-verified against the component", () => {
40
+ // The Radio has no authored data-* surface; the whole contract is derived.
41
+ expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
42
+ });
43
+
44
+ it("emits exactly the contracted data-* attribute names across its states", () => {
45
+ const emitted = new Set<string>();
46
+ for (const ui of [
47
+ <Radio.Root key="checked" checked onCheckedChange={() => {}} aria-label="x">
48
+ <Radio.Indicator />
49
+ </Radio.Root>,
50
+ <Radio.Root key="unchecked" checked={false} onCheckedChange={() => {}} aria-label="x">
51
+ <Radio.Indicator />
52
+ </Radio.Root>,
53
+ <Radio.Root key="disabled" disabled aria-label="x">
54
+ <Radio.Indicator />
55
+ </Radio.Root>,
56
+ ]) {
57
+ const { unmount } = render(ui);
58
+ for (const name of dataAttributeNames(screen.getByRole("radio"))) {
59
+ emitted.add(name);
60
+ }
61
+ unmount();
62
+ }
63
+
64
+ const contracted = new Set(
65
+ autoAttributes.map((attribute: { name: string }) => attribute.name),
66
+ );
67
+ expect([...emitted].sort()).toEqual([...contracted].sort());
68
+ });
69
+
70
+ it("emits data-state with the documented value for each state", () => {
71
+ const stateEntries = autoAttributes.filter(
72
+ (attribute: { name: string }) => attribute.name === "data-state",
73
+ );
74
+
75
+ for (const entry of stateEntries) {
76
+ const { unmount } = render(
77
+ <Radio.Root
78
+ checked={entry.value === "checked"}
79
+ onCheckedChange={() => {}}
80
+ aria-label="x"
81
+ >
82
+ <Radio.Indicator />
83
+ </Radio.Root>,
84
+ );
85
+ expect(screen.getByRole("radio")).toHaveAttribute("data-state", entry.value);
86
+ unmount();
87
+ }
88
+ });
89
+
90
+ it("emits data-disabled exactly as the contract documents it", () => {
91
+ const entry = autoAttributes.find(
92
+ (attribute: { name: string }) => attribute.name === "data-disabled",
93
+ );
94
+
95
+ render(
96
+ <Radio.Root disabled aria-label="x">
97
+ <Radio.Indicator />
98
+ </Radio.Root>,
99
+ );
100
+ expect(screen.getByRole("radio")).toHaveAttribute(entry.name, entry.value);
101
+ });
102
+
103
+ it("omits data-disabled when the documented condition does not hold", () => {
104
+ render(
105
+ <Radio.Root aria-label="x">
106
+ <Radio.Indicator />
107
+ </Radio.Root>,
108
+ );
109
+
110
+ expect(screen.getByRole("radio")).not.toHaveAttribute("data-disabled");
111
+ });
112
+ });
@@ -0,0 +1,69 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useState } from "react";
4
+
5
+ import { Radio } from "../Radio";
6
+
7
+ describe("Radio controlled state", () => {
8
+ it("reflects the controlled `checked` prop", () => {
9
+ // Arrange & Act
10
+ const { rerender } = render(
11
+ <Radio.Root checked={false} onCheckedChange={() => {}} aria-label="Compact" />,
12
+ );
13
+ const radio = screen.getByRole("radio", { name: "Compact" });
14
+ expect(radio).toHaveAttribute("aria-checked", "false");
15
+
16
+ rerender(
17
+ <Radio.Root checked onCheckedChange={() => {}} aria-label="Compact" />,
18
+ );
19
+
20
+ // Assert
21
+ expect(radio).toHaveAttribute("aria-checked", "true");
22
+ expect(radio).toHaveAttribute("data-state", "checked");
23
+ });
24
+
25
+ it("does not update its rendered state when the parent refuses to update `checked`", async () => {
26
+ // Arrange
27
+ const user = userEvent.setup();
28
+ const onCheckedChange = vi.fn();
29
+ render(
30
+ <Radio.Root
31
+ checked={false}
32
+ onCheckedChange={onCheckedChange}
33
+ aria-label="Compact"
34
+ />,
35
+ );
36
+ const radio = screen.getByRole("radio", { name: "Compact" });
37
+
38
+ // Act
39
+ await user.click(radio);
40
+
41
+ // Assert: callback fired but the rendered state stays false because the
42
+ // parent did not flip the controlled prop.
43
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
44
+ expect(radio).toHaveAttribute("aria-checked", "false");
45
+ expect(radio).toHaveAttribute("data-state", "unchecked");
46
+ });
47
+
48
+ it("lets a parent drive the value end to end", async () => {
49
+ // Arrange
50
+ const user = userEvent.setup();
51
+ function Harness() {
52
+ const [checked, setChecked] = useState(false);
53
+ return (
54
+ <Radio.Root
55
+ checked={checked}
56
+ onCheckedChange={setChecked}
57
+ aria-label="Compact"
58
+ />
59
+ );
60
+ }
61
+ render(<Harness />);
62
+ const radio = screen.getByRole("radio", { name: "Compact" });
63
+ expect(radio).toHaveAttribute("aria-checked", "false");
64
+
65
+ // Act & Assert: the click is deferred to the parent, which selects it.
66
+ await user.click(radio);
67
+ expect(radio).toHaveAttribute("aria-checked", "true");
68
+ });
69
+ });
@@ -0,0 +1,23 @@
1
+ import { render, screen } from "@testing-library/react";
2
+
3
+ import { Radio } from "../Radio";
4
+
5
+ describe("Radio disabled state", () => {
6
+ it('sets data-disabled="" on the root so CSS can target the disabled state', () => {
7
+ // Arrange & Act
8
+ render(<Radio.Root disabled aria-label="Compact" />);
9
+ const radio = screen.getByRole("radio", { name: "Compact" });
10
+
11
+ // Assert
12
+ expect(radio).toHaveAttribute("data-disabled", "");
13
+ });
14
+
15
+ it("omits data-disabled when not disabled", () => {
16
+ // Arrange & Act
17
+ render(<Radio.Root aria-label="Compact" />);
18
+ const radio = screen.getByRole("radio", { name: "Compact" });
19
+
20
+ // Assert
21
+ expect(radio).not.toHaveAttribute("data-disabled");
22
+ });
23
+ });
@@ -0,0 +1,103 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { Radio } from "../Radio";
5
+
6
+ describe("Radio.Indicator", () => {
7
+ it("does not render its children when the radio is unchecked", () => {
8
+ // Arrange & Act
9
+ render(
10
+ <Radio.Root aria-label="Compact">
11
+ <Radio.Indicator>dot</Radio.Indicator>
12
+ </Radio.Root>,
13
+ );
14
+
15
+ // Assert
16
+ expect(screen.queryByText("dot")).not.toBeInTheDocument();
17
+ });
18
+
19
+ it("renders its children when the radio is checked", () => {
20
+ // Arrange & Act
21
+ render(
22
+ <Radio.Root defaultChecked aria-label="Compact">
23
+ <Radio.Indicator>dot</Radio.Indicator>
24
+ </Radio.Root>,
25
+ );
26
+
27
+ // Assert
28
+ expect(screen.getByText("dot")).toBeInTheDocument();
29
+ });
30
+
31
+ it("mounts in response to selecting", async () => {
32
+ // Arrange
33
+ const user = userEvent.setup();
34
+ render(
35
+ <Radio.Root aria-label="Compact">
36
+ <Radio.Indicator>dot</Radio.Indicator>
37
+ </Radio.Root>,
38
+ );
39
+ expect(screen.queryByText("dot")).not.toBeInTheDocument();
40
+
41
+ // Act
42
+ await user.click(screen.getByRole("radio", { name: "Compact" }));
43
+
44
+ // Assert
45
+ expect(screen.getByText("dot")).toBeInTheDocument();
46
+ });
47
+
48
+ it('carries aria-hidden="true" since it is decorative', () => {
49
+ // Arrange & Act
50
+ render(
51
+ <Radio.Root defaultChecked aria-label="Compact">
52
+ <Radio.Indicator data-testid="indicator">dot</Radio.Indicator>
53
+ </Radio.Root>,
54
+ );
55
+
56
+ // Assert
57
+ expect(screen.getByTestId("indicator")).toHaveAttribute(
58
+ "aria-hidden",
59
+ "true",
60
+ );
61
+ });
62
+
63
+ it("mirrors the radio's data-state on the indicator", () => {
64
+ // Arrange & Act
65
+ render(
66
+ <Radio.Root defaultChecked aria-label="Compact">
67
+ <Radio.Indicator data-testid="indicator">dot</Radio.Indicator>
68
+ </Radio.Root>,
69
+ );
70
+
71
+ // Assert
72
+ expect(screen.getByTestId("indicator")).toHaveAttribute(
73
+ "data-state",
74
+ "checked",
75
+ );
76
+ });
77
+
78
+ it("stays in the DOM while unchecked when forceMount is set", () => {
79
+ // Arrange & Act
80
+ render(
81
+ <Radio.Root aria-label="Compact">
82
+ <Radio.Indicator forceMount data-testid="indicator">
83
+ dot
84
+ </Radio.Indicator>
85
+ </Radio.Root>,
86
+ );
87
+ const indicator = screen.getByTestId("indicator");
88
+
89
+ // Assert
90
+ expect(indicator).toBeInTheDocument();
91
+ expect(indicator).toHaveAttribute("data-state", "unchecked");
92
+ });
93
+
94
+ it("throws when rendered outside Radio.Root", () => {
95
+ // Arrange
96
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
97
+
98
+ // Assert
99
+ expect(() => render(<Radio.Indicator>dot</Radio.Indicator>)).toThrow();
100
+
101
+ error.mockRestore();
102
+ });
103
+ });
@@ -0,0 +1,89 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { Radio } from "../Radio";
5
+
6
+ describe("Radio uncontrolled state", () => {
7
+ it("starts checked when defaultChecked is true", () => {
8
+ // Arrange & Act
9
+ render(<Radio.Root defaultChecked aria-label="Compact" />);
10
+ const radio = screen.getByRole("radio", { name: "Compact" });
11
+
12
+ // Assert
13
+ expect(radio).toHaveAttribute("aria-checked", "true");
14
+ expect(radio).toHaveAttribute("data-state", "checked");
15
+ });
16
+
17
+ it("selects on click from unchecked to checked", async () => {
18
+ // Arrange
19
+ const user = userEvent.setup();
20
+ render(<Radio.Root aria-label="Compact" />);
21
+ const radio = screen.getByRole("radio", { name: "Compact" });
22
+
23
+ // Act
24
+ await user.click(radio);
25
+
26
+ // Assert
27
+ expect(radio).toHaveAttribute("aria-checked", "true");
28
+ expect(radio).toHaveAttribute("data-state", "checked");
29
+ });
30
+
31
+ it("is a no-op when an already-selected radio is clicked (no toggle-off)", async () => {
32
+ // Arrange
33
+ const user = userEvent.setup();
34
+ const onCheckedChange = vi.fn();
35
+ render(
36
+ <Radio.Root
37
+ defaultChecked
38
+ onCheckedChange={onCheckedChange}
39
+ aria-label="Compact"
40
+ />,
41
+ );
42
+ const radio = screen.getByRole("radio", { name: "Compact" });
43
+
44
+ // Act
45
+ await user.click(radio);
46
+
47
+ // Assert: stays checked and the callback never fires for a re-select.
48
+ expect(radio).toHaveAttribute("aria-checked", "true");
49
+ expect(radio).toHaveAttribute("data-state", "checked");
50
+ expect(onCheckedChange).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it("calls onCheckedChange with true the first time it is selected", async () => {
54
+ // Arrange
55
+ const user = userEvent.setup();
56
+ const onCheckedChange = vi.fn();
57
+ render(<Radio.Root onCheckedChange={onCheckedChange} aria-label="Compact" />);
58
+ const radio = screen.getByRole("radio", { name: "Compact" });
59
+
60
+ // Act
61
+ await user.click(radio);
62
+ await user.click(radio);
63
+
64
+ // Assert: selection is one-way, so the second click is swallowed.
65
+ expect(onCheckedChange).toHaveBeenCalledTimes(1);
66
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
67
+ });
68
+
69
+ it("composes the consumer's onClick with the internal select (consumer runs first)", async () => {
70
+ // Arrange
71
+ const user = userEvent.setup();
72
+ const order: string[] = [];
73
+ const onClick = vi.fn(() => order.push("consumer"));
74
+ const onCheckedChange = vi.fn(() => order.push("internal"));
75
+ render(
76
+ <Radio.Root
77
+ onClick={onClick}
78
+ onCheckedChange={onCheckedChange}
79
+ aria-label="Compact"
80
+ />,
81
+ );
82
+
83
+ // Act
84
+ await user.click(screen.getByRole("radio", { name: "Compact" }));
85
+
86
+ // Assert
87
+ expect(order).toEqual(["consumer", "internal"]);
88
+ });
89
+ });
@@ -0,0 +1,2 @@
1
+ export { useRadioRoot } from "./useRadioRoot";
2
+ export { useRadioContext } from "./useRadioContext";
@@ -0,0 +1 @@
1
+ export { useRadioContext } from "../RadioContext";
@@ -0,0 +1,32 @@
1
+ import { useCallback } from "react";
2
+
3
+ import { useControllableState } from "../../hooks/index.ts";
4
+
5
+ type UseRadioRootArgs = {
6
+ defaultChecked?: boolean;
7
+ checked?: boolean;
8
+ onCheckedChange?: (checked: boolean) => void;
9
+ };
10
+
11
+ export function useRadioRoot({
12
+ defaultChecked,
13
+ checked: controlledChecked,
14
+ onCheckedChange,
15
+ }: UseRadioRootArgs) {
16
+ const [checked, setChecked] = useControllableState<boolean>(
17
+ controlledChecked,
18
+ defaultChecked ?? false,
19
+ );
20
+
21
+ const select = useCallback(() => {
22
+ // A radio only ever moves *into* the selected state — re-clicking the
23
+ // already-selected radio is a no-op (no toggle-off), per the WAI-ARIA
24
+ // radio convention. De-selection happens when a sibling is chosen, which
25
+ // is the grouping consumer's concern, not this lone control's.
26
+ if (checked) return;
27
+ setChecked(true);
28
+ onCheckedChange?.(true);
29
+ }, [checked, setChecked, onCheckedChange]);
30
+
31
+ return { checked, select };
32
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./Radio";
2
+ export * from "./types";
@@ -0,0 +1,56 @@
1
+ import { ComponentProps, ReactNode, Ref } from "react";
2
+
3
+ /**
4
+ * Props for {@link Radio.Indicator} — all `<span>` attributes plus
5
+ * `forceMount` (keep mounted while unchecked for exit animations) and the
6
+ * `asChild` escape hatch.
7
+ */
8
+ export type RadioIndicatorProps = ComponentProps<"span"> & {
9
+ children?: ReactNode;
10
+ forceMount?: boolean;
11
+ asChild?: boolean;
12
+ };
13
+
14
+ /**
15
+ * Shared base for both {@link RadioRootProps} variants — the native
16
+ * `<button>` attributes (minus the ones the component owns) plus the
17
+ * `asChild` escape hatch and a typed `ref`.
18
+ */
19
+ export type RadioRootBaseProps = Omit<
20
+ ComponentProps<"button">,
21
+ "type" | "role" | "aria-checked" | "defaultChecked"
22
+ > & {
23
+ asChild?: boolean;
24
+ ref?: Ref<HTMLButtonElement>;
25
+ };
26
+
27
+ /**
28
+ * Uncontrolled variant of {@link RadioRootProps}: the component owns the
29
+ * checked value. Pass `defaultChecked` (or omit it); `onCheckedChange` is
30
+ * optional and `checked` is forbidden.
31
+ */
32
+ export type RadioRootUncontrolledProps = RadioRootBaseProps & {
33
+ defaultChecked?: boolean;
34
+ checked?: never;
35
+ onCheckedChange?: (checked: boolean) => void;
36
+ };
37
+
38
+ /**
39
+ * Controlled variant of {@link RadioRootProps}: the parent owns the checked
40
+ * value. Pass `checked` and `onCheckedChange` together; `defaultChecked` is
41
+ * forbidden.
42
+ */
43
+ export type RadioRootControlledProps = RadioRootBaseProps & {
44
+ defaultChecked?: never;
45
+ checked: boolean;
46
+ onCheckedChange: (checked: boolean) => void;
47
+ };
48
+
49
+ /**
50
+ * Props for {@link Radio.Root}. A discriminated union of
51
+ * {@link RadioRootUncontrolledProps} and {@link RadioRootControlledProps},
52
+ * so TypeScript accepts exactly one state mode.
53
+ */
54
+ export type RadioRootProps =
55
+ | RadioRootUncontrolledProps
56
+ | RadioRootControlledProps;
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export * from "./MillerColumns/index.ts";
21
21
  export * from "./Modal/index.ts";
22
22
  export * from "./Portal/index.ts";
23
23
  export * from "./Progress/index.ts";
24
+ export * from "./Radio/index.ts";
24
25
  export * from "./RadioCard/index.ts";
25
26
  export * from "./RadioGroup/index.ts";
26
27
  export * from "./Select/index.ts";