@primitiv-ui/react 0.1.14 → 0.1.16

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.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/Checkbox/Checkbox.tsx +96 -120
  4. package/src/Checkbox/CheckboxContext.ts +8 -0
  5. package/src/Checkbox/README.md +69 -88
  6. package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +48 -10
  7. package/src/Checkbox/__tests__/Checkbox.contract.test.tsx +29 -40
  8. package/src/Checkbox/__tests__/Checkbox.controlled-state.test.tsx +16 -24
  9. package/src/Checkbox/__tests__/Checkbox.disabled.test.tsx +33 -3
  10. package/src/Checkbox/__tests__/Checkbox.form.test.tsx +53 -0
  11. package/src/Checkbox/__tests__/Checkbox.indeterminate.test.tsx +28 -34
  12. package/src/Checkbox/__tests__/Checkbox.indicator.test.tsx +27 -52
  13. package/src/Checkbox/__tests__/Checkbox.uncontrolled-state.test.tsx +33 -17
  14. package/src/Checkbox/hooks/index.ts +1 -0
  15. package/src/Checkbox/hooks/useCheckboxInput.ts +49 -0
  16. package/src/Checkbox/types.ts +24 -17
  17. package/src/Radio/README.md +89 -86
  18. package/src/Radio/Radio.tsx +87 -98
  19. package/src/Radio/RadioContext.ts +9 -0
  20. package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +34 -9
  21. package/src/Radio/__tests__/Radio.contract.test.tsx +30 -36
  22. package/src/Radio/__tests__/Radio.controlled-state.test.tsx +21 -11
  23. package/src/Radio/__tests__/Radio.disabled.test.tsx +31 -6
  24. package/src/Radio/__tests__/Radio.native-grouping.test.tsx +77 -0
  25. package/src/Radio/__tests__/Radio.parts.test.tsx +76 -0
  26. package/src/Radio/__tests__/Radio.uncontrolled-state.test.tsx +39 -32
  27. package/src/Radio/hooks/useRadioRoot.ts +32 -16
  28. package/src/Radio/types.ts +30 -16
  29. package/src/Switch/README.md +45 -57
  30. package/src/Switch/Switch.tsx +78 -65
  31. package/src/Switch/SwitchContext.ts +3 -1
  32. package/src/Switch/__tests__/Switch.asChild.test.tsx +16 -34
  33. package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +47 -28
  34. package/src/Switch/__tests__/Switch.contract.test.tsx +21 -16
  35. package/src/Switch/__tests__/Switch.controlled-state.test.tsx +10 -12
  36. package/src/Switch/__tests__/Switch.disabled.test.tsx +12 -17
  37. package/src/Switch/__tests__/Switch.form.test.tsx +55 -0
  38. package/src/Switch/__tests__/Switch.keyboard-interaction.test.tsx +4 -4
  39. package/src/Switch/__tests__/Switch.uncontrolled-state.test.tsx +55 -20
  40. package/src/Switch/hooks/useSwitchRoot.ts +29 -11
  41. package/src/Switch/types.ts +20 -10
  42. package/src/Checkbox/__tests__/Checkbox.asChild.test.tsx +0 -69
  43. package/src/Radio/__tests__/Radio.asChild.test.tsx +0 -63
  44. 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
- render(<Checkbox.Root defaultChecked aria-label="Accept terms" />);
10
- const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
9
+ const { container } = render(
10
+ <Checkbox.Root defaultChecked aria-label="Accept terms" />,
11
+ );
11
12
 
12
13
  // Assert
13
- expect(checkbox).toHaveAttribute("aria-checked", "true");
14
- expect(checkbox).toHaveAttribute("data-state", "checked");
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).toHaveAttribute("aria-checked", "true");
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
- // Precondition: defaultChecked really did initialise as checked.
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).toHaveAttribute("aria-checked", "false");
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 the consumer's onClick with the internal toggle (consumer runs first)", async () => {
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 onClick = vi.fn(() => order.push("consumer"));
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
- onClick={onClick}
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
  });
@@ -1,2 +1,3 @@
1
1
  export { useCheckboxRoot } from "./useCheckboxRoot";
2
+ export { useCheckboxInput } from "./useCheckboxInput";
2
3
  export { useCheckboxContext } from "./useCheckboxContext";
@@ -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
+ }
@@ -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} — all `<span>` attributes plus
8
- * `forceMount` (keep mounted while unchecked for exit animations) and the
9
- * `asChild` escape hatch.
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
- * `<button>` attributes (minus the ones the component owns) plus the
20
- * `asChild` escape hatch and a typed `ref`.
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<"button">,
24
- "type" | "role" | "aria-checked" | "defaultChecked"
25
+ ComponentProps<"input">,
26
+ "type" | "checked" | "defaultChecked"
25
27
  > & {
26
- asChild?: boolean;
27
- ref?: Ref<HTMLButtonElement>;
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); `onCheckedChange` is
33
- * optional and `checked` is forbidden.
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
- * checked value. Pass `checked` and `onCheckedChange` together;
44
- * `defaultChecked` is forbidden.
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>;
@@ -1,116 +1,135 @@
1
1
  # Radio
2
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.
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="Compact">
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 | 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` |
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 | `aria-checked` | `data-state` | Indicator renders? |
42
- | ------- | -------------- | ------------- | ------------------------ |
43
- | `true` | `"true"` | `"checked"` | Yes |
44
- | `false` | `"false"` | `"unchecked"` | No (unless `forceMount`) |
60
+ | Value | Native | `data-state` | Dot visible? |
61
+ | ------- | --------- | ------------- | ------------ |
62
+ | `true` | `:checked`| `"checked"` | Yes |
63
+ | `false` | — | `"unchecked"` | No |
45
64
 
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.
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
- - **Controlled** — pass `checked` **and** `onCheckedChange` together.
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>…</Radio.Root>;
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 | Behaviour |
76
- | ------- | -------------------------------------- |
77
- | `Space` | Selects the radio (native `<button>`) |
78
- | `Enter` | Selects the radio (native `<button>`) |
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 `<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.)
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 button
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 root so CSS can target `[data-disabled]` without
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
- 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`).
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"` is set on both `Radio.Root` and
147
- `Radio.Indicator`, letting any CSS system target both phases.
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
- button[data-state="checked"] {
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
- button[data-disabled] {
156
+ .radio[data-disabled] {
154
157
  opacity: 0.5;
155
158
  cursor: not-allowed;
156
159
  }