@primitiv-ui/react 0.1.15 → 0.1.17
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 +3 -3
- package/package.json +1 -1
- package/src/Checkbox/Checkbox.tsx +96 -120
- package/src/Checkbox/CheckboxContext.ts +8 -0
- package/src/Checkbox/README.md +69 -88
- package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +48 -10
- package/src/Checkbox/__tests__/Checkbox.contract.test.tsx +29 -40
- package/src/Checkbox/__tests__/Checkbox.controlled-state.test.tsx +16 -24
- package/src/Checkbox/__tests__/Checkbox.disabled.test.tsx +33 -3
- package/src/Checkbox/__tests__/Checkbox.form.test.tsx +53 -0
- package/src/Checkbox/__tests__/Checkbox.indeterminate.test.tsx +28 -34
- package/src/Checkbox/__tests__/Checkbox.indicator.test.tsx +27 -52
- package/src/Checkbox/__tests__/Checkbox.uncontrolled-state.test.tsx +33 -17
- package/src/Checkbox/hooks/index.ts +1 -0
- package/src/Checkbox/hooks/useCheckboxInput.ts +49 -0
- package/src/Checkbox/types.ts +24 -17
- package/src/Radio/README.md +89 -86
- package/src/Radio/Radio.tsx +87 -98
- package/src/Radio/RadioContext.ts +9 -0
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +34 -9
- package/src/Radio/__tests__/Radio.contract.test.tsx +30 -36
- package/src/Radio/__tests__/Radio.controlled-state.test.tsx +21 -11
- package/src/Radio/__tests__/Radio.disabled.test.tsx +31 -6
- package/src/Radio/__tests__/Radio.native-grouping.test.tsx +77 -0
- package/src/Radio/__tests__/Radio.parts.test.tsx +76 -0
- package/src/Radio/__tests__/Radio.uncontrolled-state.test.tsx +39 -32
- package/src/Radio/hooks/useRadioRoot.ts +32 -16
- package/src/Radio/types.ts +30 -16
- package/src/Switch/README.md +45 -57
- package/src/Switch/Switch.tsx +78 -65
- package/src/Switch/SwitchContext.ts +3 -1
- package/src/Switch/__tests__/Switch.asChild.test.tsx +16 -34
- package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +47 -28
- package/src/Switch/__tests__/Switch.contract.test.tsx +21 -16
- package/src/Switch/__tests__/Switch.controlled-state.test.tsx +10 -12
- package/src/Switch/__tests__/Switch.disabled.test.tsx +12 -17
- package/src/Switch/__tests__/Switch.form.test.tsx +55 -0
- package/src/Switch/__tests__/Switch.keyboard-interaction.test.tsx +4 -4
- package/src/Switch/__tests__/Switch.uncontrolled-state.test.tsx +55 -20
- package/src/Switch/hooks/useSwitchRoot.ts +29 -11
- package/src/Switch/types.ts +20 -10
- package/src/Checkbox/__tests__/Checkbox.asChild.test.tsx +0 -69
- package/src/Radio/__tests__/Radio.asChild.test.tsx +0 -63
- package/src/Radio/__tests__/Radio.indicator.test.tsx +0 -103
|
@@ -6,12 +6,16 @@ import { Checkbox } from "../Checkbox";
|
|
|
6
6
|
describe("Checkbox uncontrolled state", () => {
|
|
7
7
|
it("starts checked when defaultChecked is true", () => {
|
|
8
8
|
// Arrange & Act
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<Checkbox.Root defaultChecked aria-label="Accept terms" />,
|
|
11
|
+
);
|
|
11
12
|
|
|
12
13
|
// Assert
|
|
13
|
-
expect(
|
|
14
|
-
expect(
|
|
14
|
+
expect(screen.getByRole("checkbox", { name: "Accept terms" })).toBeChecked();
|
|
15
|
+
expect(container.querySelector("label")).toHaveAttribute(
|
|
16
|
+
"data-state",
|
|
17
|
+
"checked",
|
|
18
|
+
);
|
|
15
19
|
});
|
|
16
20
|
|
|
17
21
|
it("toggles on click from unchecked to checked", async () => {
|
|
@@ -24,8 +28,7 @@ describe("Checkbox uncontrolled state", () => {
|
|
|
24
28
|
await user.click(checkbox);
|
|
25
29
|
|
|
26
30
|
// Assert
|
|
27
|
-
expect(checkbox).
|
|
28
|
-
expect(checkbox).toHaveAttribute("data-state", "checked");
|
|
31
|
+
expect(checkbox).toBeChecked();
|
|
29
32
|
});
|
|
30
33
|
|
|
31
34
|
it("toggles on click from checked to unchecked", async () => {
|
|
@@ -33,15 +36,13 @@ describe("Checkbox uncontrolled state", () => {
|
|
|
33
36
|
const user = userEvent.setup();
|
|
34
37
|
render(<Checkbox.Root defaultChecked aria-label="Accept terms" />);
|
|
35
38
|
const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
|
|
36
|
-
|
|
37
|
-
expect(checkbox).toHaveAttribute("aria-checked", "true");
|
|
39
|
+
expect(checkbox).toBeChecked();
|
|
38
40
|
|
|
39
41
|
// Act
|
|
40
42
|
await user.click(checkbox);
|
|
41
43
|
|
|
42
44
|
// Assert
|
|
43
|
-
expect(checkbox).
|
|
44
|
-
expect(checkbox).toHaveAttribute("data-state", "unchecked");
|
|
45
|
+
expect(checkbox).not.toBeChecked();
|
|
45
46
|
});
|
|
46
47
|
|
|
47
48
|
it("calls onCheckedChange with the new boolean value", async () => {
|
|
@@ -49,10 +50,7 @@ describe("Checkbox uncontrolled state", () => {
|
|
|
49
50
|
const user = userEvent.setup();
|
|
50
51
|
const onCheckedChange = vi.fn();
|
|
51
52
|
render(
|
|
52
|
-
<Checkbox.Root
|
|
53
|
-
onCheckedChange={onCheckedChange}
|
|
54
|
-
aria-label="Accept terms"
|
|
55
|
-
/>,
|
|
53
|
+
<Checkbox.Root onCheckedChange={onCheckedChange} aria-label="Accept terms" />,
|
|
56
54
|
);
|
|
57
55
|
const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
|
|
58
56
|
|
|
@@ -65,15 +63,15 @@ describe("Checkbox uncontrolled state", () => {
|
|
|
65
63
|
expect(onCheckedChange).toHaveBeenNthCalledWith(2, false);
|
|
66
64
|
});
|
|
67
65
|
|
|
68
|
-
it("composes
|
|
66
|
+
it("composes a consumer onChange, running it before the internal handler", async () => {
|
|
69
67
|
// Arrange
|
|
70
68
|
const user = userEvent.setup();
|
|
71
69
|
const order: string[] = [];
|
|
72
|
-
const
|
|
70
|
+
const onChange = vi.fn(() => order.push("consumer"));
|
|
73
71
|
const onCheckedChange = vi.fn(() => order.push("internal"));
|
|
74
72
|
render(
|
|
75
73
|
<Checkbox.Root
|
|
76
|
-
|
|
74
|
+
onChange={onChange}
|
|
77
75
|
onCheckedChange={onCheckedChange}
|
|
78
76
|
aria-label="Accept terms"
|
|
79
77
|
/>,
|
|
@@ -86,4 +84,22 @@ describe("Checkbox uncontrolled state", () => {
|
|
|
86
84
|
expect(order).toEqual(["consumer", "internal"]);
|
|
87
85
|
});
|
|
88
86
|
|
|
87
|
+
it("lets a consumer onChange veto the internal handler via preventDefault", async () => {
|
|
88
|
+
// Arrange
|
|
89
|
+
const user = userEvent.setup();
|
|
90
|
+
const onCheckedChange = vi.fn();
|
|
91
|
+
render(
|
|
92
|
+
<Checkbox.Root
|
|
93
|
+
onChange={(event) => event.preventDefault()}
|
|
94
|
+
onCheckedChange={onCheckedChange}
|
|
95
|
+
aria-label="Accept terms"
|
|
96
|
+
/>,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Act
|
|
100
|
+
await user.click(screen.getByRole("checkbox", { name: "Accept terms" }));
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
103
|
+
expect(onCheckedChange).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
89
105
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { ChangeEvent } from "react";
|
|
3
|
+
|
|
4
|
+
import { CheckboxChangeHandler, CheckedState } from "../types";
|
|
5
|
+
|
|
6
|
+
type UseCheckboxInputArgs = {
|
|
7
|
+
defaultChecked?: CheckedState;
|
|
8
|
+
checked?: CheckedState;
|
|
9
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
10
|
+
onChange?: CheckboxChangeHandler;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the controlled / uncontrolled tri-state for a {@link Checkbox.Root}
|
|
15
|
+
* and produces the props to spread onto its native `<input type="checkbox">`.
|
|
16
|
+
*
|
|
17
|
+
* Distinct from {@link useCheckboxRoot}, which drives the *button*-based
|
|
18
|
+
* `menuitemcheckbox` items in Dropdown / ContextMenu via a `toggle()` callback.
|
|
19
|
+
* This hook instead lets the native input own the toggling: the boolean half of
|
|
20
|
+
* the state maps to the input's `checked` / `defaultChecked`, and the
|
|
21
|
+
* `"indeterminate"` value is applied by the component via the input's
|
|
22
|
+
* `.indeterminate` DOM property. A native checkbox only toggles between booleans
|
|
23
|
+
* by user action — clicking a mixed checkbox resolves it to `true` — so
|
|
24
|
+
* `onCheckedChange` is always called with a boolean.
|
|
25
|
+
*/
|
|
26
|
+
export function useCheckboxInput({
|
|
27
|
+
defaultChecked,
|
|
28
|
+
checked: controlledChecked,
|
|
29
|
+
onCheckedChange,
|
|
30
|
+
onChange,
|
|
31
|
+
}: UseCheckboxInputArgs) {
|
|
32
|
+
const isControlled = controlledChecked !== undefined;
|
|
33
|
+
const [mirror, setMirror] = useState<CheckedState>(defaultChecked ?? false);
|
|
34
|
+
const checked = isControlled ? controlledChecked : mirror;
|
|
35
|
+
|
|
36
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
37
|
+
onChange?.(event);
|
|
38
|
+
if (event.defaultPrevented) return;
|
|
39
|
+
const next = event.target.checked;
|
|
40
|
+
if (!isControlled) setMirror(next);
|
|
41
|
+
onCheckedChange?.(next);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const inputStateProps = isControlled
|
|
45
|
+
? { checked: controlledChecked === true }
|
|
46
|
+
: { defaultChecked: defaultChecked === true };
|
|
47
|
+
|
|
48
|
+
return { checked, handleChange, inputStateProps };
|
|
49
|
+
}
|
package/src/Checkbox/types.ts
CHANGED
|
@@ -1,47 +1,51 @@
|
|
|
1
|
-
import { ComponentProps, ReactNode, Ref } from "react";
|
|
1
|
+
import { ChangeEventHandler, ComponentProps, ReactNode, Ref } from "react";
|
|
2
2
|
|
|
3
3
|
/** The checked value of a checkbox — `true`, `false`, or `"indeterminate"` (the tri-state). */
|
|
4
4
|
export type CheckedState = boolean | "indeterminate";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Props for {@link Checkbox.Indicator} —
|
|
8
|
-
* `
|
|
9
|
-
* `
|
|
7
|
+
* Props for {@link Checkbox.Indicator} — the decorative mark. All `<span>`
|
|
8
|
+
* attributes plus the `asChild` escape hatch. The indicator is always mounted;
|
|
9
|
+
* its visibility is a CSS concern, revealed off the input's native `:checked` /
|
|
10
|
+
* `:indeterminate` state.
|
|
10
11
|
*/
|
|
11
12
|
export type CheckboxIndicatorProps = ComponentProps<"span"> & {
|
|
12
13
|
children?: ReactNode;
|
|
13
|
-
forceMount?: boolean;
|
|
14
14
|
asChild?: boolean;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Shared base for both {@link CheckboxRootProps} variants — the native
|
|
19
|
-
* `<
|
|
20
|
-
* `
|
|
19
|
+
* `<input type="checkbox">` attributes (minus the ones the component owns) plus
|
|
20
|
+
* the `onCheckedChange` convenience callback. `className` / `style` here style
|
|
21
|
+
* the **box** (the visible control), not the hidden input; everything else
|
|
22
|
+
* spreads onto the input, because semantically the Root *is* the checkbox.
|
|
21
23
|
*/
|
|
22
24
|
export type CheckboxRootBaseProps = Omit<
|
|
23
|
-
ComponentProps<"
|
|
24
|
-
"type" | "
|
|
25
|
+
ComponentProps<"input">,
|
|
26
|
+
"type" | "checked" | "defaultChecked"
|
|
25
27
|
> & {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
/** Fired with the new boolean checked value on every user toggle. */
|
|
29
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
/** Forwarded to the underlying native `<input type="checkbox">`. */
|
|
32
|
+
ref?: Ref<HTMLInputElement>;
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* Uncontrolled variant of {@link CheckboxRootProps}: the component owns the
|
|
32
|
-
* checked value. Pass `defaultChecked` (or omit it); `
|
|
33
|
-
*
|
|
37
|
+
* checked value. Pass `defaultChecked` (or omit it); `checked` is forbidden.
|
|
38
|
+
* `defaultChecked` may be `"indeterminate"` for a mixed-on-mount checkbox.
|
|
34
39
|
*/
|
|
35
40
|
export type CheckboxRootUncontrolledProps = CheckboxRootBaseProps & {
|
|
36
41
|
defaultChecked?: CheckedState;
|
|
37
42
|
checked?: never;
|
|
38
|
-
onCheckedChange?: (checked: boolean) => void;
|
|
39
43
|
};
|
|
40
44
|
|
|
41
45
|
/**
|
|
42
|
-
* Controlled variant of {@link CheckboxRootProps}: the parent owns the
|
|
43
|
-
*
|
|
44
|
-
*
|
|
46
|
+
* Controlled variant of {@link CheckboxRootProps}: the parent owns the checked
|
|
47
|
+
* value. Pass `checked` and `onCheckedChange` together; `defaultChecked` is
|
|
48
|
+
* forbidden.
|
|
45
49
|
*/
|
|
46
50
|
export type CheckboxRootControlledProps = CheckboxRootBaseProps & {
|
|
47
51
|
defaultChecked?: never;
|
|
@@ -58,3 +62,6 @@ export type CheckboxRootControlledProps = CheckboxRootBaseProps & {
|
|
|
58
62
|
export type CheckboxRootProps =
|
|
59
63
|
| CheckboxRootUncontrolledProps
|
|
60
64
|
| CheckboxRootControlledProps;
|
|
65
|
+
|
|
66
|
+
/** Internal: the native `onChange` shape the Root composes with its own. */
|
|
67
|
+
export type CheckboxChangeHandler = ChangeEventHandler<HTMLInputElement>;
|
package/src/Radio/README.md
CHANGED
|
@@ -1,116 +1,135 @@
|
|
|
1
1
|
# Radio
|
|
2
2
|
|
|
3
|
-
A headless, accessible, **standalone**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
>
|
|
15
|
-
>
|
|
16
|
-
>
|
|
17
|
-
> [`RadioGroup`](../RadioGroup/README.md)
|
|
18
|
-
>
|
|
3
|
+
A headless, accessible, **standalone** radio control built on a **real native
|
|
4
|
+
`<input type="radio">`**.
|
|
5
|
+
|
|
6
|
+
Radio renders a `<label>` that is the visible box, wrapping a visually-hidden
|
|
7
|
+
native `<input type="radio">` (the focusable, form-participating control) and a
|
|
8
|
+
decorative `Indicator` dot. Because the underlying element is a genuine native
|
|
9
|
+
radio, siblings sharing a `name` form a **native radio group** — the browser,
|
|
10
|
+
not JavaScript, enforces single-selection and silently deselects the others.
|
|
11
|
+
It also submits with an enclosing form, resets with it, and gets keyboard
|
|
12
|
+
activation and focus for free.
|
|
13
|
+
|
|
14
|
+
> **Radio vs. RadioGroup.** `Radio` is the lone native control for when you own
|
|
15
|
+
> the grouping (a shared `name`, a bespoke layout, a single opt-in). For a
|
|
16
|
+
> managed set with roving-tabindex keyboard navigation, arrow-key selection, and
|
|
17
|
+
> disabled-item skipping, reach for [`RadioGroup`](../RadioGroup/README.md) — it
|
|
18
|
+
> composes `role="radio"` items and owns the shared selected value itself.
|
|
19
19
|
|
|
20
20
|
```tsx
|
|
21
21
|
import { Radio } from "@primitiv-ui/react";
|
|
22
22
|
|
|
23
|
-
<Radio.Root defaultChecked aria-label="
|
|
24
|
-
<Radio.Indicator
|
|
25
|
-
<DotIcon />
|
|
26
|
-
</Radio.Indicator>
|
|
23
|
+
<Radio.Root name="density" value="comfortable" defaultChecked aria-label="Comfortable">
|
|
24
|
+
<Radio.Indicator />
|
|
27
25
|
</Radio.Root>;
|
|
28
26
|
```
|
|
29
27
|
|
|
30
28
|
## Sub-components
|
|
31
29
|
|
|
32
|
-
| Export | Element
|
|
33
|
-
| ----------------- |
|
|
34
|
-
| `Radio.Root` | `<
|
|
35
|
-
| `Radio.Indicator` | `<span>`
|
|
30
|
+
| Export | Element | Notes |
|
|
31
|
+
| ----------------- | -------------------------------- | ---------------------------------------------------------------------------------- |
|
|
32
|
+
| `Radio.Root` | `<label>` + hidden `<input>` | The styled box. `data-state`, `data-disabled` on the label; `name`/`value`/`aria-*`/`ref` go to the input. |
|
|
33
|
+
| `Radio.Indicator` | `<span>` | `aria-hidden="true"`. Always mounted; CSS reveals it. `asChild` |
|
|
34
|
+
|
|
35
|
+
`Radio.Root`'s `className` / `style` style the **box** (the `<label>` you see).
|
|
36
|
+
Every other prop spreads onto the hidden `<input>`, because semantically the
|
|
37
|
+
Root *is* the radio: pass `name`, `value`, `id`, `required`, `aria-*`, `ref`,
|
|
38
|
+
etc. straight to `Radio.Root`.
|
|
39
|
+
|
|
40
|
+
## Native grouping (the headline)
|
|
41
|
+
|
|
42
|
+
Give sibling radios the same `name` and the browser groups them. No shared
|
|
43
|
+
state, no `RadioGroup`, no controlled wiring required:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
{plans.map((plan) => (
|
|
47
|
+
<Radio.Root key={plan.id} name="plan" value={plan.id} aria-label={plan.label}>
|
|
48
|
+
<Radio.Indicator />
|
|
49
|
+
</Radio.Root>
|
|
50
|
+
))}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Selecting one deselects the rest, and the chosen `value` submits under `plan`
|
|
54
|
+
with an enclosing `<form>`.
|
|
36
55
|
|
|
37
56
|
## Selected state
|
|
38
57
|
|
|
39
58
|
Radio is binary. The `checked` / `defaultChecked` value is a `boolean`:
|
|
40
59
|
|
|
41
|
-
| Value |
|
|
42
|
-
| ------- |
|
|
43
|
-
| `true` |
|
|
44
|
-
| `false` |
|
|
60
|
+
| Value | Native | `data-state` | Dot visible? |
|
|
61
|
+
| ------- | --------- | ------------- | ------------ |
|
|
62
|
+
| `true` | `:checked`| `"checked"` | Yes |
|
|
63
|
+
| `false` | — | `"unchecked"` | No |
|
|
45
64
|
|
|
46
|
-
|
|
47
|
-
clicking the already-selected radio is a no-op
|
|
48
|
-
|
|
49
|
-
happens when a sibling is chosen, which is the grouping consumer's concern.
|
|
65
|
+
A native radio only ever moves *into* the checked state by user action —
|
|
66
|
+
clicking the already-selected radio is a no-op, and selection moves off it only
|
|
67
|
+
when a sibling is chosen. You get that for free.
|
|
50
68
|
|
|
51
69
|
## State modes
|
|
52
70
|
|
|
53
71
|
- **Uncontrolled** — pass `defaultChecked` (or omit for unselected on mount).
|
|
54
|
-
|
|
72
|
+
The **browser** owns the value, so native `name`-grouping works.
|
|
73
|
+
- **Controlled** — pass `checked` **and** `onCheckedChange` together. The parent
|
|
74
|
+
owns the value (and the grouping).
|
|
55
75
|
|
|
56
76
|
The two shapes are statically discriminated at the type level; TypeScript
|
|
57
77
|
rejects mixing them.
|
|
58
78
|
|
|
59
79
|
```tsx
|
|
60
|
-
// Uncontrolled
|
|
61
|
-
<Radio.Root defaultChecked
|
|
80
|
+
// Uncontrolled native group
|
|
81
|
+
<Radio.Root name="density" value="compact" defaultChecked>
|
|
82
|
+
<Radio.Indicator />
|
|
83
|
+
</Radio.Root>;
|
|
62
84
|
|
|
63
85
|
// Controlled — the consumer owns the group's selected value.
|
|
64
86
|
const [value, setValue] = useState("comfortable");
|
|
65
87
|
<Radio.Root
|
|
88
|
+
name="density"
|
|
89
|
+
value="compact"
|
|
66
90
|
checked={value === "compact"}
|
|
67
91
|
onCheckedChange={() => setValue("compact")}
|
|
68
92
|
>
|
|
69
|
-
|
|
93
|
+
<Radio.Indicator />
|
|
70
94
|
</Radio.Root>;
|
|
71
95
|
```
|
|
72
96
|
|
|
97
|
+
> **`data-state` is a best-effort mirror.** It is accurate in controlled mode and
|
|
98
|
+
> for self-clicks, but a sibling the browser silently deselects fires no React
|
|
99
|
+
> event, so its `data-state` can lag. Drive the *visual* checked look off the
|
|
100
|
+
> input's native `:checked` (and `:has(> input:checked)` on the box), as the
|
|
101
|
+
> shipped registry stylesheet does — not off `data-state`.
|
|
102
|
+
|
|
73
103
|
## Keyboard interaction
|
|
74
104
|
|
|
75
|
-
| Key
|
|
76
|
-
|
|
|
77
|
-
| `Space`
|
|
78
|
-
| `
|
|
105
|
+
| Key | Behaviour |
|
|
106
|
+
| -------------- | ------------------------------------------ |
|
|
107
|
+
| `Space` | Selects the focused radio (native `<input>`) |
|
|
108
|
+
| `Tab` | Moves to / from the radio (native) |
|
|
79
109
|
|
|
80
|
-
Keyboard handling comes from the underlying
|
|
81
|
-
|
|
82
|
-
`RadioGroup` concern, not a standalone `Radio` one.)
|
|
110
|
+
Keyboard handling comes from the underlying native input — no custom `keydown`
|
|
111
|
+
listeners. (Arrow-key navigation *within* a group is a `RadioGroup` concern.)
|
|
83
112
|
|
|
84
113
|
## Disabled
|
|
85
114
|
|
|
86
|
-
Passing `disabled` forwards the native `disabled` attribute to the
|
|
115
|
+
Passing `disabled` forwards the native `disabled` attribute to the input
|
|
87
116
|
(removing it from the tab order and suppressing clicks) **and** sets
|
|
88
|
-
`data-disabled=""` on the
|
|
117
|
+
`data-disabled=""` on the box so CSS can target `[data-disabled]` without
|
|
89
118
|
reaching for `:disabled`.
|
|
90
119
|
|
|
91
120
|
```tsx
|
|
92
121
|
<Radio.Root disabled aria-label="Unavailable plan">
|
|
93
|
-
<Radio.Indicator
|
|
94
|
-
<DotIcon />
|
|
95
|
-
</Radio.Indicator>
|
|
122
|
+
<Radio.Indicator />
|
|
96
123
|
</Radio.Root>
|
|
97
124
|
```
|
|
98
125
|
|
|
99
126
|
## `asChild` composition
|
|
100
127
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
the child (the asChild contract: the child's handler runs first, the
|
|
105
|
-
library's runs second unless the child calls `preventDefault`).
|
|
128
|
+
`Radio.Indicator` accepts an `asChild` boolean. When set, the component
|
|
129
|
+
delegates rendering to its single child element and merges its `aria-hidden`,
|
|
130
|
+
`data-state`, and ref onto the child — typically to use an `<svg>` as the dot:
|
|
106
131
|
|
|
107
132
|
```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
133
|
<Radio.Indicator asChild>
|
|
115
134
|
<svg viewBox="0 0 10 10">
|
|
116
135
|
<circle cx="5" cy="5" r="3" />
|
|
@@ -118,39 +137,23 @@ library's runs second unless the child calls `preventDefault`).
|
|
|
118
137
|
</Radio.Indicator>
|
|
119
138
|
```
|
|
120
139
|
|
|
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
140
|
## Styling hooks
|
|
145
141
|
|
|
146
|
-
`data-state="checked" | "unchecked"`
|
|
147
|
-
`
|
|
142
|
+
- The box (`Radio.Root`) carries `data-state="checked" | "unchecked"` and
|
|
143
|
+
`data-disabled=""`.
|
|
144
|
+
- The hidden input exposes the always-correct native states: `:checked`,
|
|
145
|
+
`:focus-visible`, `:disabled`. Prefer these for the *checked* look.
|
|
148
146
|
|
|
149
147
|
```css
|
|
150
|
-
|
|
148
|
+
/* Reveal the dot from the native checked state — correct even on a silent
|
|
149
|
+
sibling deselect. */
|
|
150
|
+
.radio > input:checked ~ .radio__indicator {
|
|
151
|
+
scale: 1;
|
|
152
|
+
}
|
|
153
|
+
.radio:has(> input:checked) {
|
|
151
154
|
border-color: oklch(55% 0.2 265);
|
|
152
155
|
}
|
|
153
|
-
|
|
156
|
+
.radio[data-disabled] {
|
|
154
157
|
opacity: 0.5;
|
|
155
158
|
cursor: not-allowed;
|
|
156
159
|
}
|