@primitiv-ui/react 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });