@primitiv-ui/react 0.1.13 → 0.1.15
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/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.15",
|
|
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 @@
|
|
|
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,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";
|