@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.
- 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/Switch/Switch.tsx
CHANGED
|
@@ -1,41 +1,50 @@
|
|
|
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 { SwitchContext } from "./SwitchContext";
|
|
7
7
|
import { useSwitchContext, useSwitchRoot } from "./hooks/index.ts";
|
|
8
8
|
import { SwitchRootProps, SwitchThumbProps } from "./types";
|
|
9
9
|
|
|
10
|
+
function dataStateOf(checked: boolean) {
|
|
11
|
+
return checked ? ("checked" as const) : ("unchecked" as const);
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
/**
|
|
11
|
-
* The root of a Switch — a
|
|
12
|
-
*
|
|
13
|
-
*
|
|
15
|
+
* The root of a Switch — a `<label>` that is itself the **visible track** and
|
|
16
|
+
* wraps a **real, visually-hidden `<input type="checkbox" role="switch">`**
|
|
17
|
+
* (the focusable, form-participating control) plus the
|
|
18
|
+
* {@link SwitchThumb | `Switch.Thumb`}. It provides
|
|
19
|
+
* {@link SwitchContext | `SwitchContext`} to the thumb.
|
|
20
|
+
*
|
|
21
|
+
* Because the underlying element is a genuine native checkbox (with the switch
|
|
22
|
+
* role), it behaves like a real form field: it submits its `value` under `name`
|
|
23
|
+
* with an enclosing form, resets with it, and gets keyboard activation and
|
|
24
|
+
* focus for free. Semantically it represents an immediate on/off action.
|
|
25
|
+
*
|
|
26
|
+
* Props routing: `className` / `style` style the **track** (the `<label>` you
|
|
27
|
+
* see — the input is hidden); every other prop (`name`, `value`, `id`,
|
|
28
|
+
* `aria-*`, `required`, `disabled`, `ref`, …) spreads onto the input, because
|
|
29
|
+
* semantically the Root *is* the switch.
|
|
14
30
|
*
|
|
15
31
|
* Supports two state modes, statically discriminated at the type level:
|
|
16
32
|
*
|
|
17
|
-
* - **Uncontrolled** — pass
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* - **Controlled** — pass
|
|
21
|
-
* {@link SwitchRootProps.checked | `checked`} *and*
|
|
22
|
-
* {@link SwitchRootProps.onCheckedChange | `onCheckedChange`} together.
|
|
23
|
-
* The parent owns the value; every click defers back through the callback.
|
|
24
|
-
*
|
|
25
|
-
* **ARIA.** `role="switch"` and `aria-checked` are set automatically.
|
|
26
|
-
* Provide an accessible name via `aria-label`, `aria-labelledby`, or
|
|
27
|
-
* a visible label element.
|
|
33
|
+
* - **Uncontrolled** — pass `defaultChecked` (or omit). The browser owns the
|
|
34
|
+
* value, so the switch participates in forms and resets.
|
|
35
|
+
* - **Controlled** — pass `checked` *and* `onCheckedChange` together.
|
|
28
36
|
*
|
|
29
|
-
* **Styling hooks.** `data-state="checked" | "unchecked"`
|
|
30
|
-
* `data-
|
|
37
|
+
* **Styling hooks.** `data-state="checked" | "unchecked"` and `data-disabled=""`
|
|
38
|
+
* on the track (and `data-state` on the thumb). These mirror the input, but
|
|
39
|
+
* shipped CSS positions the thumb off the input's native `:checked` instead;
|
|
40
|
+
* `data-state` is a convenience mirror.
|
|
31
41
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* `ref` merged in. The native `<button>` is dropped.
|
|
42
|
+
* **Keyboard.** Toggles on `Space` (the native checkbox behaviour); `Enter`
|
|
43
|
+
* does not toggle a checkbox-based control.
|
|
35
44
|
*
|
|
36
45
|
* @example Uncontrolled
|
|
37
46
|
* ```tsx
|
|
38
|
-
* <Switch.Root defaultChecked aria-label="Enable notifications">
|
|
47
|
+
* <Switch.Root name="notify" value="on" defaultChecked aria-label="Enable notifications">
|
|
39
48
|
* <Switch.Thumb />
|
|
40
49
|
* </Switch.Root>
|
|
41
50
|
* ```
|
|
@@ -50,41 +59,45 @@ import { SwitchRootProps, SwitchThumbProps } from "./types";
|
|
|
50
59
|
* ```
|
|
51
60
|
*/
|
|
52
61
|
export function SwitchRoot({
|
|
62
|
+
className,
|
|
63
|
+
style,
|
|
53
64
|
defaultChecked,
|
|
54
65
|
checked,
|
|
55
66
|
onCheckedChange,
|
|
67
|
+
onChange,
|
|
56
68
|
disabled,
|
|
57
|
-
asChild = false,
|
|
58
|
-
onClick,
|
|
59
69
|
children,
|
|
60
70
|
ref,
|
|
61
|
-
...
|
|
71
|
+
...inputRest
|
|
62
72
|
}: SwitchRootProps): ReactElement {
|
|
63
|
-
const {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
68
|
-
const contextValue = useMemo(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
role: "switch" as const,
|
|
73
|
-
"aria-checked": isChecked,
|
|
74
|
-
"data-state": isChecked ? ("checked" as const) : ("unchecked" as const),
|
|
75
|
-
"data-disabled": disabled ? "" : undefined,
|
|
76
|
-
disabled,
|
|
77
|
-
onClick: composeEventHandlers(onClick, toggle),
|
|
78
|
-
};
|
|
73
|
+
const {
|
|
74
|
+
checked: isChecked,
|
|
75
|
+
handleChange,
|
|
76
|
+
inputStateProps,
|
|
77
|
+
} = useSwitchRoot({ defaultChecked, checked, onCheckedChange, onChange });
|
|
78
|
+
const contextValue = useMemo(
|
|
79
|
+
() => ({ checked: isChecked, disabled: Boolean(disabled) }),
|
|
80
|
+
[isChecked, disabled],
|
|
81
|
+
);
|
|
79
82
|
return (
|
|
80
83
|
<SwitchContext.Provider value={contextValue}>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
<label
|
|
85
|
+
className={className}
|
|
86
|
+
style={style}
|
|
87
|
+
data-state={dataStateOf(isChecked)}
|
|
88
|
+
data-disabled={disabled ? "" : undefined}
|
|
89
|
+
>
|
|
90
|
+
<input
|
|
91
|
+
type="checkbox"
|
|
92
|
+
role="switch"
|
|
93
|
+
ref={ref}
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
{...inputRest}
|
|
96
|
+
{...inputStateProps}
|
|
97
|
+
onChange={handleChange}
|
|
98
|
+
/>
|
|
99
|
+
{children}
|
|
100
|
+
</label>
|
|
88
101
|
</SwitchContext.Provider>
|
|
89
102
|
);
|
|
90
103
|
}
|
|
@@ -93,16 +106,15 @@ export function SwitchRoot({
|
|
|
93
106
|
SwitchRoot.displayName = "SwitchRoot";
|
|
94
107
|
|
|
95
108
|
/**
|
|
96
|
-
* A decorative `<span aria-hidden="true">`
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
109
|
+
* A decorative `<span aria-hidden="true">` representing the sliding thumb of
|
|
110
|
+
* the switch. Always mounted — its position is driven entirely by CSS, which
|
|
111
|
+
* should key off the input's native `:checked` so it stays correct through a
|
|
112
|
+
* form reset.
|
|
100
113
|
*
|
|
101
|
-
* **Styling hook.** Mirrors the
|
|
102
|
-
* `data-state="checked" | "unchecked"` for CSS transitions.
|
|
114
|
+
* **Styling hook.** Mirrors the root's `data-state="checked" | "unchecked"`.
|
|
103
115
|
*
|
|
104
|
-
* **`asChild` prop.** Pass `asChild` to render the consumer's own element
|
|
105
|
-
*
|
|
116
|
+
* **`asChild` prop.** Pass `asChild` to render the consumer's own element as
|
|
117
|
+
* the thumb, with `aria-hidden` and `data-state` merged in.
|
|
106
118
|
*
|
|
107
119
|
* @example
|
|
108
120
|
* ```tsx
|
|
@@ -122,7 +134,7 @@ export function SwitchThumb({
|
|
|
122
134
|
const thumbProps = {
|
|
123
135
|
...rest,
|
|
124
136
|
"aria-hidden": "true" as const,
|
|
125
|
-
"data-state":
|
|
137
|
+
"data-state": dataStateOf(checked),
|
|
126
138
|
};
|
|
127
139
|
if (asChild) {
|
|
128
140
|
return <Slot {...thumbProps}>{children}</Slot>;
|
|
@@ -135,36 +147,37 @@ SwitchThumb.displayName = "SwitchThumb";
|
|
|
135
147
|
|
|
136
148
|
/** Type of the {@link Switch} compound — the Root callable plus its sub-components. */
|
|
137
149
|
export type TSwitchCompound = typeof SwitchRoot & {
|
|
138
|
-
/** The root
|
|
150
|
+
/** The root track, owning checked state and context. */
|
|
139
151
|
Root: typeof SwitchRoot;
|
|
140
152
|
/** The sliding thumb indicator. */
|
|
141
153
|
Thumb: typeof SwitchThumb;
|
|
142
154
|
};
|
|
143
155
|
|
|
144
156
|
/**
|
|
145
|
-
* Headless, accessible **Switch** — a compound component
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* on/off action (as opposed to a selection
|
|
157
|
+
* Headless, accessible **Switch** — a compound component built on a **real,
|
|
158
|
+
* visually-hidden native `<input type="checkbox" role="switch">`**. Unlike a
|
|
159
|
+
* button dressed up with `role="switch"`, it participates in native forms.
|
|
160
|
+
* Semantically represents an immediate on/off action (as opposed to a selection
|
|
161
|
+
* choice). Zero styles ship.
|
|
149
162
|
*
|
|
150
163
|
* `Switch` is both callable (an alias of
|
|
151
164
|
* {@link SwitchRoot | `Switch.Root`}) and carries its sub-components as
|
|
152
165
|
* static properties.
|
|
153
166
|
*
|
|
154
|
-
* - {@link SwitchRoot | `Switch.Root`} — state owner, context provider
|
|
167
|
+
* - {@link SwitchRoot | `Switch.Root`} — the track: label + hidden input, state owner, context provider.
|
|
155
168
|
* - {@link SwitchThumb | `Switch.Thumb`} — always-mounted decorative thumb;
|
|
156
|
-
* position driven by
|
|
169
|
+
* position driven by CSS (the input's native `:checked`).
|
|
157
170
|
*
|
|
158
171
|
* @example Minimal usage
|
|
159
172
|
* ```tsx
|
|
160
173
|
* import { Switch } from "@primitiv-ui/react";
|
|
161
174
|
*
|
|
162
|
-
* <Switch.Root aria-label="Enable notifications">
|
|
175
|
+
* <Switch.Root name="notify" value="on" aria-label="Enable notifications">
|
|
163
176
|
* <Switch.Thumb />
|
|
164
177
|
* </Switch.Root>
|
|
165
178
|
* ```
|
|
166
179
|
*
|
|
167
|
-
* @see {@link SwitchRoot} for state modes and
|
|
180
|
+
* @see {@link SwitchRoot} for state modes and form participation.
|
|
168
181
|
* @see {@link SwitchThumb} for styling the sliding thumb.
|
|
169
182
|
*/
|
|
170
183
|
const SwitchCompound: TSwitchCompound = Object.assign(SwitchRoot, {
|
|
@@ -3,8 +3,10 @@ import { createStrictContext } from "../utils/index.ts";
|
|
|
3
3
|
|
|
4
4
|
/** Value shared from `Switch.Root` to `Switch.Thumb` through context. */
|
|
5
5
|
export type SwitchContextValue = {
|
|
6
|
-
/** Whether the switch is currently checked. */
|
|
6
|
+
/** Whether the switch is currently checked (best-effort mirror of the input). */
|
|
7
7
|
checked: boolean;
|
|
8
|
+
/** Whether the switch is disabled, for the `data-disabled` hook. */
|
|
9
|
+
disabled: boolean;
|
|
8
10
|
};
|
|
9
11
|
|
|
10
12
|
const switchContextPair = createStrictContext<SwitchContextValue>(
|
|
@@ -1,56 +1,38 @@
|
|
|
1
1
|
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import userEvent from "@testing-library/user-event";
|
|
3
2
|
|
|
4
3
|
import { Switch } from "../Switch";
|
|
5
4
|
|
|
6
5
|
describe("Switch asChild", () => {
|
|
7
|
-
it("renders the
|
|
6
|
+
it("renders the Thumb as the consumer element when asChild is set", () => {
|
|
8
7
|
// Arrange & Act
|
|
9
8
|
render(
|
|
10
|
-
<Switch.Root
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// Assert — div rendered, not button; role and aria-checked merged
|
|
16
|
-
const sw = screen.getByRole("switch", { name: "Enable notifications" });
|
|
17
|
-
expect(sw.tagName).toBe("DIV");
|
|
18
|
-
expect(sw).toHaveAttribute("aria-checked", "false");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("merges onClick and toggles state on the asChild Root element", async () => {
|
|
22
|
-
// Arrange
|
|
23
|
-
const user = userEvent.setup();
|
|
24
|
-
const onClick = vi.fn();
|
|
25
|
-
render(
|
|
26
|
-
<Switch.Root asChild aria-label="Enable notifications" onClick={onClick}>
|
|
27
|
-
<div>Toggle</div>
|
|
9
|
+
<Switch.Root aria-label="Enable notifications">
|
|
10
|
+
<Switch.Thumb asChild>
|
|
11
|
+
<span data-testid="custom-thumb" />
|
|
12
|
+
</Switch.Thumb>
|
|
28
13
|
</Switch.Root>,
|
|
29
14
|
);
|
|
15
|
+
const thumb = screen.getByTestId("custom-thumb");
|
|
30
16
|
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// Assert — consumer onClick ran and state toggled
|
|
35
|
-
expect(onClick).toHaveBeenCalledOnce();
|
|
36
|
-
expect(
|
|
37
|
-
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
38
|
-
).toHaveAttribute("aria-checked", "true");
|
|
17
|
+
// Assert — our span rendered; aria-hidden and data-state merged onto it
|
|
18
|
+
expect(thumb).toHaveAttribute("aria-hidden", "true");
|
|
19
|
+
expect(thumb).toHaveAttribute("data-state", "unchecked");
|
|
39
20
|
});
|
|
40
21
|
|
|
41
|
-
it("
|
|
22
|
+
it("merges the checked data-state onto the asChild Thumb", () => {
|
|
42
23
|
// Arrange & Act
|
|
43
24
|
render(
|
|
44
|
-
<Switch.Root aria-label="Enable notifications">
|
|
25
|
+
<Switch.Root aria-label="Enable notifications" defaultChecked>
|
|
45
26
|
<Switch.Thumb asChild>
|
|
46
27
|
<span data-testid="custom-thumb" />
|
|
47
28
|
</Switch.Thumb>
|
|
48
29
|
</Switch.Root>,
|
|
49
30
|
);
|
|
50
31
|
|
|
51
|
-
// Assert
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
32
|
+
// Assert
|
|
33
|
+
expect(screen.getByTestId("custom-thumb")).toHaveAttribute(
|
|
34
|
+
"data-state",
|
|
35
|
+
"checked",
|
|
36
|
+
);
|
|
55
37
|
});
|
|
56
38
|
});
|
|
@@ -1,76 +1,95 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
1
2
|
import { render, screen } from "@testing-library/react";
|
|
2
3
|
|
|
3
4
|
import { Switch } from "../Switch";
|
|
4
5
|
|
|
5
6
|
describe("Switch basic rendering", () => {
|
|
6
|
-
it('renders a <
|
|
7
|
+
it('renders a native <input type="checkbox" role="switch">', () => {
|
|
7
8
|
// Arrange & Act
|
|
8
9
|
render(<Switch.Root aria-label="Enable notifications" />);
|
|
9
10
|
const sw = screen.getByRole("switch", { name: "Enable notifications" });
|
|
10
11
|
|
|
11
12
|
// Assert
|
|
12
|
-
expect(sw.tagName).toBe("
|
|
13
|
-
expect(sw).toHaveAttribute("type", "
|
|
13
|
+
expect(sw.tagName).toBe("INPUT");
|
|
14
|
+
expect(sw).toHaveAttribute("type", "checkbox");
|
|
14
15
|
});
|
|
15
16
|
|
|
16
|
-
it(
|
|
17
|
+
it("wraps the input in a <label> so the visible track is clickable", () => {
|
|
17
18
|
// Arrange & Act
|
|
18
19
|
render(<Switch.Root aria-label="Enable notifications" />);
|
|
19
20
|
|
|
20
21
|
// Assert
|
|
21
22
|
expect(
|
|
22
|
-
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
23
|
-
).
|
|
23
|
+
screen.getByRole("switch", { name: "Enable notifications" }).closest("label"),
|
|
24
|
+
).not.toBeNull();
|
|
24
25
|
});
|
|
25
26
|
|
|
26
|
-
it(
|
|
27
|
+
it("is unchecked by default", () => {
|
|
27
28
|
// Arrange & Act
|
|
28
29
|
render(<Switch.Root aria-label="Enable notifications" />);
|
|
29
30
|
|
|
30
31
|
// Assert
|
|
31
32
|
expect(
|
|
32
33
|
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
33
|
-
).
|
|
34
|
+
).not.toBeChecked();
|
|
34
35
|
});
|
|
35
36
|
|
|
36
|
-
it("
|
|
37
|
+
it("forwards native input attributes (name, value) to the input", () => {
|
|
37
38
|
// Arrange & Act
|
|
38
|
-
render(
|
|
39
|
-
|
|
40
|
-
<Switch.Thumb data-testid="thumb" />
|
|
41
|
-
</Switch.Root>,
|
|
42
|
-
);
|
|
39
|
+
render(<Switch.Root name="notify" value="on" aria-label="Enable notifications" />);
|
|
40
|
+
const sw = screen.getByRole("switch", { name: "Enable notifications" });
|
|
43
41
|
|
|
44
42
|
// Assert
|
|
45
|
-
|
|
46
|
-
expect(
|
|
47
|
-
expect(thumb).toBeInTheDocument();
|
|
43
|
+
expect(sw).toHaveAttribute("name", "notify");
|
|
44
|
+
expect(sw).toHaveAttribute("value", "on");
|
|
48
45
|
});
|
|
49
46
|
|
|
50
|
-
it(
|
|
47
|
+
it("applies className to the track, not the hidden input", () => {
|
|
51
48
|
// Arrange & Act
|
|
52
|
-
render(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
render(<Switch.Root className="track" aria-label="Enable notifications" />);
|
|
50
|
+
const sw = screen.getByRole("switch", { name: "Enable notifications" });
|
|
51
|
+
|
|
52
|
+
// Assert
|
|
53
|
+
expect(sw).not.toHaveClass("track");
|
|
54
|
+
expect(sw.closest("label")).toHaveClass("track");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("forwards a ref to the underlying input", () => {
|
|
58
|
+
// Arrange
|
|
59
|
+
const ref = createRef<HTMLInputElement>();
|
|
60
|
+
|
|
61
|
+
// Act
|
|
62
|
+
render(<Switch.Root ref={ref} aria-label="Enable notifications" />);
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
expect(ref.current).toBe(
|
|
66
|
+
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
56
67
|
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('defaults data-state="unchecked" on the track on mount', () => {
|
|
71
|
+
// Arrange & Act
|
|
72
|
+
const { container } = render(<Switch.Root aria-label="Enable notifications" />);
|
|
57
73
|
|
|
58
74
|
// Assert
|
|
59
|
-
expect(
|
|
75
|
+
expect(container.querySelector("label")).toHaveAttribute(
|
|
76
|
+
"data-state",
|
|
77
|
+
"unchecked",
|
|
78
|
+
);
|
|
60
79
|
});
|
|
61
80
|
|
|
62
|
-
it(
|
|
81
|
+
it("renders Switch.Thumb as an aria-hidden <span> inside the track", () => {
|
|
63
82
|
// Arrange & Act
|
|
64
83
|
render(
|
|
65
84
|
<Switch.Root aria-label="Enable notifications">
|
|
66
85
|
<Switch.Thumb data-testid="thumb" />
|
|
67
86
|
</Switch.Root>,
|
|
68
87
|
);
|
|
88
|
+
const thumb = screen.getByTestId("thumb");
|
|
69
89
|
|
|
70
90
|
// Assert
|
|
71
|
-
expect(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
);
|
|
91
|
+
expect(thumb.tagName).toBe("SPAN");
|
|
92
|
+
expect(thumb).toHaveAttribute("aria-hidden", "true");
|
|
93
|
+
expect(thumb).toHaveAttribute("data-state", "unchecked");
|
|
75
94
|
});
|
|
76
95
|
});
|
|
@@ -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 { Switch } from "../Switch";
|
|
8
8
|
|
|
@@ -10,11 +10,11 @@ import { Switch } from "../Switch";
|
|
|
10
10
|
* Auto-verification of the Switch styling contract (RFC 0004 §3.4 / D15). The
|
|
11
11
|
* `data-*` half of `registry/components/switch/contract.json` is *derived from and
|
|
12
12
|
* asserted against the rendered headless component* so it can never drift from
|
|
13
|
-
* what the component actually emits.
|
|
14
|
-
*
|
|
15
|
-
* present) plus `data-disabled=""` when disabled. The authored half (
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* what the component actually emits. The switch's `data-*` surface lives on the
|
|
14
|
+
* **track** (the styled root) — `data-state="checked" | "unchecked"` (always
|
|
15
|
+
* present) plus `data-disabled=""` when disabled. The authored half (classes,
|
|
16
|
+
* parts, custom properties) is a styling convention the headless layer does not
|
|
17
|
+
* 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 Switch 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,7 +44,6 @@ describe("Switch styling contract", () => {
|
|
|
37
44
|
);
|
|
38
45
|
|
|
39
46
|
it("declares every data-* attribute as auto-verified against the component", () => {
|
|
40
|
-
// The Switch has no authored data-* surface; the whole contract is derived.
|
|
41
47
|
expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
|
|
42
48
|
});
|
|
43
49
|
|
|
@@ -51,8 +57,8 @@ describe("Switch styling contract", () => {
|
|
|
51
57
|
<Switch.Thumb />
|
|
52
58
|
</Switch.Root>,
|
|
53
59
|
]) {
|
|
54
|
-
const { unmount } = render(ui);
|
|
55
|
-
for (const name of dataAttributeNames(
|
|
60
|
+
const { container, unmount } = render(ui);
|
|
61
|
+
for (const name of dataAttributeNames(rootOf(container))) {
|
|
56
62
|
emitted.add(name);
|
|
57
63
|
}
|
|
58
64
|
unmount();
|
|
@@ -70,7 +76,7 @@ describe("Switch styling contract", () => {
|
|
|
70
76
|
);
|
|
71
77
|
|
|
72
78
|
for (const entry of stateEntries) {
|
|
73
|
-
const { unmount } = render(
|
|
79
|
+
const { container, unmount } = render(
|
|
74
80
|
<Switch.Root
|
|
75
81
|
checked={entry.value === "checked"}
|
|
76
82
|
onCheckedChange={() => {}}
|
|
@@ -79,7 +85,7 @@ describe("Switch styling contract", () => {
|
|
|
79
85
|
<Switch.Thumb />
|
|
80
86
|
</Switch.Root>,
|
|
81
87
|
);
|
|
82
|
-
expect(
|
|
88
|
+
expect(rootOf(container)).toHaveAttribute("data-state", entry.value);
|
|
83
89
|
unmount();
|
|
84
90
|
}
|
|
85
91
|
});
|
|
@@ -89,21 +95,20 @@ describe("Switch styling contract", () => {
|
|
|
89
95
|
(attribute: { name: string }) => attribute.name === "data-disabled",
|
|
90
96
|
);
|
|
91
97
|
|
|
92
|
-
render(
|
|
98
|
+
const { container } = render(
|
|
93
99
|
<Switch.Root disabled aria-label="x">
|
|
94
100
|
<Switch.Thumb />
|
|
95
101
|
</Switch.Root>,
|
|
96
102
|
);
|
|
97
|
-
expect(
|
|
103
|
+
expect(rootOf(container)).toHaveAttribute(entry.name, entry.value);
|
|
98
104
|
});
|
|
99
105
|
|
|
100
106
|
it("omits data-disabled when the documented condition does not hold", () => {
|
|
101
|
-
render(
|
|
107
|
+
const { container } = render(
|
|
102
108
|
<Switch.Root aria-label="x">
|
|
103
109
|
<Switch.Thumb />
|
|
104
110
|
</Switch.Root>,
|
|
105
111
|
);
|
|
106
|
-
|
|
107
|
-
expect(screen.getByRole("switch")).not.toHaveAttribute("data-disabled");
|
|
112
|
+
expect(rootOf(container)).not.toHaveAttribute("data-disabled");
|
|
108
113
|
});
|
|
109
114
|
});
|
|
@@ -6,18 +6,18 @@ import { Switch } from "../Switch";
|
|
|
6
6
|
describe("Switch controlled state", () => {
|
|
7
7
|
it("reflects checked={true}", () => {
|
|
8
8
|
// Arrange & Act
|
|
9
|
-
render(
|
|
10
|
-
<Switch.Root
|
|
11
|
-
aria-label="Enable notifications"
|
|
12
|
-
checked
|
|
13
|
-
onCheckedChange={() => {}}
|
|
14
|
-
/>,
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<Switch.Root aria-label="Enable notifications" checked onCheckedChange={() => {}} />,
|
|
15
11
|
);
|
|
16
12
|
|
|
17
13
|
// Assert
|
|
18
14
|
expect(
|
|
19
15
|
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
20
|
-
).
|
|
16
|
+
).toBeChecked();
|
|
17
|
+
expect(container.querySelector("label")).toHaveAttribute(
|
|
18
|
+
"data-state",
|
|
19
|
+
"checked",
|
|
20
|
+
);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
it("reflects checked={false}", () => {
|
|
@@ -33,7 +33,7 @@ describe("Switch controlled state", () => {
|
|
|
33
33
|
// Assert
|
|
34
34
|
expect(
|
|
35
35
|
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
36
|
-
).
|
|
36
|
+
).not.toBeChecked();
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
it("fires onCheckedChange with the next value when clicked", async () => {
|
|
@@ -49,9 +49,7 @@ describe("Switch controlled state", () => {
|
|
|
49
49
|
);
|
|
50
50
|
|
|
51
51
|
// Act
|
|
52
|
-
await user.click(
|
|
53
|
-
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
54
|
-
);
|
|
52
|
+
await user.click(screen.getByRole("switch", { name: "Enable notifications" }));
|
|
55
53
|
|
|
56
54
|
// Assert
|
|
57
55
|
expect(onCheckedChange).toHaveBeenCalledOnce();
|
|
@@ -74,6 +72,6 @@ describe("Switch controlled state", () => {
|
|
|
74
72
|
await user.click(sw);
|
|
75
73
|
|
|
76
74
|
// Assert — controlled value not updated
|
|
77
|
-
expect(sw).
|
|
75
|
+
expect(sw).not.toBeChecked();
|
|
78
76
|
});
|
|
79
77
|
});
|
|
@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
|
|
|
4
4
|
import { Switch } from "../Switch";
|
|
5
5
|
|
|
6
6
|
describe("Switch disabled", () => {
|
|
7
|
-
it("sets the native disabled attribute", () => {
|
|
7
|
+
it("sets the native disabled attribute on the input", () => {
|
|
8
8
|
// Arrange & Act
|
|
9
9
|
render(<Switch.Root aria-label="Enable notifications" disabled />);
|
|
10
10
|
|
|
@@ -14,27 +14,25 @@ describe("Switch disabled", () => {
|
|
|
14
14
|
).toBeDisabled();
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
it('sets data-disabled="" when disabled', () => {
|
|
17
|
+
it('sets data-disabled="" on the track when disabled', () => {
|
|
18
18
|
// Arrange & Act
|
|
19
|
-
|
|
19
|
+
const { container } = render(
|
|
20
|
+
<Switch.Root aria-label="Enable notifications" disabled />,
|
|
21
|
+
);
|
|
20
22
|
|
|
21
23
|
// Assert
|
|
22
|
-
expect(
|
|
23
|
-
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
24
|
-
).toHaveAttribute("data-disabled", "");
|
|
24
|
+
expect(container.querySelector("label")).toHaveAttribute("data-disabled", "");
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
it("does not set data-disabled when not disabled", () => {
|
|
28
28
|
// Arrange & Act
|
|
29
|
-
render(<Switch.Root aria-label="Enable notifications" />);
|
|
29
|
+
const { container } = render(<Switch.Root aria-label="Enable notifications" />);
|
|
30
30
|
|
|
31
31
|
// Assert
|
|
32
|
-
expect(
|
|
33
|
-
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
34
|
-
).not.toHaveAttribute("data-disabled");
|
|
32
|
+
expect(container.querySelector("label")).not.toHaveAttribute("data-disabled");
|
|
35
33
|
});
|
|
36
34
|
|
|
37
|
-
it("does not toggle
|
|
35
|
+
it("does not toggle or call onCheckedChange when clicked while disabled", async () => {
|
|
38
36
|
// Arrange
|
|
39
37
|
const user = userEvent.setup();
|
|
40
38
|
const onCheckedChange = vi.fn();
|
|
@@ -45,16 +43,13 @@ describe("Switch disabled", () => {
|
|
|
45
43
|
onCheckedChange={onCheckedChange}
|
|
46
44
|
/>,
|
|
47
45
|
);
|
|
46
|
+
const sw = screen.getByRole("switch", { name: "Enable notifications" });
|
|
48
47
|
|
|
49
48
|
// Act
|
|
50
|
-
await user.click(
|
|
51
|
-
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
52
|
-
);
|
|
49
|
+
await user.click(sw);
|
|
53
50
|
|
|
54
51
|
// Assert
|
|
55
52
|
expect(onCheckedChange).not.toHaveBeenCalled();
|
|
56
|
-
expect(
|
|
57
|
-
screen.getByRole("switch", { name: "Enable notifications" }),
|
|
58
|
-
).toHaveAttribute("aria-checked", "false");
|
|
53
|
+
expect(sw).not.toBeChecked();
|
|
59
54
|
});
|
|
60
55
|
});
|