@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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/Checkbox/__tests__/Checkbox.contract.test.tsx +117 -0
- package/src/Field/__tests__/Field.contract.test.tsx +87 -0
- package/src/Input/__tests__/Input.contract.test.tsx +77 -0
- package/src/InputGroup/__tests__/InputGroup.contract.test.tsx +60 -0
- package/src/Radio/README.md +157 -0
- package/src/Radio/Radio.tsx +213 -0
- package/src/Radio/RadioContext.ts +10 -0
- package/src/Radio/__tests__/Radio.asChild.test.tsx +63 -0
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +41 -0
- package/src/Radio/__tests__/Radio.contract.test.tsx +112 -0
- package/src/Radio/__tests__/Radio.controlled-state.test.tsx +69 -0
- package/src/Radio/__tests__/Radio.disabled.test.tsx +23 -0
- package/src/Radio/__tests__/Radio.indicator.test.tsx +103 -0
- package/src/Radio/__tests__/Radio.uncontrolled-state.test.tsx +89 -0
- package/src/Radio/hooks/index.ts +2 -0
- package/src/Radio/hooks/useRadioContext.ts +1 -0
- package/src/Radio/hooks/useRadioRoot.ts +32 -0
- package/src/Radio/index.ts +2 -0
- package/src/Radio/types.ts +56 -0
- package/src/index.ts +1 -0
|
@@ -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
|
+
});
|