@primitiv-ui/react 0.1.15 → 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
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import type { ReactElement } from "react";
3
3
 
4
- import { Slot, composeEventHandlers } from "../Slot/index.ts";
4
+ import { Slot } from "../Slot/index.ts";
5
5
 
6
6
  import { RadioContext } from "./RadioContext";
7
7
  import { useRadioContext, useRadioRoot } from "./hooks/index.ts";
@@ -12,52 +12,54 @@ function dataStateOf(checked: boolean) {
12
12
  }
13
13
 
14
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).
15
+ * The root of a Radio — a `<label>` that is itself the **visible, styled box**
16
+ * and wraps a **real, visually-hidden `<input type="radio">`** (the focusable,
17
+ * form-participating control) plus the decorative
18
+ * {@link RadioIndicator | `Radio.Indicator`} dot. It provides
19
+ * {@link RadioContext | `RadioContext`} to the indicator.
20
+ *
21
+ * Because the underlying element is a genuine native radio, it behaves like
22
+ * one: siblings sharing a `name` form a **native radio group** (the browser
23
+ * deselects the others when one is chosen — no JS required), it submits with
24
+ * an enclosing form, resets with it, and gets keyboard activation and focus
25
+ * for free. This is the standalone primitive for "I own the grouping via
26
+ * `name`, or I'm a single opt-in". For a managed set with roving-tabindex
27
+ * arrow-key navigation, reach for `RadioGroup` instead.
28
+ *
29
+ * Props routing: `className` / `style` style the **box** (the `<label>` you
30
+ * see — the input is hidden); every other prop (`name`, `value`, `id`,
31
+ * `aria-*`, `required`, `disabled`, `ref`, …) spreads onto the input, because
32
+ * semantically the Root *is* the radio.
27
33
  *
28
34
  * Supports two state modes, statically discriminated at the type level:
29
35
  *
30
36
  * - **Uncontrolled** — pass
31
- * {@link RadioRootProps.defaultChecked | `defaultChecked`} (or omit for
32
- * unselected-on-mount). The component owns the value internally.
37
+ * {@link RadioRootProps.defaultChecked | `defaultChecked`} (or omit). The
38
+ * **browser** owns the value, so native `name`-grouping just works.
33
39
  * - **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.
40
+ * {@link RadioRootProps.onCheckedChange | `onCheckedChange`} together. The
41
+ * parent owns the value and the grouping.
37
42
  *
38
- * **ARIA.** `role="radio"` and `aria-checked` are set automatically.
43
+ * **Styling hooks.** `data-state="checked" | "unchecked"` and
44
+ * `data-disabled=""` on the box (and `data-state` on the indicator). These
45
+ * mirror the input but can lag a silent native deselect, so shipped CSS keys
46
+ * visibility off the input's native `:checked` / `:has()` selectors;
47
+ * `data-state` is a convenience mirror.
39
48
  *
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
49
+ * @example Native group (the browser owns selection)
50
50
  * ```tsx
51
- * <Radio.Root defaultChecked aria-label="Compact">
51
+ * <Radio.Root name="density" value="compact" defaultChecked aria-label="Compact">
52
52
  * <Radio.Indicator />
53
53
  * </Radio.Root>
54
54
  * ```
55
55
  *
56
- * @example Controlled (consumer owns the group)
56
+ * @example Controlled
57
57
  * ```tsx
58
58
  * const [value, setValue] = useState("comfortable");
59
59
  *
60
60
  * <Radio.Root
61
+ * name="density"
62
+ * value="compact"
61
63
  * checked={value === "compact"}
62
64
  * onCheckedChange={() => setValue("compact")}
63
65
  * aria-label="Compact"
@@ -68,39 +70,44 @@ function dataStateOf(checked: boolean) {
68
70
  */
69
71
  export function RadioRoot(props: RadioRootProps): ReactElement {
70
72
  const {
71
- defaultChecked,
72
- checked,
73
- onCheckedChange,
74
- onClick,
75
- disabled,
76
- asChild = false,
73
+ className,
74
+ style,
77
75
  children,
78
- ...rest
79
- } = props;
80
- const { checked: isChecked, select } = useRadioRoot({
81
76
  defaultChecked,
82
77
  checked,
83
78
  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,
79
+ onChange,
92
80
  disabled,
93
- onClick: composeEventHandlers(onClick, select),
94
- };
81
+ ref,
82
+ ...inputRest
83
+ } = props;
84
+ const {
85
+ checked: isChecked,
86
+ handleChange,
87
+ inputStateProps,
88
+ } = useRadioRoot({ defaultChecked, checked, onCheckedChange, onChange });
89
+ const contextValue = useMemo(
90
+ () => ({ checked: isChecked, disabled: Boolean(disabled) }),
91
+ [isChecked, disabled],
92
+ );
95
93
  return (
96
94
  <RadioContext.Provider value={contextValue}>
97
- {asChild ? (
98
- <Slot {...rootProps}>{children}</Slot>
99
- ) : (
100
- <button type="button" {...rootProps}>
101
- {children}
102
- </button>
103
- )}
95
+ <label
96
+ className={className}
97
+ style={style}
98
+ data-state={dataStateOf(isChecked)}
99
+ data-disabled={disabled ? "" : undefined}
100
+ >
101
+ <input
102
+ type="radio"
103
+ ref={ref}
104
+ disabled={disabled}
105
+ {...inputRest}
106
+ {...inputStateProps}
107
+ onChange={handleChange}
108
+ />
109
+ {children}
110
+ </label>
104
111
  </RadioContext.Provider>
105
112
  );
106
113
  }
@@ -109,32 +116,18 @@ export function RadioRoot(props: RadioRootProps): ReactElement {
109
116
  RadioRoot.displayName = "RadioRoot";
110
117
 
111
118
  /**
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.
119
+ * The decorative dot — a `<span>` that is **always mounted**, sitting inside
120
+ * the {@link RadioRoot | `Radio.Root`} box. Its visibility is a pure CSS
121
+ * concern, driven off the input's native `:checked` state, so it stays correct
122
+ * even when the browser silently deselects a grouped sibling (an event React
123
+ * never sees). The radio's accessible state is conveyed by the native input,
124
+ * so the dot is purely visual.
117
125
  *
118
- * **Styling hook.** Mirrors the root's
119
- * `data-state="checked" | "unchecked"` so the same CSS rule can target
120
- * both.
126
+ * **Styling hook.** Mirrors the root's `data-state="checked" | "unchecked"`.
121
127
  *
122
128
  * **`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
- * ```
129
+ * (typically an `<svg>` dot) as the indicator itself, with `data-state` merged
130
+ * onto that element rather than a wrapper.
138
131
  *
139
132
  * @example Dot as the indicator via `asChild`
140
133
  * ```tsx
@@ -147,12 +140,10 @@ RadioRoot.displayName = "RadioRoot";
147
140
  */
148
141
  export function RadioIndicator({
149
142
  children,
150
- forceMount,
151
143
  asChild = false,
152
144
  ...rest
153
- }: RadioIndicatorProps): ReactElement | null {
145
+ }: RadioIndicatorProps): ReactElement {
154
146
  const { checked } = useRadioContext();
155
- if (!checked && !forceMount) return null;
156
147
  const indicatorProps = {
157
148
  ...rest,
158
149
  "aria-hidden": "true" as const,
@@ -174,33 +165,31 @@ export type TRadioCompound = typeof RadioRoot & {
174
165
  };
175
166
 
176
167
  /**
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.
168
+ * Headless, accessible **Radio** — a standalone compound component built on a
169
+ * **real, visually-hidden native `<input type="radio">`**. Unlike a button
170
+ * dressed up with `role="radio"`, it participates in native forms and groups
171
+ * by `name` the way the platform intends. Zero styles ship.
182
172
  *
183
- * `Radio` is the lone control for when you own the grouping; for a managed
184
- * set with roving-tabindex keyboard navigation use `RadioGroup`.
173
+ * `Radio` is the lone control for when you own the grouping (via a native
174
+ * `name`, or a single opt-in); for a managed set with roving-tabindex
175
+ * keyboard navigation use `RadioGroup`.
185
176
  *
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.
177
+ * `Radio` is both callable (an alias of {@link RadioRoot | `Radio.Root`}) and
178
+ * carries its sub-components as static properties.
189
179
  *
190
- * - {@link RadioRoot | `Radio.Root`} — state owner, context provider, select button.
191
- * - {@link RadioIndicator | `Radio.Indicator`} — decorative dot, conditional on selected state.
180
+ * - {@link RadioRoot | `Radio.Root`} — the styled box: label + hidden input, state owner, context provider.
181
+ * - {@link RadioIndicator | `Radio.Indicator`} — the decorative dot.
192
182
  *
193
183
  * @example Minimal usage
194
184
  * ```tsx
195
185
  * import { Radio } from "@primitiv-ui/react";
196
186
  *
197
- * <Radio.Root aria-label="Compact">
187
+ * <Radio.Root name="plan" value="free" aria-label="Free">
198
188
  * <Radio.Indicator />
199
189
  * </Radio.Root>;
200
190
  * ```
201
191
  *
202
- * @see {@link RadioRoot} for state modes and one-way selection semantics.
203
- * @see {@link RadioIndicator} for the mount gate and animation hooks.
192
+ * @see {@link RadioRoot} for state modes and native-grouping semantics.
204
193
  */
205
194
  const RadioCompound: TRadioCompound = Object.assign(RadioRoot, {
206
195
  Root: RadioRoot,
@@ -1,7 +1,16 @@
1
1
  import { createStrictContext } from "../utils/index.ts";
2
2
 
3
3
  export type RadioContextValue = {
4
+ /**
5
+ * Best-effort mirror of the input's checked state, for the `data-state`
6
+ * hook on the visual parts. Accurate in controlled mode and for self-clicks;
7
+ * may go stale when the browser silently deselects a grouped sibling, which
8
+ * is why the shipped CSS keys visibility off the input's native `:checked`,
9
+ * not off `data-state`.
10
+ */
4
11
  checked: boolean;
12
+ /** Whether the radio is disabled, for the `data-disabled` hook. */
13
+ disabled: boolean;
5
14
  };
6
15
 
7
16
  export const [RadioContext, useRadioContext] =
@@ -3,39 +3,64 @@ import { render, screen } from "@testing-library/react";
3
3
  import { Radio } from "../Radio";
4
4
 
5
5
  describe("Radio basic rendering", () => {
6
- it('renders a <button> with role="radio"', () => {
6
+ it("renders a real native <input type=radio> as the control", () => {
7
7
  // Arrange & Act
8
8
  render(<Radio.Root aria-label="Compact" />);
9
9
  const radio = screen.getByRole("radio", { name: "Compact" });
10
10
 
11
11
  // Assert
12
- expect(radio.tagName).toBe("BUTTON");
12
+ expect(radio.tagName).toBe("INPUT");
13
+ expect(radio).toHaveAttribute("type", "radio");
13
14
  });
14
15
 
15
- it('defaults aria-checked to "false"', () => {
16
+ it("wraps the input in a <label> so the visible control is clickable", () => {
16
17
  // Arrange & Act
17
18
  render(<Radio.Root aria-label="Compact" />);
18
19
  const radio = screen.getByRole("radio", { name: "Compact" });
19
20
 
20
21
  // Assert
21
- expect(radio).toHaveAttribute("aria-checked", "false");
22
+ expect(radio.closest("label")).not.toBeNull();
22
23
  });
23
24
 
24
- it('defaults type="button" so the radio never submits an enclosing form', () => {
25
+ it("is unchecked by default", () => {
25
26
  // Arrange & Act
26
27
  render(<Radio.Root aria-label="Compact" />);
28
+
29
+ // Assert
30
+ expect(screen.getByRole("radio", { name: "Compact" })).not.toBeChecked();
31
+ });
32
+
33
+ it("forwards native input attributes (name, value, id) to the input", () => {
34
+ // Arrange & Act
35
+ render(
36
+ <Radio.Root id="r1" name="density" value="compact" aria-label="Compact" />,
37
+ );
27
38
  const radio = screen.getByRole("radio", { name: "Compact" });
28
39
 
29
40
  // Assert
30
- expect(radio).toHaveAttribute("type", "button");
41
+ expect(radio).toHaveAttribute("id", "r1");
42
+ expect(radio).toHaveAttribute("name", "density");
43
+ expect(radio).toHaveAttribute("value", "compact");
31
44
  });
32
45
 
33
- it('sets data-state="unchecked" on the root when unchecked', () => {
46
+ it("applies className to the wrapper, not the hidden input", () => {
34
47
  // Arrange & Act
35
- render(<Radio.Root aria-label="Compact" />);
48
+ render(<Radio.Root className="box" aria-label="Compact" />);
36
49
  const radio = screen.getByRole("radio", { name: "Compact" });
37
50
 
38
51
  // Assert
39
- expect(radio).toHaveAttribute("data-state", "unchecked");
52
+ expect(radio).not.toHaveClass("box");
53
+ expect(radio.closest("label")).toHaveClass("box");
54
+ });
55
+
56
+ it('sets data-state="unchecked" on the wrapper when unchecked', () => {
57
+ // Arrange & Act
58
+ const { container } = render(<Radio.Root aria-label="Compact" />);
59
+
60
+ // Assert
61
+ expect(container.querySelector("label")).toHaveAttribute(
62
+ "data-state",
63
+ "unchecked",
64
+ );
40
65
  });
41
66
  });
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { dirname, resolve } from "node:path";
4
4
 
5
- import { render, screen } from "@testing-library/react";
5
+ import { render } from "@testing-library/react";
6
6
 
7
7
  import { Radio } from "../Radio";
8
8
 
@@ -10,11 +10,11 @@ import { Radio } from "../Radio";
10
10
  * Auto-verification of the Radio styling contract (RFC 0004 §3.4 / D15). The
11
11
  * `data-*` half of `registry/components/radio/contract.json` is *derived from
12
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.
13
+ * from what the component actually emits. The radio's `data-*` surface lives
14
+ * on the **wrapper** (the styled root) — `data-state="checked" | "unchecked"`
15
+ * (always present) plus `data-disabled=""` when disabled. The authored half
16
+ * (classes, parts, custom properties) is a styling convention the headless
17
+ * layer does not emit and is checked by the generator drift-guards instead.
18
18
  */
19
19
  const contractPath = resolve(
20
20
  dirname(fileURLToPath(import.meta.url)),
@@ -23,6 +23,13 @@ const contractPath = resolve(
23
23
 
24
24
  const contract = JSON.parse(readFileSync(contractPath, "utf8"));
25
25
 
26
+ /** The wrapper element is the contracted styling root. */
27
+ function rootOf(container: HTMLElement): HTMLElement {
28
+ const root = container.querySelector("label");
29
+ if (!root) throw new Error("expected a Radio wrapper element");
30
+ return root;
31
+ }
32
+
26
33
  /** Every `data-*` attribute name present on an element. */
27
34
  function dataAttributeNames(element: Element): string[] {
28
35
  return [...element.attributes]
@@ -37,25 +44,23 @@ describe("Radio styling contract", () => {
37
44
  );
38
45
 
39
46
  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
47
  expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
42
48
  });
43
49
 
44
50
  it("emits exactly the contracted data-* attribute names across its states", () => {
45
51
  const emitted = new Set<string>();
46
52
  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>,
53
+ <Radio.Root key="checked" checked onCheckedChange={() => {}} aria-label="x" />,
54
+ <Radio.Root
55
+ key="unchecked"
56
+ checked={false}
57
+ onCheckedChange={() => {}}
58
+ aria-label="x"
59
+ />,
60
+ <Radio.Root key="disabled" disabled aria-label="x" />,
56
61
  ]) {
57
- const { unmount } = render(ui);
58
- for (const name of dataAttributeNames(screen.getByRole("radio"))) {
62
+ const { container, unmount } = render(ui);
63
+ for (const name of dataAttributeNames(rootOf(container))) {
59
64
  emitted.add(name);
60
65
  }
61
66
  unmount();
@@ -73,16 +78,14 @@ describe("Radio styling contract", () => {
73
78
  );
74
79
 
75
80
  for (const entry of stateEntries) {
76
- const { unmount } = render(
81
+ const { container, unmount } = render(
77
82
  <Radio.Root
78
83
  checked={entry.value === "checked"}
79
84
  onCheckedChange={() => {}}
80
85
  aria-label="x"
81
- >
82
- <Radio.Indicator />
83
- </Radio.Root>,
86
+ />,
84
87
  );
85
- expect(screen.getByRole("radio")).toHaveAttribute("data-state", entry.value);
88
+ expect(rootOf(container)).toHaveAttribute("data-state", entry.value);
86
89
  unmount();
87
90
  }
88
91
  });
@@ -92,21 +95,12 @@ describe("Radio styling contract", () => {
92
95
  (attribute: { name: string }) => attribute.name === "data-disabled",
93
96
  );
94
97
 
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);
98
+ const { container } = render(<Radio.Root disabled aria-label="x" />);
99
+ expect(rootOf(container)).toHaveAttribute(entry.name, entry.value);
101
100
  });
102
101
 
103
102
  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");
103
+ const { container } = render(<Radio.Root aria-label="x" />);
104
+ expect(rootOf(container)).not.toHaveAttribute("data-disabled");
111
105
  });
112
106
  });
@@ -11,18 +11,30 @@ describe("Radio controlled state", () => {
11
11
  <Radio.Root checked={false} onCheckedChange={() => {}} aria-label="Compact" />,
12
12
  );
13
13
  const radio = screen.getByRole("radio", { name: "Compact" });
14
- expect(radio).toHaveAttribute("aria-checked", "false");
14
+ expect(radio).not.toBeChecked();
15
15
 
16
16
  rerender(
17
17
  <Radio.Root checked onCheckedChange={() => {}} aria-label="Compact" />,
18
18
  );
19
19
 
20
20
  // Assert
21
- expect(radio).toHaveAttribute("aria-checked", "true");
22
- expect(radio).toHaveAttribute("data-state", "checked");
21
+ expect(radio).toBeChecked();
23
22
  });
24
23
 
25
- it("does not update its rendered state when the parent refuses to update `checked`", async () => {
24
+ it("mirrors the controlled value into the wrapper's data-state", () => {
25
+ // Arrange & Act
26
+ const { container } = render(
27
+ <Radio.Root checked onCheckedChange={() => {}} aria-label="Compact" />,
28
+ );
29
+
30
+ // Assert
31
+ expect(container.querySelector("label")).toHaveAttribute(
32
+ "data-state",
33
+ "checked",
34
+ );
35
+ });
36
+
37
+ it("does not change its rendered state when the parent refuses to update `checked`", async () => {
26
38
  // Arrange
27
39
  const user = userEvent.setup();
28
40
  const onCheckedChange = vi.fn();
@@ -38,11 +50,9 @@ describe("Radio controlled state", () => {
38
50
  // Act
39
51
  await user.click(radio);
40
52
 
41
- // Assert: callback fired but the rendered state stays false because the
42
- // parent did not flip the controlled prop.
53
+ // Assert: callback fired but the rendered state stays unchecked.
43
54
  expect(onCheckedChange).toHaveBeenCalledWith(true);
44
- expect(radio).toHaveAttribute("aria-checked", "false");
45
- expect(radio).toHaveAttribute("data-state", "unchecked");
55
+ expect(radio).not.toBeChecked();
46
56
  });
47
57
 
48
58
  it("lets a parent drive the value end to end", async () => {
@@ -60,10 +70,10 @@ describe("Radio controlled state", () => {
60
70
  }
61
71
  render(<Harness />);
62
72
  const radio = screen.getByRole("radio", { name: "Compact" });
63
- expect(radio).toHaveAttribute("aria-checked", "false");
73
+ expect(radio).not.toBeChecked();
64
74
 
65
- // Act & Assert: the click is deferred to the parent, which selects it.
75
+ // Act & Assert
66
76
  await user.click(radio);
67
- expect(radio).toHaveAttribute("aria-checked", "true");
77
+ expect(radio).toBeChecked();
68
78
  });
69
79
  });
@@ -1,23 +1,48 @@
1
1
  import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
2
3
 
3
4
  import { Radio } from "../Radio";
4
5
 
5
6
  describe("Radio disabled state", () => {
6
- it('sets data-disabled="" on the root so CSS can target the disabled state', () => {
7
+ it("disables the underlying native input", () => {
7
8
  // Arrange & Act
8
9
  render(<Radio.Root disabled aria-label="Compact" />);
9
- const radio = screen.getByRole("radio", { name: "Compact" });
10
10
 
11
11
  // Assert
12
- expect(radio).toHaveAttribute("data-disabled", "");
12
+ expect(screen.getByRole("radio", { name: "Compact" })).toBeDisabled();
13
+ });
14
+
15
+ it('sets data-disabled="" on the wrapper so CSS can target the disabled state', () => {
16
+ // Arrange & Act
17
+ const { container } = render(<Radio.Root disabled aria-label="Compact" />);
18
+
19
+ // Assert
20
+ expect(container.querySelector("label")).toHaveAttribute(
21
+ "data-disabled",
22
+ "",
23
+ );
13
24
  });
14
25
 
15
26
  it("omits data-disabled when not disabled", () => {
16
27
  // Arrange & Act
17
- render(<Radio.Root aria-label="Compact" />);
18
- const radio = screen.getByRole("radio", { name: "Compact" });
28
+ const { container } = render(<Radio.Root aria-label="Compact" />);
29
+
30
+ // Assert
31
+ expect(container.querySelector("label")).not.toHaveAttribute("data-disabled");
32
+ });
33
+
34
+ it("does not fire onCheckedChange when a disabled radio is clicked", async () => {
35
+ // Arrange
36
+ const user = userEvent.setup();
37
+ const onCheckedChange = vi.fn();
38
+ render(
39
+ <Radio.Root disabled onCheckedChange={onCheckedChange} aria-label="Compact" />,
40
+ );
41
+
42
+ // Act
43
+ await user.click(screen.getByRole("radio", { name: "Compact" }));
19
44
 
20
45
  // Assert
21
- expect(radio).not.toHaveAttribute("data-disabled");
46
+ expect(onCheckedChange).not.toHaveBeenCalled();
22
47
  });
23
48
  });