@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.
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,79 @@
1
+ import { createRef } from "react";
1
2
  import { render, screen } from "@testing-library/react";
2
3
 
3
4
  import { Checkbox } from "../Checkbox";
4
5
 
5
6
  describe("Checkbox basic rendering", () => {
6
- it('renders a <button> with role="checkbox"', () => {
7
+ it("renders a real native <input type=checkbox> as the control", () => {
7
8
  // Arrange & Act
8
9
  render(<Checkbox.Root aria-label="Accept terms" />);
9
10
  const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
10
11
 
11
12
  // Assert
12
- expect(checkbox.tagName).toBe("BUTTON");
13
+ expect(checkbox.tagName).toBe("INPUT");
14
+ expect(checkbox).toHaveAttribute("type", "checkbox");
13
15
  });
14
16
 
15
- it('defaults aria-checked to "false"', () => {
17
+ it("wraps the input in a <label> so the visible box is clickable", () => {
16
18
  // Arrange & Act
17
19
  render(<Checkbox.Root aria-label="Accept terms" />);
18
- const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
19
20
 
20
21
  // Assert
21
- expect(checkbox).toHaveAttribute("aria-checked", "false");
22
+ expect(
23
+ screen.getByRole("checkbox", { name: "Accept terms" }).closest("label"),
24
+ ).not.toBeNull();
22
25
  });
23
26
 
24
- it('defaults type="button" so the checkbox never submits an enclosing form', () => {
27
+ it("is unchecked by default", () => {
25
28
  // Arrange & Act
26
29
  render(<Checkbox.Root aria-label="Accept terms" />);
30
+
31
+ // Assert
32
+ expect(screen.getByRole("checkbox", { name: "Accept terms" })).not.toBeChecked();
33
+ });
34
+
35
+ it("forwards native input attributes (name, value, id) to the input", () => {
36
+ // Arrange & Act
37
+ render(
38
+ <Checkbox.Root id="c1" name="terms" value="accepted" aria-label="Accept terms" />,
39
+ );
27
40
  const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
28
41
 
29
42
  // Assert
30
- expect(checkbox).toHaveAttribute("type", "button");
43
+ expect(checkbox).toHaveAttribute("id", "c1");
44
+ expect(checkbox).toHaveAttribute("name", "terms");
45
+ expect(checkbox).toHaveAttribute("value", "accepted");
31
46
  });
32
47
 
33
- it('sets data-state="unchecked" on the root when unchecked', () => {
48
+ it("applies className to the box, not the hidden input", () => {
34
49
  // Arrange & Act
35
- render(<Checkbox.Root aria-label="Accept terms" />);
50
+ render(<Checkbox.Root className="box" aria-label="Accept terms" />);
36
51
  const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
37
52
 
38
53
  // Assert
39
- expect(checkbox).toHaveAttribute("data-state", "unchecked");
54
+ expect(checkbox).not.toHaveClass("box");
55
+ expect(checkbox.closest("label")).toHaveClass("box");
56
+ });
57
+
58
+ it("forwards a ref to the underlying input", () => {
59
+ // Arrange
60
+ const ref = createRef<HTMLInputElement>();
61
+
62
+ // Act
63
+ render(<Checkbox.Root ref={ref} aria-label="Accept terms" />);
64
+
65
+ // Assert
66
+ expect(ref.current).toBe(screen.getByRole("checkbox", { name: "Accept terms" }));
67
+ });
68
+
69
+ it('sets data-state="unchecked" on the box when unchecked', () => {
70
+ // Arrange & Act
71
+ const { container } = render(<Checkbox.Root aria-label="Accept terms" />);
72
+
73
+ // Assert
74
+ expect(container.querySelector("label")).toHaveAttribute(
75
+ "data-state",
76
+ "unchecked",
77
+ );
40
78
  });
41
79
  });
@@ -2,19 +2,21 @@ 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 { Checkbox } from "../Checkbox";
8
+ import { CheckedState } from "../types";
8
9
 
9
10
  /**
10
11
  * Auto-verification of the Checkbox styling contract (RFC 0004 §3.4 / D15). The
11
12
  * `data-*` half of `registry/components/checkbox/contract.json` is *derived from
12
13
  * and asserted against the rendered headless component* so it can never drift
13
- * from what the component actually emits. Checkbox is tri-state: its surface is
14
- * `data-state="checked" | "unchecked" | "indeterminate"` (always present) plus
15
- * `data-disabled=""` when disabled. The authored half (root class, parts, custom
16
- * properties) is a styling convention the headless layer does not emit and is
17
- * checked by the generator drift-guards instead.
14
+ * from what the component actually emits. The checkbox's `data-*` surface lives
15
+ * on the **wrapper box** (the styled root) — `data-state="checked" |
16
+ * "unchecked" | "indeterminate"` (always present) plus `data-disabled=""` when
17
+ * disabled. The authored half (classes, parts, custom properties) is a styling
18
+ * convention the headless layer does not emit and is checked by the generator
19
+ * drift-guards instead.
18
20
  */
19
21
  const contractPath = resolve(
20
22
  dirname(fileURLToPath(import.meta.url)),
@@ -23,6 +25,13 @@ const contractPath = resolve(
23
25
 
24
26
  const contract = JSON.parse(readFileSync(contractPath, "utf8"));
25
27
 
28
+ /** The wrapper element is the contracted styling root. */
29
+ function rootOf(container: HTMLElement): HTMLElement {
30
+ const root = container.querySelector("label");
31
+ if (!root) throw new Error("expected a Checkbox wrapper element");
32
+ return root;
33
+ }
34
+
26
35
  /** Every `data-*` attribute name present on an element. */
27
36
  function dataAttributeNames(element: Element): string[] {
28
37
  return [...element.attributes]
@@ -37,30 +46,23 @@ describe("Checkbox styling contract", () => {
37
46
  );
38
47
 
39
48
  it("declares every data-* attribute as auto-verified against the component", () => {
40
- // The Checkbox has no authored data-* surface; the whole contract is derived.
41
49
  expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
42
50
  });
43
51
 
44
52
  it("emits exactly the contracted data-* attribute names across its states", () => {
45
53
  const emitted = new Set<string>();
46
54
  for (const ui of [
47
- <Checkbox.Root key="checked" checked onCheckedChange={() => {}} aria-label="x">
48
- <Checkbox.Indicator />
49
- </Checkbox.Root>,
55
+ <Checkbox.Root key="checked" checked onCheckedChange={() => {}} aria-label="x" />,
50
56
  <Checkbox.Root
51
57
  key="indeterminate"
52
58
  checked="indeterminate"
53
59
  onCheckedChange={() => {}}
54
60
  aria-label="x"
55
- >
56
- <Checkbox.Indicator />
57
- </Checkbox.Root>,
58
- <Checkbox.Root key="disabled" disabled aria-label="x">
59
- <Checkbox.Indicator />
60
- </Checkbox.Root>,
61
+ />,
62
+ <Checkbox.Root key="disabled" disabled aria-label="x" />,
61
63
  ]) {
62
- const { unmount } = render(ui);
63
- for (const name of dataAttributeNames(screen.getByRole("checkbox"))) {
64
+ const { container, unmount } = render(ui);
65
+ for (const name of dataAttributeNames(rootOf(container))) {
64
66
  emitted.add(name);
65
67
  }
66
68
  unmount();
@@ -78,16 +80,12 @@ describe("Checkbox styling contract", () => {
78
80
  );
79
81
 
80
82
  for (const entry of stateEntries) {
81
- const checked =
82
- entry.value === "indeterminate"
83
- ? ("indeterminate" as const)
84
- : entry.value === "checked";
85
- const { unmount } = render(
86
- <Checkbox.Root checked={checked} onCheckedChange={() => {}} aria-label="x">
87
- <Checkbox.Indicator />
88
- </Checkbox.Root>,
83
+ const checked: CheckedState =
84
+ entry.value === "indeterminate" ? "indeterminate" : entry.value === "checked";
85
+ const { container, unmount } = render(
86
+ <Checkbox.Root checked={checked} onCheckedChange={() => {}} aria-label="x" />,
89
87
  );
90
- expect(screen.getByRole("checkbox")).toHaveAttribute("data-state", entry.value);
88
+ expect(rootOf(container)).toHaveAttribute("data-state", entry.value);
91
89
  unmount();
92
90
  }
93
91
  });
@@ -97,21 +95,12 @@ describe("Checkbox styling contract", () => {
97
95
  (attribute: { name: string }) => attribute.name === "data-disabled",
98
96
  );
99
97
 
100
- render(
101
- <Checkbox.Root disabled aria-label="x">
102
- <Checkbox.Indicator />
103
- </Checkbox.Root>,
104
- );
105
- expect(screen.getByRole("checkbox")).toHaveAttribute(entry.name, entry.value);
98
+ const { container } = render(<Checkbox.Root disabled aria-label="x" />);
99
+ expect(rootOf(container)).toHaveAttribute(entry.name, entry.value);
106
100
  });
107
101
 
108
102
  it("omits data-disabled when the documented condition does not hold", () => {
109
- render(
110
- <Checkbox.Root aria-label="x">
111
- <Checkbox.Indicator />
112
- </Checkbox.Root>,
113
- );
114
-
115
- expect(screen.getByRole("checkbox")).not.toHaveAttribute("data-disabled");
103
+ const { container } = render(<Checkbox.Root aria-label="x" />);
104
+ expect(rootOf(container)).not.toHaveAttribute("data-disabled");
116
105
  });
117
106
  });
@@ -7,27 +7,22 @@ import { Checkbox } from "../Checkbox";
7
7
  describe("Checkbox controlled state", () => {
8
8
  it("reflects the controlled `checked` prop", () => {
9
9
  // Arrange & Act
10
- const { rerender } = render(
11
- <Checkbox.Root
12
- checked={false}
13
- onCheckedChange={() => {}}
14
- aria-label="Accept terms"
15
- />,
10
+ const { container, rerender } = render(
11
+ <Checkbox.Root checked={false} onCheckedChange={() => {}} aria-label="Accept terms" />,
16
12
  );
17
13
  const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
18
- expect(checkbox).toHaveAttribute("aria-checked", "false");
14
+ expect(checkbox).not.toBeChecked();
19
15
 
20
16
  rerender(
21
- <Checkbox.Root
22
- checked
23
- onCheckedChange={() => {}}
24
- aria-label="Accept terms"
25
- />,
17
+ <Checkbox.Root checked onCheckedChange={() => {}} aria-label="Accept terms" />,
26
18
  );
27
19
 
28
20
  // Assert
29
- expect(checkbox).toHaveAttribute("aria-checked", "true");
30
- expect(checkbox).toHaveAttribute("data-state", "checked");
21
+ expect(checkbox).toBeChecked();
22
+ expect(container.querySelector("label")).toHaveAttribute(
23
+ "data-state",
24
+ "checked",
25
+ );
31
26
  });
32
27
 
33
28
  it("does not update its rendered state when the parent refuses to update `checked`", async () => {
@@ -46,20 +41,17 @@ describe("Checkbox controlled state", () => {
46
41
  // Act
47
42
  await user.click(checkbox);
48
43
 
49
- // Assert: callback fired but the rendered state stays false because the
50
- // parent did not flip the controlled prop.
44
+ // Assert: callback fired but the rendered state stays unchecked.
51
45
  expect(onCheckedChange).toHaveBeenCalledWith(true);
52
- expect(checkbox).toHaveAttribute("aria-checked", "false");
53
- expect(checkbox).toHaveAttribute("data-state", "unchecked");
46
+ expect(checkbox).not.toBeChecked();
54
47
  });
55
48
 
56
49
  it("lets a parent drive the value end to end", async () => {
57
50
  // Arrange
58
51
  const user = userEvent.setup();
59
52
  function Harness() {
60
- // Start true so the pre-click state can only be correct if the
61
- // controlled prop is honoured (a broken impl would fall back to
62
- // defaultChecked=false and render the opposite).
53
+ // Start true so the pre-click state is only correct if the controlled
54
+ // prop is honoured (a broken impl would fall back to unchecked).
63
55
  const [checked, setChecked] = useState(true);
64
56
  return (
65
57
  <Checkbox.Root
@@ -71,12 +63,12 @@ describe("Checkbox controlled state", () => {
71
63
  }
72
64
  render(<Harness />);
73
65
  const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
74
- expect(checkbox).toHaveAttribute("aria-checked", "true");
66
+ expect(checkbox).toBeChecked();
75
67
 
76
68
  // Act & Assert
77
69
  await user.click(checkbox);
78
- expect(checkbox).toHaveAttribute("aria-checked", "false");
70
+ expect(checkbox).not.toBeChecked();
79
71
  await user.click(checkbox);
80
- expect(checkbox).toHaveAttribute("aria-checked", "true");
72
+ expect(checkbox).toBeChecked();
81
73
  });
82
74
  });
@@ -1,15 +1,45 @@
1
1
  import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
2
3
 
3
4
  import { Checkbox } from "../Checkbox";
4
5
 
5
6
  describe("Checkbox disabled state", () => {
6
- it('sets data-disabled="" on the root so CSS can target the disabled state', () => {
7
+ it("disables the underlying native input", () => {
7
8
  // Arrange & Act
8
9
  render(<Checkbox.Root disabled aria-label="Accept terms" />);
9
- const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
10
10
 
11
11
  // Assert
12
- expect(checkbox).toHaveAttribute("data-disabled", "");
12
+ expect(screen.getByRole("checkbox", { name: "Accept terms" })).toBeDisabled();
13
13
  });
14
14
 
15
+ it('sets data-disabled="" on the box so CSS can target the disabled state', () => {
16
+ // Arrange & Act
17
+ const { container } = render(<Checkbox.Root disabled aria-label="Accept terms" />);
18
+
19
+ // Assert
20
+ expect(container.querySelector("label")).toHaveAttribute("data-disabled", "");
21
+ });
22
+
23
+ it("omits data-disabled when not disabled", () => {
24
+ // Arrange & Act
25
+ const { container } = render(<Checkbox.Root aria-label="Accept terms" />);
26
+
27
+ // Assert
28
+ expect(container.querySelector("label")).not.toHaveAttribute("data-disabled");
29
+ });
30
+
31
+ it("does not fire onCheckedChange when a disabled checkbox is clicked", async () => {
32
+ // Arrange
33
+ const user = userEvent.setup();
34
+ const onCheckedChange = vi.fn();
35
+ render(
36
+ <Checkbox.Root disabled onCheckedChange={onCheckedChange} aria-label="Accept terms" />,
37
+ );
38
+
39
+ // Act
40
+ await user.click(screen.getByRole("checkbox", { name: "Accept terms" }));
41
+
42
+ // Assert
43
+ expect(onCheckedChange).not.toHaveBeenCalled();
44
+ });
15
45
  });
@@ -0,0 +1,53 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { Checkbox } from "../Checkbox";
5
+
6
+ /**
7
+ * The checkbox is a real native input, so it participates in forms: a checked
8
+ * box submits its `value` under `name`, and an unchecked one submits nothing.
9
+ * The old `<button role="checkbox">` could do neither.
10
+ */
11
+ describe("Checkbox form participation", () => {
12
+ function NewsletterForm({ onSubmit }: { onSubmit: (value: string | null) => void }) {
13
+ return (
14
+ <form
15
+ onSubmit={(event) => {
16
+ event.preventDefault();
17
+ const data = new FormData(event.currentTarget);
18
+ onSubmit(data.get("newsletter") as string | null);
19
+ }}
20
+ >
21
+ <Checkbox.Root name="newsletter" value="weekly" aria-label="Subscribe" />
22
+ <button type="submit">Save</button>
23
+ </form>
24
+ );
25
+ }
26
+
27
+ it("submits the value when checked", async () => {
28
+ // Arrange
29
+ const user = userEvent.setup();
30
+ const onSubmit = vi.fn();
31
+ render(<NewsletterForm onSubmit={onSubmit} />);
32
+
33
+ // Act
34
+ await user.click(screen.getByRole("checkbox", { name: "Subscribe" }));
35
+ await user.click(screen.getByRole("button", { name: "Save" }));
36
+
37
+ // Assert
38
+ expect(onSubmit).toHaveBeenCalledWith("weekly");
39
+ });
40
+
41
+ it("submits nothing when unchecked", async () => {
42
+ // Arrange
43
+ const user = userEvent.setup();
44
+ const onSubmit = vi.fn();
45
+ render(<NewsletterForm onSubmit={onSubmit} />);
46
+
47
+ // Act
48
+ await user.click(screen.getByRole("button", { name: "Save" }));
49
+
50
+ // Assert
51
+ expect(onSubmit).toHaveBeenCalledWith(null);
52
+ });
53
+ });
@@ -4,39 +4,34 @@ import userEvent from "@testing-library/user-event";
4
4
  import { Checkbox } from "../Checkbox";
5
5
 
6
6
  describe("Checkbox indeterminate state", () => {
7
- it('exposes aria-checked="mixed" when defaultChecked is "indeterminate"', () => {
7
+ it('marks the input partially-checked (aria mixed) when defaultChecked is "indeterminate"', () => {
8
8
  // Arrange & Act
9
- render(
10
- <Checkbox.Root
11
- defaultChecked="indeterminate"
12
- aria-label="Accept terms"
13
- />,
14
- );
15
- const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
9
+ render(<Checkbox.Root defaultChecked="indeterminate" aria-label="Accept terms" />);
16
10
 
17
11
  // Assert
18
- expect(checkbox).toHaveAttribute("aria-checked", "mixed");
12
+ expect(
13
+ screen.getByRole("checkbox", { name: "Accept terms" }),
14
+ ).toBePartiallyChecked();
19
15
  });
20
16
 
21
- it('sets data-state="indeterminate" on the root in indeterminate mode', () => {
17
+ it('sets data-state="indeterminate" on the box in indeterminate mode', () => {
22
18
  // Arrange & Act
23
- render(
24
- <Checkbox.Root
25
- defaultChecked="indeterminate"
26
- aria-label="Accept terms"
27
- />,
19
+ const { container } = render(
20
+ <Checkbox.Root defaultChecked="indeterminate" aria-label="Accept terms" />,
28
21
  );
29
- const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
30
22
 
31
23
  // Assert
32
- expect(checkbox).toHaveAttribute("data-state", "indeterminate");
24
+ expect(container.querySelector("label")).toHaveAttribute(
25
+ "data-state",
26
+ "indeterminate",
27
+ );
33
28
  });
34
29
 
35
30
  it("resolves to checked=true on the first click (WAI-ARIA tri-state convention)", async () => {
36
31
  // Arrange
37
32
  const user = userEvent.setup();
38
33
  const onCheckedChange = vi.fn();
39
- render(
34
+ const { container } = render(
40
35
  <Checkbox.Root
41
36
  defaultChecked="indeterminate"
42
37
  onCheckedChange={onCheckedChange}
@@ -50,33 +45,32 @@ describe("Checkbox indeterminate state", () => {
50
45
 
51
46
  // Assert
52
47
  expect(onCheckedChange).toHaveBeenCalledWith(true);
53
- expect(checkbox).toHaveAttribute("aria-checked", "true");
54
- expect(checkbox).toHaveAttribute("data-state", "checked");
48
+ expect(checkbox).toBeChecked();
49
+ expect(checkbox).not.toBePartiallyChecked();
50
+ expect(container.querySelector("label")).toHaveAttribute(
51
+ "data-state",
52
+ "checked",
53
+ );
55
54
  });
56
55
 
57
56
  it('honours controlled checked="indeterminate" across re-renders', () => {
58
57
  // Arrange
59
- const { rerender } = render(
60
- <Checkbox.Root
61
- checked={false}
62
- onCheckedChange={() => {}}
63
- aria-label="Accept terms"
64
- />,
58
+ const { container, rerender } = render(
59
+ <Checkbox.Root checked={false} onCheckedChange={() => {}} aria-label="Accept terms" />,
65
60
  );
66
61
  const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
67
- expect(checkbox).toHaveAttribute("aria-checked", "false");
62
+ expect(checkbox).not.toBeChecked();
68
63
 
69
64
  // Act
70
65
  rerender(
71
- <Checkbox.Root
72
- checked="indeterminate"
73
- onCheckedChange={() => {}}
74
- aria-label="Accept terms"
75
- />,
66
+ <Checkbox.Root checked="indeterminate" onCheckedChange={() => {}} aria-label="Accept terms" />,
76
67
  );
77
68
 
78
69
  // Assert
79
- expect(checkbox).toHaveAttribute("aria-checked", "mixed");
80
- expect(checkbox).toHaveAttribute("data-state", "indeterminate");
70
+ expect(checkbox).toBePartiallyChecked();
71
+ expect(container.querySelector("label")).toHaveAttribute(
72
+ "data-state",
73
+ "indeterminate",
74
+ );
81
75
  });
82
76
  });
@@ -4,103 +4,80 @@ import userEvent from "@testing-library/user-event";
4
4
  import { Checkbox } from "../Checkbox";
5
5
 
6
6
  describe("Checkbox.Indicator", () => {
7
- it("does not render its children when the checkbox is unchecked", () => {
8
- // Arrange & Act
7
+ it("is always mounted, regardless of checked state", () => {
8
+ // Arrange & Act — unchecked, yet the mark is in the DOM (CSS hides it).
9
9
  render(
10
10
  <Checkbox.Root aria-label="Accept terms">
11
- <Checkbox.Indicator>tick</Checkbox.Indicator>
12
- </Checkbox.Root>,
13
- );
14
-
15
- // Assert
16
- expect(screen.queryByText("tick")).not.toBeInTheDocument();
17
- });
18
-
19
- it("renders its children when the checkbox is checked", () => {
20
- // Arrange & Act
21
- render(
22
- <Checkbox.Root defaultChecked aria-label="Accept terms">
23
- <Checkbox.Indicator>tick</Checkbox.Indicator>
11
+ <Checkbox.Indicator data-testid="mark" />
24
12
  </Checkbox.Root>,
25
13
  );
26
14
 
27
15
  // Assert
28
- expect(screen.getByText("tick")).toBeInTheDocument();
16
+ expect(screen.getByTestId("mark")).toBeInTheDocument();
29
17
  });
30
18
 
31
- it("renders its children when the checkbox is indeterminate", () => {
19
+ it("is decorative, carrying aria-hidden", () => {
32
20
  // Arrange & Act
33
21
  render(
34
- <Checkbox.Root defaultChecked="indeterminate" aria-label="Accept terms">
35
- <Checkbox.Indicator>tick</Checkbox.Indicator>
22
+ <Checkbox.Root aria-label="Accept terms">
23
+ <Checkbox.Indicator data-testid="mark" />
36
24
  </Checkbox.Root>,
37
25
  );
38
26
 
39
27
  // Assert
40
- expect(screen.getByText("tick")).toBeInTheDocument();
28
+ expect(screen.getByTestId("mark")).toHaveAttribute("aria-hidden", "true");
41
29
  });
42
30
 
43
- it("mounts and unmounts in response to toggling", async () => {
31
+ it("mirrors the checkbox's data-state for CSS to key off", async () => {
44
32
  // Arrange
45
33
  const user = userEvent.setup();
46
34
  render(
47
35
  <Checkbox.Root aria-label="Accept terms">
48
- <Checkbox.Indicator>tick</Checkbox.Indicator>
36
+ <Checkbox.Indicator data-testid="mark" />
49
37
  </Checkbox.Root>,
50
38
  );
51
- expect(screen.queryByText("tick")).not.toBeInTheDocument();
39
+ const mark = screen.getByTestId("mark");
40
+ expect(mark).toHaveAttribute("data-state", "unchecked");
52
41
 
53
42
  // Act
54
43
  await user.click(screen.getByRole("checkbox", { name: "Accept terms" }));
55
44
 
56
45
  // Assert
57
- expect(screen.getByText("tick")).toBeInTheDocument();
46
+ expect(mark).toHaveAttribute("data-state", "checked");
58
47
  });
59
48
 
60
- it('carries aria-hidden="true" since it is decorative', () => {
61
- // Arrange & Act
62
- render(
63
- <Checkbox.Root defaultChecked aria-label="Accept terms">
64
- <Checkbox.Indicator data-testid="indicator">tick</Checkbox.Indicator>
65
- </Checkbox.Root>,
66
- );
67
-
68
- // Assert
69
- expect(screen.getByTestId("indicator")).toHaveAttribute(
70
- "aria-hidden",
71
- "true",
72
- );
73
- });
74
-
75
- it("mirrors the checkbox's data-state on the indicator", () => {
49
+ it("mirrors the indeterminate data-state", () => {
76
50
  // Arrange & Act
77
51
  render(
78
52
  <Checkbox.Root defaultChecked="indeterminate" aria-label="Accept terms">
79
- <Checkbox.Indicator data-testid="indicator">tick</Checkbox.Indicator>
53
+ <Checkbox.Indicator data-testid="mark" />
80
54
  </Checkbox.Root>,
81
55
  );
82
56
 
83
57
  // Assert
84
- expect(screen.getByTestId("indicator")).toHaveAttribute(
58
+ expect(screen.getByTestId("mark")).toHaveAttribute(
85
59
  "data-state",
86
60
  "indeterminate",
87
61
  );
88
62
  });
89
63
 
90
- it("stays in the DOM while unchecked when forceMount is set", () => {
64
+ it("delegates to the consumer's element (e.g. an svg tick) via asChild", () => {
91
65
  // Arrange & Act
92
66
  render(
93
- <Checkbox.Root aria-label="Accept terms">
94
- <Checkbox.Indicator forceMount data-testid="indicator">
95
- tick
67
+ <Checkbox.Root defaultChecked aria-label="Accept terms">
68
+ <Checkbox.Indicator asChild>
69
+ <svg data-testid="tick" viewBox="0 0 10 10">
70
+ <path d="M1 5l3 3 5-7" />
71
+ </svg>
96
72
  </Checkbox.Indicator>
97
73
  </Checkbox.Root>,
98
74
  );
99
- const indicator = screen.getByTestId("indicator");
75
+ const tick = screen.getByTestId("tick");
100
76
 
101
77
  // Assert
102
- expect(indicator).toBeInTheDocument();
103
- expect(indicator).toHaveAttribute("data-state", "unchecked");
78
+ expect(tick.tagName.toLowerCase()).toBe("svg");
79
+ expect(tick).toHaveAttribute("aria-hidden", "true");
80
+ expect(tick).toHaveAttribute("data-state", "checked");
104
81
  });
105
82
 
106
83
  it("throws when rendered outside Checkbox.Root", () => {
@@ -108,9 +85,7 @@ describe("Checkbox.Indicator", () => {
108
85
  const error = vi.spyOn(console, "error").mockImplementation(() => {});
109
86
 
110
87
  // Assert
111
- expect(() =>
112
- render(<Checkbox.Indicator>tick</Checkbox.Indicator>),
113
- ).toThrow();
88
+ expect(() => render(<Checkbox.Indicator />)).toThrow();
114
89
 
115
90
  error.mockRestore();
116
91
  });