@primitiv-ui/react 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/Checkbox/Checkbox.tsx +96 -120
  4. package/src/Checkbox/CheckboxContext.ts +8 -0
  5. package/src/Checkbox/README.md +69 -88
  6. package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +48 -10
  7. package/src/Checkbox/__tests__/Checkbox.contract.test.tsx +29 -40
  8. package/src/Checkbox/__tests__/Checkbox.controlled-state.test.tsx +16 -24
  9. package/src/Checkbox/__tests__/Checkbox.disabled.test.tsx +33 -3
  10. package/src/Checkbox/__tests__/Checkbox.form.test.tsx +53 -0
  11. package/src/Checkbox/__tests__/Checkbox.indeterminate.test.tsx +28 -34
  12. package/src/Checkbox/__tests__/Checkbox.indicator.test.tsx +27 -52
  13. package/src/Checkbox/__tests__/Checkbox.uncontrolled-state.test.tsx +33 -17
  14. package/src/Checkbox/hooks/index.ts +1 -0
  15. package/src/Checkbox/hooks/useCheckboxInput.ts +49 -0
  16. package/src/Checkbox/types.ts +24 -17
  17. package/src/Radio/README.md +89 -86
  18. package/src/Radio/Radio.tsx +87 -98
  19. package/src/Radio/RadioContext.ts +9 -0
  20. package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +34 -9
  21. package/src/Radio/__tests__/Radio.contract.test.tsx +30 -36
  22. package/src/Radio/__tests__/Radio.controlled-state.test.tsx +21 -11
  23. package/src/Radio/__tests__/Radio.disabled.test.tsx +31 -6
  24. package/src/Radio/__tests__/Radio.native-grouping.test.tsx +77 -0
  25. package/src/Radio/__tests__/Radio.parts.test.tsx +76 -0
  26. package/src/Radio/__tests__/Radio.uncontrolled-state.test.tsx +39 -32
  27. package/src/Radio/hooks/useRadioRoot.ts +32 -16
  28. package/src/Radio/types.ts +30 -16
  29. package/src/Switch/README.md +45 -57
  30. package/src/Switch/Switch.tsx +78 -65
  31. package/src/Switch/SwitchContext.ts +3 -1
  32. package/src/Switch/__tests__/Switch.asChild.test.tsx +16 -34
  33. package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +47 -28
  34. package/src/Switch/__tests__/Switch.contract.test.tsx +21 -16
  35. package/src/Switch/__tests__/Switch.controlled-state.test.tsx +10 -12
  36. package/src/Switch/__tests__/Switch.disabled.test.tsx +12 -17
  37. package/src/Switch/__tests__/Switch.form.test.tsx +55 -0
  38. package/src/Switch/__tests__/Switch.keyboard-interaction.test.tsx +4 -4
  39. package/src/Switch/__tests__/Switch.uncontrolled-state.test.tsx +55 -20
  40. package/src/Switch/hooks/useSwitchRoot.ts +29 -11
  41. package/src/Switch/types.ts +20 -10
  42. package/src/Checkbox/__tests__/Checkbox.asChild.test.tsx +0 -69
  43. package/src/Radio/__tests__/Radio.asChild.test.tsx +0 -63
  44. package/src/Radio/__tests__/Radio.indicator.test.tsx +0 -103
@@ -1,41 +1,50 @@
1
1
  import { useMemo } from "react";
2
2
  import type { ReactElement } from "react";
3
3
 
4
- import { Slot, composeEventHandlers } from "../Slot/index.ts";
4
+ import { Slot } from "../Slot/index.ts";
5
5
 
6
6
  import { 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 native `<button type="button" role="switch">` that
12
- * owns the binary checked state and provides {@link SwitchContext} to
13
- * descendant {@link SwitchThumb | `Switch.Thumb`}s.
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
- * {@link SwitchRootProps.defaultChecked | `defaultChecked`} (or omit for
19
- * unchecked-on-mount). The component owns the value internally.
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"` on the root, plus
30
- * `data-disabled=""` when disabled.
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
- * **`asChild` prop.** Pass `asChild` to render any consumer-supplied element
33
- * with the switch's ARIA attributes, `data-state`, composed `onClick`, and
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
- ...rest
71
+ ...inputRest
62
72
  }: SwitchRootProps): ReactElement {
63
- const { checked: isChecked, toggle } = useSwitchRoot({
64
- defaultChecked,
65
- checked,
66
- onCheckedChange,
67
- });
68
- const contextValue = useMemo(() => ({ checked: isChecked }), [isChecked]);
69
- const rootProps = {
70
- ...rest,
71
- ref,
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
- {asChild ? (
82
- <Slot {...rootProps}>{children}</Slot>
83
- ) : (
84
- <button type="button" {...rootProps}>
85
- {children}
86
- </button>
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">` that represents the sliding thumb
97
- * of the switch. Always mounted — its position is driven entirely by CSS
98
- * targeting `data-state="checked" | "unchecked"`. Unlike
99
- * {@link Checkbox.Indicator}, it never conditionally unmounts.
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 parent Root's
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
- * as the thumb, with `aria-hidden` and `data-state` merged in.
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": checked ? ("checked" as const) : ("unchecked" as const),
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 toggle button, owning checked state and context. */
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 implementing the
146
- * [WAI-ARIA Switch pattern](https://www.w3.org/WAI/ARIA/apg/patterns/switch/)
147
- * on a native `<button role="switch">`. Semantically represents an immediate
148
- * on/off action (as opposed to a selection choice). Zero styles ship.
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, toggle button.
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 `data-state` via CSS.
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 ARIA details.
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 Root as the consumer element when asChild is set", () => {
6
+ it("renders the Thumb as the consumer element when asChild is set", () => {
8
7
  // Arrange & Act
9
8
  render(
10
- <Switch.Root asChild aria-label="Enable notifications">
11
- <div>Toggle</div>
12
- </Switch.Root>,
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
- // Act
32
- await user.click(screen.getByRole("switch", { name: "Enable notifications" }));
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("renders the Thumb as the consumer element when asChild is set", () => {
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 — our span rendered; aria-hidden and data-state merged onto it
52
- const thumb = screen.getByTestId("custom-thumb");
53
- expect(thumb).toHaveAttribute("aria-hidden", "true");
54
- expect(thumb).toHaveAttribute("data-state", "unchecked");
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 <button type="button" role="switch">', () => {
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("BUTTON");
13
- expect(sw).toHaveAttribute("type", "button");
13
+ expect(sw.tagName).toBe("INPUT");
14
+ expect(sw).toHaveAttribute("type", "checkbox");
14
15
  });
15
16
 
16
- it('defaults aria-checked="false" when unchecked', () => {
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
- ).toHaveAttribute("aria-checked", "false");
23
+ screen.getByRole("switch", { name: "Enable notifications" }).closest("label"),
24
+ ).not.toBeNull();
24
25
  });
25
26
 
26
- it('defaults data-state="unchecked" on mount', () => {
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
- ).toHaveAttribute("data-state", "unchecked");
34
+ ).not.toBeChecked();
34
35
  });
35
36
 
36
- it("renders Switch.Thumb as a <span> inside the root", () => {
37
+ it("forwards native input attributes (name, value) to the input", () => {
37
38
  // Arrange & Act
38
- render(
39
- <Switch.Root aria-label="Enable notifications">
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
- const thumb = screen.getByTestId("thumb");
46
- expect(thumb.tagName).toBe("SPAN");
47
- expect(thumb).toBeInTheDocument();
43
+ expect(sw).toHaveAttribute("name", "notify");
44
+ expect(sw).toHaveAttribute("value", "on");
48
45
  });
49
46
 
50
- it('sets aria-hidden="true" on the Thumb', () => {
47
+ it("applies className to the track, not the hidden input", () => {
51
48
  // Arrange & Act
52
- render(
53
- <Switch.Root aria-label="Enable notifications">
54
- <Switch.Thumb data-testid="thumb" />
55
- </Switch.Root>,
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(screen.getByTestId("thumb")).toHaveAttribute("aria-hidden", "true");
75
+ expect(container.querySelector("label")).toHaveAttribute(
76
+ "data-state",
77
+ "unchecked",
78
+ );
60
79
  });
61
80
 
62
- it('sets data-state="unchecked" on Thumb by default', () => {
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(screen.getByTestId("thumb")).toHaveAttribute(
72
- "data-state",
73
- "unchecked",
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, screen } from "@testing-library/react";
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. Switch is the state-driven proof (no
14
- * modifiers): its surface is `data-state="checked" | "unchecked"` (always
15
- * present) plus `data-disabled=""` when disabled. The authored half (root
16
- * class, parts, custom properties) is a styling convention the headless layer
17
- * does not emit and is checked by the generator drift-guards instead.
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(screen.getByRole("switch"))) {
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(screen.getByRole("switch")).toHaveAttribute("data-state", entry.value);
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(screen.getByRole("switch")).toHaveAttribute(entry.name, entry.value);
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
- ).toHaveAttribute("aria-checked", "true");
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
- ).toHaveAttribute("aria-checked", "false");
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).toHaveAttribute("aria-checked", "false");
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
- render(<Switch.Root aria-label="Enable notifications" disabled />);
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 and does not call onCheckedChange when clicked while disabled", async () => {
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
  });