@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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/Checkbox/Checkbox.tsx +96 -120
- package/src/Checkbox/CheckboxContext.ts +8 -0
- package/src/Checkbox/README.md +69 -88
- package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +48 -10
- package/src/Checkbox/__tests__/Checkbox.contract.test.tsx +29 -40
- package/src/Checkbox/__tests__/Checkbox.controlled-state.test.tsx +16 -24
- package/src/Checkbox/__tests__/Checkbox.disabled.test.tsx +33 -3
- package/src/Checkbox/__tests__/Checkbox.form.test.tsx +53 -0
- package/src/Checkbox/__tests__/Checkbox.indeterminate.test.tsx +28 -34
- package/src/Checkbox/__tests__/Checkbox.indicator.test.tsx +27 -52
- package/src/Checkbox/__tests__/Checkbox.uncontrolled-state.test.tsx +33 -17
- package/src/Checkbox/hooks/index.ts +1 -0
- package/src/Checkbox/hooks/useCheckboxInput.ts +49 -0
- package/src/Checkbox/types.ts +24 -17
- package/src/Radio/README.md +89 -86
- package/src/Radio/Radio.tsx +87 -98
- package/src/Radio/RadioContext.ts +9 -0
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +34 -9
- package/src/Radio/__tests__/Radio.contract.test.tsx +30 -36
- package/src/Radio/__tests__/Radio.controlled-state.test.tsx +21 -11
- package/src/Radio/__tests__/Radio.disabled.test.tsx +31 -6
- package/src/Radio/__tests__/Radio.native-grouping.test.tsx +77 -0
- package/src/Radio/__tests__/Radio.parts.test.tsx +76 -0
- package/src/Radio/__tests__/Radio.uncontrolled-state.test.tsx +39 -32
- package/src/Radio/hooks/useRadioRoot.ts +32 -16
- package/src/Radio/types.ts +30 -16
- package/src/Switch/README.md +45 -57
- package/src/Switch/Switch.tsx +78 -65
- package/src/Switch/SwitchContext.ts +3 -1
- package/src/Switch/__tests__/Switch.asChild.test.tsx +16 -34
- package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +47 -28
- package/src/Switch/__tests__/Switch.contract.test.tsx +21 -16
- package/src/Switch/__tests__/Switch.controlled-state.test.tsx +10 -12
- package/src/Switch/__tests__/Switch.disabled.test.tsx +12 -17
- package/src/Switch/__tests__/Switch.form.test.tsx +55 -0
- package/src/Switch/__tests__/Switch.keyboard-interaction.test.tsx +4 -4
- package/src/Switch/__tests__/Switch.uncontrolled-state.test.tsx +55 -20
- package/src/Switch/hooks/useSwitchRoot.ts +29 -11
- package/src/Switch/types.ts +20 -10
- package/src/Checkbox/__tests__/Checkbox.asChild.test.tsx +0 -69
- package/src/Radio/__tests__/Radio.asChild.test.tsx +0 -63
- package/src/Radio/__tests__/Radio.indicator.test.tsx +0 -103
package/src/Radio/Radio.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import type { ReactElement } from "react";
|
|
3
3
|
|
|
4
|
-
import { Slot
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* {@link RadioIndicator | `Radio.Indicator`}
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
32
|
-
*
|
|
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
|
-
*
|
|
36
|
-
* the callback.
|
|
40
|
+
* {@link RadioRootProps.onCheckedChange | `onCheckedChange`} together. The
|
|
41
|
+
* parent owns the value and the grouping.
|
|
37
42
|
*
|
|
38
|
-
* **
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* is
|
|
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 `
|
|
124
|
-
*
|
|
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
|
|
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
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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
|
|
184
|
-
* set with roving-tabindex
|
|
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
|
-
*
|
|
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
|
|
191
|
-
* - {@link RadioIndicator | `Radio.Indicator`} — decorative dot
|
|
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="
|
|
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
|
|
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(
|
|
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("
|
|
12
|
+
expect(radio.tagName).toBe("INPUT");
|
|
13
|
+
expect(radio).toHaveAttribute("type", "radio");
|
|
13
14
|
});
|
|
14
15
|
|
|
15
|
-
it(
|
|
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
|
|
22
|
+
expect(radio.closest("label")).not.toBeNull();
|
|
22
23
|
});
|
|
23
24
|
|
|
24
|
-
it(
|
|
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("
|
|
41
|
+
expect(radio).toHaveAttribute("id", "r1");
|
|
42
|
+
expect(radio).toHaveAttribute("name", "density");
|
|
43
|
+
expect(radio).toHaveAttribute("value", "compact");
|
|
31
44
|
});
|
|
32
45
|
|
|
33
|
-
it(
|
|
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).
|
|
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
|
|
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.
|
|
14
|
-
* `data-state="checked" | "unchecked"`
|
|
15
|
-
* when disabled. The authored half
|
|
16
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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).
|
|
22
|
-
expect(radio).toHaveAttribute("data-state", "checked");
|
|
21
|
+
expect(radio).toBeChecked();
|
|
23
22
|
});
|
|
24
23
|
|
|
25
|
-
it("
|
|
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
|
|
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).
|
|
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).
|
|
73
|
+
expect(radio).not.toBeChecked();
|
|
64
74
|
|
|
65
|
-
// Act & Assert
|
|
75
|
+
// Act & Assert
|
|
66
76
|
await user.click(radio);
|
|
67
|
-
expect(radio).
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
46
|
+
expect(onCheckedChange).not.toHaveBeenCalled();
|
|
22
47
|
});
|
|
23
48
|
});
|