@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.
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
@@ -0,0 +1,77 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { Radio } from "../Radio";
5
+
6
+ /**
7
+ * The behaviour this whole redesign exists to fix: three standalone radios
8
+ * sharing a `name` must form a *native* radio group, where the browser — not
9
+ * React — enforces single-selection and silently deselects the others. The
10
+ * old `<button role="radio">` could never do this; a real `<input
11
+ * type="radio">` does it for free.
12
+ */
13
+ describe("Radio native grouping", () => {
14
+ function DensityGroup() {
15
+ return (
16
+ <fieldset>
17
+ {["compact", "comfortable", "spacious"].map((value) => (
18
+ <Radio.Root key={value} name="density" value={value} aria-label={value} />
19
+ ))}
20
+ </fieldset>
21
+ );
22
+ }
23
+
24
+ it("enforces single-selection across siblings sharing a name", async () => {
25
+ // Arrange
26
+ const user = userEvent.setup();
27
+ render(<DensityGroup />);
28
+ const compact = screen.getByRole("radio", { name: "compact" });
29
+ const comfortable = screen.getByRole("radio", { name: "comfortable" });
30
+ const spacious = screen.getByRole("radio", { name: "spacious" });
31
+
32
+ // Act: select the first
33
+ await user.click(compact);
34
+
35
+ // Assert: only it is checked
36
+ expect(compact).toBeChecked();
37
+ expect(comfortable).not.toBeChecked();
38
+ expect(spacious).not.toBeChecked();
39
+
40
+ // Act: select a sibling — the browser deselects the first
41
+ await user.click(comfortable);
42
+
43
+ // Assert: selection moved; no two radios are checked at once
44
+ expect(compact).not.toBeChecked();
45
+ expect(comfortable).toBeChecked();
46
+ expect(spacious).not.toBeChecked();
47
+ });
48
+
49
+ it("submits the selected radio's value with an enclosing form", async () => {
50
+ // Arrange
51
+ const user = userEvent.setup();
52
+ const submitted: string[] = [];
53
+ function Form() {
54
+ return (
55
+ <form
56
+ onSubmit={(event) => {
57
+ event.preventDefault();
58
+ const data = new FormData(event.currentTarget);
59
+ submitted.push(String(data.get("density")));
60
+ }}
61
+ >
62
+ <Radio.Root name="density" value="compact" aria-label="compact" />
63
+ <Radio.Root name="density" value="spacious" aria-label="spacious" />
64
+ <button type="submit">Save</button>
65
+ </form>
66
+ );
67
+ }
68
+ render(<Form />);
69
+
70
+ // Act
71
+ await user.click(screen.getByRole("radio", { name: "spacious" }));
72
+ await user.click(screen.getByRole("button", { name: "Save" }));
73
+
74
+ // Assert
75
+ expect(submitted).toEqual(["spacious"]);
76
+ });
77
+ });
@@ -0,0 +1,76 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { Radio } from "../Radio";
5
+
6
+ describe("Radio.Indicator", () => {
7
+ it("is always mounted, regardless of checked state", () => {
8
+ // Arrange & Act — unchecked, yet the dot is in the DOM (CSS hides it).
9
+ render(
10
+ <Radio.Root aria-label="Compact">
11
+ <Radio.Indicator data-testid="dot" />
12
+ </Radio.Root>,
13
+ );
14
+
15
+ // Assert
16
+ expect(screen.getByTestId("dot")).toBeInTheDocument();
17
+ });
18
+
19
+ it("is decorative, carrying aria-hidden", () => {
20
+ // Arrange & Act
21
+ render(
22
+ <Radio.Root aria-label="Compact">
23
+ <Radio.Indicator data-testid="dot" />
24
+ </Radio.Root>,
25
+ );
26
+
27
+ // Assert
28
+ expect(screen.getByTestId("dot")).toHaveAttribute("aria-hidden", "true");
29
+ });
30
+
31
+ it("mirrors the radio's data-state for CSS to key off", async () => {
32
+ // Arrange
33
+ const user = userEvent.setup();
34
+ render(
35
+ <Radio.Root aria-label="Compact">
36
+ <Radio.Indicator data-testid="dot" />
37
+ </Radio.Root>,
38
+ );
39
+ const dot = screen.getByTestId("dot");
40
+ expect(dot).toHaveAttribute("data-state", "unchecked");
41
+
42
+ // Act
43
+ await user.click(screen.getByRole("radio", { name: "Compact" }));
44
+
45
+ // Assert
46
+ expect(dot).toHaveAttribute("data-state", "checked");
47
+ });
48
+
49
+ it("delegates to the consumer's element (e.g. an svg dot) via asChild", () => {
50
+ // Arrange & Act
51
+ render(
52
+ <Radio.Root defaultChecked aria-label="Compact">
53
+ <Radio.Indicator asChild>
54
+ <svg data-testid="dot" viewBox="0 0 10 10">
55
+ <circle cx="5" cy="5" r="3" />
56
+ </svg>
57
+ </Radio.Indicator>
58
+ </Radio.Root>,
59
+ );
60
+ const dot = screen.getByTestId("dot");
61
+
62
+ // Assert
63
+ expect(dot.tagName.toLowerCase()).toBe("svg");
64
+ expect(dot).toHaveAttribute("data-state", "checked");
65
+ });
66
+
67
+ it("throws when rendered outside Radio.Root", () => {
68
+ // Arrange
69
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
70
+
71
+ // Assert
72
+ expect(() => render(<Radio.Indicator />)).toThrow();
73
+
74
+ error.mockRestore();
75
+ });
76
+ });
@@ -7,14 +7,12 @@ describe("Radio uncontrolled state", () => {
7
7
  it("starts checked when defaultChecked is true", () => {
8
8
  // Arrange & Act
9
9
  render(<Radio.Root defaultChecked aria-label="Compact" />);
10
- const radio = screen.getByRole("radio", { name: "Compact" });
11
10
 
12
11
  // Assert
13
- expect(radio).toHaveAttribute("aria-checked", "true");
14
- expect(radio).toHaveAttribute("data-state", "checked");
12
+ expect(screen.getByRole("radio", { name: "Compact" })).toBeChecked();
15
13
  });
16
14
 
17
- it("selects on click from unchecked to checked", async () => {
15
+ it("checks on click", async () => {
18
16
  // Arrange
19
17
  const user = userEvent.setup();
20
18
  render(<Radio.Root aria-label="Compact" />);
@@ -24,57 +22,47 @@ describe("Radio uncontrolled state", () => {
24
22
  await user.click(radio);
25
23
 
26
24
  // Assert
27
- expect(radio).toHaveAttribute("aria-checked", "true");
28
- expect(radio).toHaveAttribute("data-state", "checked");
25
+ expect(radio).toBeChecked();
29
26
  });
30
27
 
31
- it("is a no-op when an already-selected radio is clicked (no toggle-off)", async () => {
28
+ it("calls onCheckedChange with true when it becomes selected", async () => {
32
29
  // Arrange
33
30
  const user = userEvent.setup();
34
31
  const onCheckedChange = vi.fn();
35
- render(
36
- <Radio.Root
37
- defaultChecked
38
- onCheckedChange={onCheckedChange}
39
- aria-label="Compact"
40
- />,
41
- );
42
- const radio = screen.getByRole("radio", { name: "Compact" });
32
+ render(<Radio.Root onCheckedChange={onCheckedChange} aria-label="Compact" />);
43
33
 
44
34
  // Act
45
- await user.click(radio);
35
+ await user.click(screen.getByRole("radio", { name: "Compact" }));
46
36
 
47
- // Assert: stays checked and the callback never fires for a re-select.
48
- expect(radio).toHaveAttribute("aria-checked", "true");
49
- expect(radio).toHaveAttribute("data-state", "checked");
50
- expect(onCheckedChange).not.toHaveBeenCalled();
37
+ // Assert
38
+ expect(onCheckedChange).toHaveBeenCalledTimes(1);
39
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
51
40
  });
52
41
 
53
- it("calls onCheckedChange with true the first time it is selected", async () => {
42
+ it("updates the data-state mirror on the wrapper when selected", async () => {
54
43
  // Arrange
55
44
  const user = userEvent.setup();
56
- const onCheckedChange = vi.fn();
57
- render(<Radio.Root onCheckedChange={onCheckedChange} aria-label="Compact" />);
58
- const radio = screen.getByRole("radio", { name: "Compact" });
45
+ const { container } = render(<Radio.Root aria-label="Compact" />);
59
46
 
60
47
  // Act
61
- await user.click(radio);
62
- await user.click(radio);
48
+ await user.click(screen.getByRole("radio", { name: "Compact" }));
63
49
 
64
- // Assert: selection is one-way, so the second click is swallowed.
65
- expect(onCheckedChange).toHaveBeenCalledTimes(1);
66
- expect(onCheckedChange).toHaveBeenCalledWith(true);
50
+ // Assert
51
+ expect(container.querySelector("label")).toHaveAttribute(
52
+ "data-state",
53
+ "checked",
54
+ );
67
55
  });
68
56
 
69
- it("composes the consumer's onClick with the internal select (consumer runs first)", async () => {
57
+ it("composes a consumer onChange, running it before the internal handler", async () => {
70
58
  // Arrange
71
59
  const user = userEvent.setup();
72
60
  const order: string[] = [];
73
- const onClick = vi.fn(() => order.push("consumer"));
61
+ const onChange = vi.fn(() => order.push("consumer"));
74
62
  const onCheckedChange = vi.fn(() => order.push("internal"));
75
63
  render(
76
64
  <Radio.Root
77
- onClick={onClick}
65
+ onChange={onChange}
78
66
  onCheckedChange={onCheckedChange}
79
67
  aria-label="Compact"
80
68
  />,
@@ -86,4 +74,23 @@ describe("Radio uncontrolled state", () => {
86
74
  // Assert
87
75
  expect(order).toEqual(["consumer", "internal"]);
88
76
  });
77
+
78
+ it("lets a consumer onChange veto the internal handler via preventDefault", async () => {
79
+ // Arrange
80
+ const user = userEvent.setup();
81
+ const onCheckedChange = vi.fn();
82
+ render(
83
+ <Radio.Root
84
+ onChange={(event) => event.preventDefault()}
85
+ onCheckedChange={onCheckedChange}
86
+ aria-label="Compact"
87
+ />,
88
+ );
89
+
90
+ // Act
91
+ await user.click(screen.getByRole("radio", { name: "Compact" }));
92
+
93
+ // Assert
94
+ expect(onCheckedChange).not.toHaveBeenCalled();
95
+ });
89
96
  });
@@ -1,32 +1,48 @@
1
- import { useCallback } from "react";
1
+ import { useState } from "react";
2
+ import type { ChangeEvent } from "react";
2
3
 
3
- import { useControllableState } from "../../hooks/index.ts";
4
+ import type { RadioChangeHandler } from "../types";
4
5
 
5
6
  type UseRadioRootArgs = {
6
7
  defaultChecked?: boolean;
7
8
  checked?: boolean;
8
9
  onCheckedChange?: (checked: boolean) => void;
10
+ onChange?: RadioChangeHandler;
9
11
  };
10
12
 
13
+ /**
14
+ * Resolves the controlled / uncontrolled state for a {@link Radio.Root} and
15
+ * produces the props to spread onto its native `<input type="radio">`.
16
+ *
17
+ * The key correctness move: in **uncontrolled** mode the input is rendered
18
+ * with `defaultChecked` and *no* `checked` prop, so the browser owns the
19
+ * value and native `name`-grouping (including the silent deselection of
20
+ * siblings) works. The returned `checked` is only a best-effort mirror used
21
+ * for the `data-state` styling hook — it is updated from the input's own
22
+ * `change` events, and may lag behind a sibling-driven deselect, which the
23
+ * CSS `:checked` selector handles instead.
24
+ */
11
25
  export function useRadioRoot({
12
26
  defaultChecked,
13
27
  checked: controlledChecked,
14
28
  onCheckedChange,
29
+ onChange,
15
30
  }: UseRadioRootArgs) {
16
- const [checked, setChecked] = useControllableState<boolean>(
17
- controlledChecked,
18
- defaultChecked ?? false,
19
- );
31
+ const isControlled = controlledChecked !== undefined;
32
+ const [mirror, setMirror] = useState(defaultChecked ?? false);
33
+ const checked = isControlled ? controlledChecked : mirror;
20
34
 
21
- const select = useCallback(() => {
22
- // A radio only ever moves *into* the selected state — re-clicking the
23
- // already-selected radio is a no-op (no toggle-off), per the WAI-ARIA
24
- // radio convention. De-selection happens when a sibling is chosen, which
25
- // is the grouping consumer's concern, not this lone control's.
26
- if (checked) return;
27
- setChecked(true);
28
- onCheckedChange?.(true);
29
- }, [checked, setChecked, onCheckedChange]);
35
+ const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
36
+ onChange?.(event);
37
+ if (event.defaultPrevented) return;
38
+ const next = event.target.checked;
39
+ if (!isControlled) setMirror(next);
40
+ onCheckedChange?.(next);
41
+ };
30
42
 
31
- return { checked, select };
43
+ const inputStateProps = isControlled
44
+ ? { checked: controlledChecked }
45
+ : { defaultChecked: defaultChecked ?? false };
46
+
47
+ return { checked, handleChange, inputStateProps };
32
48
  }
@@ -1,44 +1,55 @@
1
- import { ComponentProps, ReactNode, Ref } from "react";
1
+ import { ChangeEventHandler, ComponentProps, ReactNode, Ref } from "react";
2
2
 
3
3
  /**
4
- * Props for {@link Radio.Indicator} — all `<span>` attributes plus
5
- * `forceMount` (keep mounted while unchecked for exit animations) and the
6
- * `asChild` escape hatch.
4
+ * Props for {@link Radio.Indicator} — the decorative dot. All `<span>`
5
+ * attributes plus the `asChild` escape hatch. The indicator is always mounted;
6
+ * its visibility is a CSS concern, driven off the input's native `:checked`
7
+ * state, so it stays correct even when the browser silently deselects a
8
+ * grouped sibling (which fires no React event).
7
9
  */
8
10
  export type RadioIndicatorProps = ComponentProps<"span"> & {
9
11
  children?: ReactNode;
10
- forceMount?: boolean;
11
12
  asChild?: boolean;
12
13
  };
13
14
 
14
15
  /**
15
16
  * Shared base for both {@link RadioRootProps} variants — the native
16
- * `<button>` attributes (minus the ones the component owns) plus the
17
- * `asChild` escape hatch and a typed `ref`.
17
+ * `<input type="radio">` attributes (minus the ones the component owns) plus
18
+ * the `onCheckedChange` convenience callback. `className` / `style` here style
19
+ * the **wrapper** (the visible control), not the hidden input; everything
20
+ * else spreads onto the input, because semantically the Root *is* the radio.
18
21
  */
19
22
  export type RadioRootBaseProps = Omit<
20
- ComponentProps<"button">,
21
- "type" | "role" | "aria-checked" | "defaultChecked"
23
+ ComponentProps<"input">,
24
+ "type" | "checked" | "defaultChecked"
22
25
  > & {
23
- asChild?: boolean;
24
- ref?: Ref<HTMLButtonElement>;
26
+ /**
27
+ * Fired with the new checked value whenever this radio becomes selected.
28
+ * A native radio only ever fires `change` when it moves *into* the checked
29
+ * state, so this is always called with `true`.
30
+ */
31
+ onCheckedChange?: (checked: boolean) => void;
32
+ children?: ReactNode;
33
+ /** Forwarded to the underlying native `<input type="radio">`. */
34
+ ref?: Ref<HTMLInputElement>;
25
35
  };
26
36
 
27
37
  /**
28
- * Uncontrolled variant of {@link RadioRootProps}: the component owns the
29
- * checked value. Pass `defaultChecked` (or omit it); `onCheckedChange` is
30
- * optional and `checked` is forbidden.
38
+ * Uncontrolled variant of {@link RadioRootProps}: the **browser** owns the
39
+ * checked value (the input is rendered with `defaultChecked` and no `checked`
40
+ * prop), so native `name`-grouping — including the silent deselection of
41
+ * siblings — works for free. Pass `defaultChecked` (or omit it); `checked` is
42
+ * forbidden.
31
43
  */
32
44
  export type RadioRootUncontrolledProps = RadioRootBaseProps & {
33
45
  defaultChecked?: boolean;
34
46
  checked?: never;
35
- onCheckedChange?: (checked: boolean) => void;
36
47
  };
37
48
 
38
49
  /**
39
50
  * Controlled variant of {@link RadioRootProps}: the parent owns the checked
40
51
  * value. Pass `checked` and `onCheckedChange` together; `defaultChecked` is
41
- * forbidden.
52
+ * forbidden. The consumer owns grouping in this mode.
42
53
  */
43
54
  export type RadioRootControlledProps = RadioRootBaseProps & {
44
55
  defaultChecked?: never;
@@ -54,3 +65,6 @@ export type RadioRootControlledProps = RadioRootBaseProps & {
54
65
  export type RadioRootProps =
55
66
  | RadioRootUncontrolledProps
56
67
  | RadioRootControlledProps;
68
+
69
+ /** Internal: the native `onChange` shape the Root composes with its own. */
70
+ export type RadioChangeHandler = ChangeEventHandler<HTMLInputElement>;
@@ -1,14 +1,14 @@
1
1
  # Switch
2
2
 
3
- Headless, accessible **Switch** — a compound component implementing the
4
- [WAI-ARIA Switch pattern](https://www.w3.org/WAI/ARIA/apg/patterns/switch/)
5
- on a native `<button role="switch">`. Semantically represents an immediate
6
- on/off action (as opposed to a selection choice). Zero styles ship.
3
+ Headless, accessible **Switch** — a compound component built on a **real,
4
+ visually-hidden native `<input type="checkbox" role="switch">`**. Semantically
5
+ represents an immediate on/off action (as opposed to a selection choice), and —
6
+ being a genuine native input — participates in forms. Zero styles ship.
7
7
 
8
8
  ```tsx
9
9
  import { Switch } from "@primitiv-ui/react";
10
10
 
11
- <Switch.Root defaultChecked aria-label="Enable notifications">
11
+ <Switch.Root name="notify" value="on" defaultChecked aria-label="Enable notifications">
12
12
  <Switch.Thumb />
13
13
  </Switch.Root>
14
14
  ```
@@ -17,18 +17,23 @@ import { Switch } from "@primitiv-ui/react";
17
17
 
18
18
  | Export | Element | ARIA / data hooks | `asChild` |
19
19
  |--------|---------|------------------|-----------|
20
- | `Switch.Root` | `<button>` | `role="switch"`, `aria-checked`, `data-state`, `data-disabled` | yes |
20
+ | `Switch.Root` | `<label>` + hidden `<input>` | input is `role="switch"`; `data-state`, `data-disabled` on the label | — |
21
21
  | `Switch.Thumb` | `<span>` | `aria-hidden="true"`, `data-state` | yes |
22
22
 
23
+ `Switch.Root`'s `className` / `style` style the **track** (the `<label>` you
24
+ see). Every other prop spreads onto the hidden `<input>`, because semantically
25
+ the Root *is* the switch: pass `name`, `value`, `id`, `required`, `aria-*`,
26
+ `ref`, etc. straight to `Switch.Root`.
27
+
23
28
  ## State modes
24
29
 
25
30
  ### Uncontrolled
26
31
 
27
- Pass `defaultChecked` (or omit for unchecked on mount). The component owns
28
- the value internally.
32
+ Pass `defaultChecked` (or omit for off on mount). The **browser** owns the
33
+ value, so the switch participates in forms and resets.
29
34
 
30
35
  ```tsx
31
- <Switch.Root defaultChecked aria-label="Enable dark mode">
36
+ <Switch.Root name="dark" value="on" defaultChecked aria-label="Enable dark mode">
32
37
  <Switch.Thumb />
33
38
  </Switch.Root>
34
39
  ```
@@ -45,18 +50,24 @@ const [enabled, setEnabled] = useState(false);
45
50
  </Switch.Root>
46
51
  ```
47
52
 
53
+ > **`data-state` is a best-effort mirror.** Drive the *visual* on/off look off
54
+ > the input's native `:checked` (and `:has(> input:checked)` on the track), as
55
+ > the shipped registry stylesheet does — it stays correct through a form reset,
56
+ > which fires no React event.
57
+
48
58
  ## Keyboard interaction
49
59
 
50
60
  | Key | Behaviour |
51
61
  |-----|-----------|
52
- | `Space` | Toggle the switch (native `<button>` behaviour) |
53
- | `Enter` | Toggle the switch (native `<button>` behaviour) |
62
+ | `Space` | Toggle the switch (native checkbox behaviour) |
54
63
  | `Tab` | Move focus to or from the switch |
55
64
 
65
+ `Enter` does **not** toggle — a checkbox-based control responds to `Space` only.
66
+
56
67
  ## Disabled
57
68
 
58
69
  Pass `disabled` on the Root. The native attribute suppresses clicks and removes
59
- the switch from the focus ring. `data-disabled=""` is set for CSS targeting.
70
+ the switch from the focus ring. `data-disabled=""` is set on the track for CSS.
60
71
 
61
72
  ```tsx
62
73
  <Switch.Root aria-label="Enable feature" disabled>
@@ -67,15 +78,7 @@ the switch from the focus ring. `data-disabled=""` is set for CSS targeting.
67
78
  ## Size
68
79
 
69
80
  The styled surface installed by `primitiv add switch` exposes a `size` prop.
70
- The default is `md`.
71
-
72
- | Value | Description |
73
- |-------|-------------|
74
- | `xs` | Extra small |
75
- | `sm` | Small |
76
- | `md` | Medium (default) |
77
- | `lg` | Large |
78
- | `xl` | Extra large |
81
+ The default is `md` (`xs` · `sm` · `md` · `lg` · `xl`).
79
82
 
80
83
  ```tsx
81
84
  <Switch size="sm" aria-label="Enable notifications" />
@@ -83,56 +86,41 @@ The default is `md`.
83
86
 
84
87
  ## The Thumb
85
88
 
86
- `Switch.Thumb` is **always mounted** — unlike `Checkbox.Indicator`, it never
87
- conditionally unmounts. Its visual position (left for off, right for on) is
88
- driven entirely by CSS targeting `data-state`. This gives consumers a real DOM
89
- node to animate with `transition` or Web Animations, rather than being
90
- constrained to pseudo-elements.
89
+ `Switch.Thumb` is **always mounted**. Its visual position (off → on) should be
90
+ driven by CSS keyed off the input's native `:checked` (a sibling selector), so
91
+ it stays correct through a form reset. It gives consumers a real DOM node to
92
+ animate with `transition` or Web Animations.
91
93
 
92
- ```scss
93
- .switch-root {
94
+ ```css
95
+ .switch {
94
96
  position: relative;
95
- width: 2.75rem;
96
- height: 1.5rem;
97
- border-radius: 9999px;
98
- background: #d1d5db;
99
- transition: background 120ms ease;
100
-
101
- &[data-state="checked"] {
102
- background: #6366f1;
103
- }
104
97
  }
105
-
106
- .switch-thumb {
107
- position: absolute;
108
- top: 0.125rem;
109
- left: 0.125rem;
110
- width: 1.25rem;
111
- height: 1.25rem;
112
- border-radius: 50%;
113
- background: white;
98
+ .switch:has(> input:checked) {
99
+ background: #6366f1;
100
+ }
101
+ .switch__thumb {
114
102
  transition: translate 120ms ease;
115
-
116
- &[data-state="checked"] {
117
- translate: 1.25rem 0;
118
- }
103
+ }
104
+ .switch > input:checked ~ .switch__thumb {
105
+ translate: 1.25rem 0;
119
106
  }
120
107
  ```
121
108
 
122
109
  ## `asChild` composition
123
110
 
124
- Both sub-components accept `asChild`. The library's ARIA attributes,
125
- `data-state`, event handlers, and ref are merged onto the consumer's element.
111
+ `Switch.Thumb` accepts `asChild`: the library's `aria-hidden`, `data-state`, and
112
+ ref are merged onto the consumer's element.
126
113
 
127
114
  ```tsx
128
- <Switch.Root asChild aria-label="Enable notifications">
129
- <div role="switch">…</div>
130
- </Switch.Root>
115
+ <Switch.Thumb asChild>
116
+ <span className="my-thumb" />
117
+ </Switch.Thumb>
131
118
  ```
132
119
 
133
120
  ## Styling hooks
134
121
 
135
122
  | Attribute | Values | Set on |
136
123
  |-----------|--------|--------|
137
- | `data-state` | `"checked"` \| `"unchecked"` | `Switch.Root`, `Switch.Thumb` |
138
- | `data-disabled` | `""` (present when disabled) | `Switch.Root` |
124
+ | `data-state` | `"checked"` \| `"unchecked"` | `Switch.Root` (track), `Switch.Thumb` |
125
+ | `data-disabled` | `""` (present when disabled) | `Switch.Root` (track) |
126
+ | `:checked` / `:focus-visible` / `:disabled` | native | the hidden `<input>` |