@primitiv-ui/react 0.1.12 → 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 +1 -0
- package/package.json +1 -1
- package/src/Checkbox/__tests__/Checkbox.contract.test.tsx +117 -0
- package/src/Field/__tests__/Field.contract.test.tsx +87 -0
- package/src/Input/__tests__/Input.contract.test.tsx +77 -0
- package/src/InputGroup/__tests__/InputGroup.contract.test.tsx +60 -0
- package/src/Radio/README.md +157 -0
- package/src/Radio/Radio.tsx +213 -0
- package/src/Radio/RadioContext.ts +10 -0
- package/src/Radio/__tests__/Radio.asChild.test.tsx +63 -0
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +41 -0
- package/src/Radio/__tests__/Radio.contract.test.tsx +112 -0
- package/src/Radio/__tests__/Radio.controlled-state.test.tsx +69 -0
- package/src/Radio/__tests__/Radio.disabled.test.tsx +23 -0
- package/src/Radio/__tests__/Radio.indicator.test.tsx +103 -0
- package/src/Radio/__tests__/Radio.uncontrolled-state.test.tsx +89 -0
- package/src/Radio/hooks/index.ts +2 -0
- package/src/Radio/hooks/useRadioContext.ts +1 -0
- package/src/Radio/hooks/useRadioRoot.ts +32 -0
- package/src/Radio/index.ts +2 -0
- package/src/Radio/types.ts +56 -0
- package/src/index.ts +1 -0
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.
|
|
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,87 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { render } from "@testing-library/react";
|
|
6
|
+
|
|
7
|
+
import { Field } from "../Field";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Auto-verification of the Field styling contract (RFC 0004 §3.4 / D15). The
|
|
11
|
+
* `data-*` half of `registry/components/field/contract.json` is *derived from and
|
|
12
|
+
* asserted against the rendered headless `Field.Root`* so it can never drift from
|
|
13
|
+
* what the component emits. The root always carries `data-field`, plus
|
|
14
|
+
* `data-field-invalid` / `data-field-disabled` / `data-field-required` when the
|
|
15
|
+
* matching flag is set. The authored half (part classes, 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/field/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
|
+
/** Render a `Field.Root` with the given flags and return its `[data-field]` element. */
|
|
35
|
+
function renderRoot(props: Record<string, boolean>): Element {
|
|
36
|
+
const { container } = render(<Field.Root {...props} />);
|
|
37
|
+
return container.querySelector("[data-field]")!;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("Field styling contract", () => {
|
|
41
|
+
const autoAttributes = contract.dataAttributes.filter(
|
|
42
|
+
(attribute: { source: string }) => attribute.source === "auto",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
it("declares every data-* attribute as auto-verified against the component", () => {
|
|
46
|
+
// The Field has no authored data-* surface; the whole contract is derived.
|
|
47
|
+
expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("emits exactly the contracted data-* attribute names across its states", () => {
|
|
51
|
+
const emitted = new Set<string>();
|
|
52
|
+
for (const props of [{}, { invalid: true, disabled: true, required: true }]) {
|
|
53
|
+
for (const name of dataAttributeNames(renderRoot(props))) {
|
|
54
|
+
emitted.add(name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const contracted = new Set(
|
|
59
|
+
autoAttributes.map((attribute: { name: string }) => attribute.name),
|
|
60
|
+
);
|
|
61
|
+
expect([...emitted].sort()).toEqual([...contracted].sort());
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("always emits data-field regardless of state", () => {
|
|
65
|
+
expect(renderRoot({})).toHaveAttribute("data-field", "");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("emits each conditional attribute exactly as the contract documents it", () => {
|
|
69
|
+
const conditional = autoAttributes.filter(
|
|
70
|
+
(attribute: { when: string }) => attribute.when !== "always",
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
for (const entry of conditional) {
|
|
74
|
+
const root = renderRoot({ [entry.when]: true });
|
|
75
|
+
expect(root).toHaveAttribute(entry.name, entry.value);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("omits each conditional attribute when its flag is not set", () => {
|
|
80
|
+
const root = renderRoot({});
|
|
81
|
+
for (const entry of autoAttributes.filter(
|
|
82
|
+
(attribute: { when: string }) => attribute.when !== "always",
|
|
83
|
+
)) {
|
|
84
|
+
expect(root).not.toHaveAttribute(entry.name);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
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 { Input } from "../Input";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Auto-verification of the Input styling contract (RFC 0004 §3.4 / D15). The
|
|
11
|
+
* `data-*` half of `registry/components/input/contract.json` is *derived from and
|
|
12
|
+
* asserted against the rendered headless component* so it can never drift from
|
|
13
|
+
* what the component actually emits. Input's only data-* surface is
|
|
14
|
+
* `data-disabled=""` when disabled — `invalid` is an `aria-invalid` concern, not
|
|
15
|
+
* a data attribute. The authored half (root class, 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/input/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("Input 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 Input 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
|
+
<Input key="default" aria-label="x" />,
|
|
48
|
+
<Input key="disabled" aria-label="x" disabled />,
|
|
49
|
+
]) {
|
|
50
|
+
const { unmount } = render(ui);
|
|
51
|
+
for (const name of dataAttributeNames(screen.getByRole("textbox"))) {
|
|
52
|
+
emitted.add(name);
|
|
53
|
+
}
|
|
54
|
+
unmount();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const contracted = new Set(
|
|
58
|
+
autoAttributes.map((attribute: { name: string }) => attribute.name),
|
|
59
|
+
);
|
|
60
|
+
expect([...emitted].sort()).toEqual([...contracted].sort());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("emits data-disabled exactly as the contract documents it", () => {
|
|
64
|
+
const entry = autoAttributes.find(
|
|
65
|
+
(attribute: { name: string }) => attribute.name === "data-disabled",
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
render(<Input aria-label="x" disabled />);
|
|
69
|
+
expect(screen.getByRole("textbox")).toHaveAttribute(entry.name, entry.value);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("omits data-disabled when the documented condition does not hold", () => {
|
|
73
|
+
render(<Input aria-label="x" />);
|
|
74
|
+
|
|
75
|
+
expect(screen.getByRole("textbox")).not.toHaveAttribute("data-disabled");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { render } from "@testing-library/react";
|
|
6
|
+
|
|
7
|
+
import { InputGroup } from "../InputGroup";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Auto-verification of the InputGroup styling contract (RFC 0004 §3.4 / D15). The
|
|
11
|
+
* `data-*` half of `registry/components/input-group/contract.json` is *derived
|
|
12
|
+
* from and asserted against the rendered headless `InputGroup.Root`* so it can
|
|
13
|
+
* never drift. The root always carries `data-input-group`; the adornment slots
|
|
14
|
+
* are styled through their part classes, not a contracted data attribute. The
|
|
15
|
+
* authored half (part classes, custom properties) is a styling convention the
|
|
16
|
+
* headless layer does not emit and is checked by the generator drift-guards.
|
|
17
|
+
*/
|
|
18
|
+
const contractPath = resolve(
|
|
19
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
20
|
+
"../../../../../registry/components/input-group/contract.json",
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const contract = JSON.parse(readFileSync(contractPath, "utf8"));
|
|
24
|
+
|
|
25
|
+
/** Every `data-*` attribute name present on an element. */
|
|
26
|
+
function dataAttributeNames(element: Element): string[] {
|
|
27
|
+
return [...element.attributes]
|
|
28
|
+
.map((attribute) => attribute.name)
|
|
29
|
+
.filter((name) => name.startsWith("data-"))
|
|
30
|
+
.sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("InputGroup styling contract", () => {
|
|
34
|
+
const autoAttributes = contract.dataAttributes.filter(
|
|
35
|
+
(attribute: { source: string }) => attribute.source === "auto",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
it("declares every data-* attribute as auto-verified against the component", () => {
|
|
39
|
+
// The InputGroup root has no authored data-* surface; the contract is derived.
|
|
40
|
+
expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("emits exactly the contracted data-* attribute names on the root", () => {
|
|
44
|
+
const { container } = render(<InputGroup.Root />);
|
|
45
|
+
const root = container.querySelector("[data-input-group]")!;
|
|
46
|
+
|
|
47
|
+
const contracted = autoAttributes
|
|
48
|
+
.map((attribute: { name: string }) => attribute.name)
|
|
49
|
+
.sort();
|
|
50
|
+
expect(dataAttributeNames(root)).toEqual(contracted);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("always emits data-input-group", () => {
|
|
54
|
+
const { container } = render(<InputGroup.Root />);
|
|
55
|
+
expect(container.querySelector("[data-input-group]")).toHaveAttribute(
|
|
56
|
+
"data-input-group",
|
|
57
|
+
"",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -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
|
+
```
|