@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
package/README.md CHANGED
@@ -25,7 +25,7 @@ import { Tabs } from "@primitiv-ui/react";
25
25
  | [Breadcrumb](src/Breadcrumb/README.md) | WAI-ARIA Breadcrumb pattern — stateless compound component: a `<nav>` landmark wrapping an ordered list of ancestor-page links, decorative separators with a configurable glyph, an `aria-current` current page, and `asChild` composition on the link |
26
26
  | [Button](src/Button/README.md) | Headless semantic `<button>` with `type="button"` default, ref forwarding, `disabled` with `data-disabled` styling hook, and `asChild` composition |
27
27
  | [Carousel](src/Carousel/README.md) | WAI-ARIA Carousel pattern — compound component built on native CSS scroll-snap with swipe support, keyboard navigation, slide indicators, optional auto-rotation with WCAG 2.2.2 pause behaviour, and `asChild` composition |
28
- | [Checkbox](src/Checkbox/README.md) | WAI-ARIA Checkbox pattern — native `<button role="checkbox">` with tri-state (`"indeterminate"` / `aria-checked="mixed"`), controlled/uncontrolled state, `Indicator` slot, and `asChild` composition |
28
+ | [Checkbox](src/Checkbox/README.md) | A real, visually-hidden native `<input type="checkbox">` in a `<label>` box, so it participates in forms; the platform tri-state (`indeterminate` → `aria-checked="mixed"`, `:indeterminate`), controlled/uncontrolled state, `Indicator` mark, and `asChild` on the indicator |
29
29
  | [CheckboxCard](src/CheckboxCard/README.md) | WAI-ARIA Checkbox pattern in a card/tile layout — the entire card surface is the interactive element, with tri-state support, controlled/uncontrolled state, `Indicator` slot, `disabled` with `data-disabled` styling hook, and `asChild` composition |
30
30
  | [Collapsible](src/Collapsible/README.md) | Single-panel show/hide — boolean controlled/uncontrolled state, `disabled`, `asChild` composition with explicit Enter/Space handling, `forceMount` for CSS-Grid-driven animation, and `TriggerIcon` slot |
31
31
  | [ContextMenu](src/ContextMenu/README.md) | WAI-ARIA Menu pattern for right-click / long-press / context-key menus — native HTML `popover` compound component positioned at the cursor, with nested submenus, checkbox and radio items, `ItemIndicator` icon slot, groups and labels, keyboard navigation, typeahead, disabled-item skipping, `onSelect` escape hatch, and `asChild` composition |
@@ -41,14 +41,14 @@ import { Tabs } from "@primitiv-ui/react";
41
41
  | [Modal](src/Modal/README.md) | WAI-ARIA Modal Dialog pattern — native `<dialog>`-based compound component with Portal, bounding-rect click-outside, controlled/uncontrolled state, escape hatches, imperative API, `asChild`, and `forceMount` animation hooks |
42
42
  | [Portal](src/Portal/README.md) | DOM-escape utility — renders children into `document.body` (or a consumer-supplied container) via `createPortal`; compose with any component that needs to break out of the current stacking context |
43
43
  | [Progress](src/Progress/README.md) | WAI-ARIA progressbar pattern — display-only compound component reflecting a consumer-supplied `value`, with determinate/indeterminate states, configurable `max`, `getValueLabel` for `aria-valuetext`, `data-*` styling hooks on the fill `Indicator`, and `asChild` composition |
44
- | [Radio](src/Radio/README.md) | WAI-ARIA Radio pattern, standalone — native `<button role="radio">` with one-way single-selection state (no toggle-off), controlled/uncontrolled state, `Indicator` slot, `disabled` with `data-disabled` styling hook, and `asChild` composition; pair with your own grouping (use `RadioGroup` for managed roving-tabindex sets) |
44
+ | [Radio](src/Radio/README.md) | Standalone — a real, visually-hidden native `<input type="radio">` in a `<label>` box, so siblings sharing a `name` form a native radio group (the browser enforces single-selection) and it participates in forms; controlled/uncontrolled state, `Indicator` dot, `disabled` with `data-disabled` styling hook, and `asChild` on the indicator (use `RadioGroup` for managed roving-tabindex sets) |
45
45
  | [RadioCard](src/RadioCard/README.md) | WAI-ARIA Radio Group pattern in a card/tile layout — the entire card surface is the interactive element, with single-selection state, roving tabindex, arrow-key navigation, disabled-item skipping, `Indicator` slot, and `asChild` composition |
46
46
  | [RadioGroup](src/RadioGroup/README.md) | WAI-ARIA Radio Group pattern — native `<button role="radio">` items with single-selection state, roving tabindex, arrow-key navigation, disabled-item skipping, `Indicator` slot, and `asChild` composition |
47
47
  | [Select](src/Select/README.md) | Headless wrapper over native `<select>` / `<option>` / `<optgroup>` — controlled/uncontrolled state via `onValueChange`, `Select.Placeholder` with auto-`defaultValue` inference, `Select.Group` for optgroup labelling, `data-disabled` styling hook, `asChild` composition, and Field-aware (opts into `FieldContext` for auto-wired `id` / `aria-describedby` / `aria-invalid` / `disabled` / `required`) |
48
48
  | [SkipNav](src/SkipNav/README.md) | WCAG 2.4.1 Bypass Blocks technique — a stateless "skip to main content" link and its `tabIndex=-1` focus target, connected by a shared content id, working with no JavaScript at runtime |
49
49
  | [Slider](src/Slider/README.md) | WAI-ARIA Slider & Multi-Thumb Slider pattern — an array-valued range input with any number of independently focusable thumbs, keyboard and pointer dragging, `min`/`max`/`step`, `minStepsBetweenThumbs`, horizontal/vertical orientation, RTL and `inverted` direction, `onValueCommit`, hidden inputs for form submission, `disabled` with `data-disabled` styling hook, and `asChild` composition |
50
50
  | [Status](src/Status/README.md) | WAI-ARIA `status` role — a polite live region for low-priority, non-urgent status messages, announced once the user is idle, with `asChild` composition |
51
- | [Switch](src/Switch/README.md) | WAI-ARIA Switch pattern — `<button role="switch">` with binary on/off state, always-mounted `Thumb` sub-component for CSS-driven sliding animation, controlled/uncontrolled state, `data-disabled` styling hook, and `asChild` composition |
51
+ | [Switch](src/Switch/README.md) | A real, visually-hidden native `<input type="checkbox" role="switch">` in a `<label>` track, so it participates in forms; binary on/off state, always-mounted `Thumb` for CSS-driven sliding (off the input's native `:checked`), controlled/uncontrolled state, `data-disabled` styling hook, and `asChild` on the thumb |
52
52
  | [Table](src/Table/README.md) | WAI-ARIA Table pattern — compound component wrapping standard HTML table elements with accessible headers, captions, and a responsive scroll wrapper |
53
53
  | [Tabs](src/Tabs/README.md) | WAI-ARIA Tabs pattern — compound component with keyboard navigation, controlled/uncontrolled state, and `asChild` composition |
54
54
  | [Textarea](src/Textarea/README.md) | Headless multi-line text input — native `<textarea>` with full attribute pass-through, ref forwarding, `disabled` with `data-disabled` styling hook, `asChild` composition for wrapping autosizing implementations, and Field-aware (opts into `FieldContext` for auto-wired `id` / `aria-describedby` / `aria-invalid` / `disabled` / `required`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitiv-ui/react",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Headless, accessible React components built on the WAI-ARIA authoring patterns. Zero styles ship with this package — bring your own (CSS, Tailwind, CSS-in-JS, design tokens).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,10 +1,10 @@
1
- import { useMemo } from "react";
1
+ import { useEffect, useMemo, useRef } from "react";
2
2
  import type { ReactElement } from "react";
3
3
 
4
- import { Slot, composeEventHandlers } from "../Slot/index.ts";
4
+ import { Slot, composeRefs } from "../Slot/index.ts";
5
5
 
6
6
  import { CheckboxContext } from "./CheckboxContext";
7
- import { useCheckboxContext, useCheckboxRoot } from "./hooks/index.ts";
7
+ import { useCheckboxContext, useCheckboxInput } from "./hooks/index.ts";
8
8
  import {
9
9
  CheckboxIndicatorProps,
10
10
  CheckboxRootProps,
@@ -17,104 +17,105 @@ function dataStateOf(checked: CheckedState) {
17
17
  }
18
18
 
19
19
  /**
20
- * The root of a Checkbox — a native `<button role="checkbox">` that
21
- * owns the tri-state checked value and provides
22
- * {@link CheckboxContext | `CheckboxContext`} to descendant
23
- * {@link CheckboxIndicator | `Checkbox.Indicator`}s.
20
+ * The root of a Checkbox — a `<label>` that is itself the **visible, styled
21
+ * box** and wraps a **real, visually-hidden `<input type="checkbox">`** (the
22
+ * focusable, form-participating control) plus the decorative
23
+ * {@link CheckboxIndicator | `Checkbox.Indicator`} mark. It provides
24
+ * {@link CheckboxContext | `CheckboxContext`} to the indicator.
25
+ *
26
+ * Because the underlying element is a genuine native checkbox, it behaves like
27
+ * one: it submits its `value` under `name` with an enclosing form, resets with
28
+ * the form, and gets keyboard activation and focus for free. The tri-state
29
+ * `"indeterminate"` is the platform's own: it is applied via the input's
30
+ * `.indeterminate` DOM property, so the browser exposes `aria-checked="mixed"`
31
+ * and the `:indeterminate` pseudo-class for styling.
32
+ *
33
+ * Props routing: `className` / `style` style the **box** (the `<label>` you
34
+ * see — the input is hidden); every other prop (`name`, `value`, `id`,
35
+ * `aria-*`, `required`, `disabled`, `ref`, …) spreads onto the input, because
36
+ * semantically the Root *is* the checkbox.
24
37
  *
25
38
  * Supports two state modes, statically discriminated at the type level:
26
39
  *
27
40
  * - **Uncontrolled** — pass
28
- * {@link CheckboxRootProps.defaultChecked | `defaultChecked`} (or omit
29
- * for unchecked-on-mount). The component owns the value internally.
41
+ * {@link CheckboxRootProps.defaultChecked | `defaultChecked`} (or omit). It
42
+ * may be `"indeterminate"` for a mixed-on-mount checkbox.
30
43
  * - **Controlled** — pass
31
44
  * {@link CheckboxRootProps.checked | `checked`} *and*
32
- * {@link CheckboxRootProps.onCheckedChange | `onCheckedChange`}
33
- * together. The parent owns the value; the component defers every
34
- * change back through the callback.
35
- *
36
- * Both `checked` and `defaultChecked` accept `boolean | "indeterminate"`.
37
- * Clicking an indeterminate checkbox resolves it to `true` per the
38
- * WAI-ARIA tri-state convention, then flips boolean on subsequent clicks.
39
- *
40
- * **ARIA.** `role="checkbox"` and `aria-checked` are set automatically;
41
- * `aria-checked="mixed"` represents the indeterminate state.
45
+ * {@link CheckboxRootProps.onCheckedChange | `onCheckedChange`} together.
46
+ * Clicking a mixed checkbox resolves it to `true`.
42
47
  *
43
48
  * **Styling hooks.** `data-state="checked" | "unchecked" | "indeterminate"`
44
- * on the root, plus `data-disabled=""` when disabled.
45
- *
46
- * **`asChild` prop.** Pass `asChild` to render any consumer-supplied
47
- * element (e.g. `<li role="menuitemcheckbox">` for menu composition)
48
- * with the checkbox's ARIA attributes, data-state, composed onClick, and
49
- * ref merged in. The native `<button>` is dropped; consumers who want
50
- * keyboard activation on a non-button element are responsible for
51
- * providing it.
49
+ * and `data-disabled=""` on the box (and `data-state` on the indicator). These
50
+ * mirror the input, but shipped CSS keys the mark off the input's native
51
+ * `:checked` / `:indeterminate` instead; `data-state` is a convenience mirror.
52
52
  *
53
53
  * @example Uncontrolled
54
54
  * ```tsx
55
- * <Checkbox.Root defaultChecked aria-label="Accept terms">
56
- * <Checkbox.Indicator>
57
- * <CheckIcon />
58
- * </Checkbox.Indicator>
55
+ * <Checkbox.Root name="terms" value="accepted" defaultChecked aria-label="Accept terms">
56
+ * <Checkbox.Indicator />
59
57
  * </Checkbox.Root>
60
58
  * ```
61
59
  *
62
- * @example Controlled
60
+ * @example Controlled tri-state
63
61
  * ```tsx
64
- * const [checked, setChecked] = useState<CheckedState>(false);
62
+ * const [checked, setChecked] = useState<CheckedState>("indeterminate");
65
63
  *
66
64
  * <Checkbox.Root checked={checked} onCheckedChange={setChecked} aria-label="…">
67
- * <Checkbox.Indicator>
68
- * <CheckIcon />
69
- * </Checkbox.Indicator>
70
- * </Checkbox.Root>
71
- * ```
72
- *
73
- * @example Composed into a menu item via `asChild`
74
- * ```tsx
75
- * <Checkbox.Root asChild aria-label="Show hidden files">
76
- * <li role="menuitemcheckbox">Show hidden files</li>
65
+ * <Checkbox.Indicator />
77
66
  * </Checkbox.Root>
78
67
  * ```
79
68
  */
80
69
  export function CheckboxRoot(props: CheckboxRootProps): ReactElement {
81
70
  const {
82
- defaultChecked,
83
- checked,
84
- onCheckedChange,
85
- onClick,
86
- disabled,
87
- asChild = false,
71
+ className,
72
+ style,
88
73
  children,
89
- ...rest
90
- } = props;
91
- const { checked: isChecked, toggle } = useCheckboxRoot({
92
74
  defaultChecked,
93
75
  checked,
94
76
  onCheckedChange,
95
- });
96
- const contextValue = useMemo(() => ({ checked: isChecked }), [isChecked]);
97
- const rootProps = {
98
- ...rest,
99
- role: "checkbox" as const,
100
- "aria-checked":
101
- isChecked === "indeterminate"
102
- ? ("mixed" as const)
103
- : (isChecked as boolean),
104
- "data-state": dataStateOf(isChecked),
105
- "data-disabled": disabled ? "" : undefined,
77
+ onChange,
106
78
  disabled,
107
- onClick: composeEventHandlers(onClick, toggle),
108
- };
79
+ ref,
80
+ ...inputRest
81
+ } = props;
82
+ const {
83
+ checked: value,
84
+ handleChange,
85
+ inputStateProps,
86
+ } = useCheckboxInput({ defaultChecked, checked, onCheckedChange, onChange });
87
+
88
+ // `indeterminate` is a DOM property with no JSX attribute, so it is applied
89
+ // imperatively whenever the resolved tri-state value changes. The input is
90
+ // rendered unconditionally, so the ref is always set here.
91
+ const inputRef = useRef<HTMLInputElement>(null);
92
+ useEffect(() => {
93
+ inputRef.current!.indeterminate = value === "indeterminate";
94
+ }, [value]);
95
+ const composedRef = ref ? composeRefs(inputRef, ref) : inputRef;
96
+
97
+ const contextValue = useMemo(
98
+ () => ({ checked: value, disabled: Boolean(disabled) }),
99
+ [value, disabled],
100
+ );
109
101
  return (
110
102
  <CheckboxContext.Provider value={contextValue}>
111
- {asChild ? (
112
- <Slot {...rootProps}>{children}</Slot>
113
- ) : (
114
- <button type="button" {...rootProps}>
115
- {children}
116
- </button>
117
- )}
103
+ <label
104
+ className={className}
105
+ style={style}
106
+ data-state={dataStateOf(value)}
107
+ data-disabled={disabled ? "" : undefined}
108
+ >
109
+ <input
110
+ type="checkbox"
111
+ ref={composedRef}
112
+ disabled={disabled}
113
+ {...inputRest}
114
+ {...inputStateProps}
115
+ onChange={handleChange}
116
+ />
117
+ {children}
118
+ </label>
118
119
  </CheckboxContext.Provider>
119
120
  );
120
121
  }
@@ -123,32 +124,19 @@ export function CheckboxRoot(props: CheckboxRootProps): ReactElement {
123
124
  CheckboxRoot.displayName = "CheckboxRoot";
124
125
 
125
126
  /**
126
- * A decorative `<span aria-hidden="true">` that renders its children
127
- * only while the parent {@link CheckboxRoot | `Checkbox.Root`} is
128
- * **checked** or **indeterminate** — never when unchecked. The
129
- * checkbox's accessible state is already conveyed by `aria-checked`
130
- * on the root, so the indicator is purely visual.
127
+ * The decorative mark — a `<span>` that is **always mounted**, sitting inside
128
+ * the {@link CheckboxRoot | `Checkbox.Root`} box. Its visibility and shape (a
129
+ * tick when checked, a bar when indeterminate) are a pure CSS concern, revealed
130
+ * off the input's native `:checked` / `:indeterminate` state. The checkbox's
131
+ * accessible state is conveyed by the native input, so the mark is purely
132
+ * visual.
131
133
  *
132
134
  * **Styling hook.** Mirrors the root's
133
- * `data-state="checked" | "unchecked" | "indeterminate"` so the same
134
- * CSS rules can target both.
135
- *
136
- * **`asChild` prop.** Pass `asChild` to render the consumer's own
137
- * element (typically an `<svg>` tick icon) as the indicator itself,
138
- * with `aria-hidden` and `data-state` merged onto that element rather
139
- * than a wrapper.
140
- *
141
- * **`forceMount` prop.** Pass `forceMount` to keep the indicator in
142
- * the DOM while unchecked so a CSS exit animation can play against
143
- * `data-state="unchecked"`. Consumers who use `forceMount` own the
144
- * exit lifecycle themselves.
135
+ * `data-state="checked" | "unchecked" | "indeterminate"`.
145
136
  *
146
- * @example Default span wrapper
147
- * ```tsx
148
- * <Checkbox.Indicator>
149
- * <CheckIcon />
150
- * </Checkbox.Indicator>
151
- * ```
137
+ * **`asChild` prop.** Pass `asChild` to render the consumer's own element
138
+ * (typically an `<svg>` tick) as the indicator itself, with `data-state`
139
+ * merged onto that element rather than a wrapper.
152
140
  *
153
141
  * @example Icon as the indicator via `asChild`
154
142
  * ```tsx
@@ -157,24 +145,14 @@ CheckboxRoot.displayName = "CheckboxRoot";
157
145
  * </Checkbox.Indicator>
158
146
  * ```
159
147
  *
160
- * @example Force-mounted for exit animation
161
- * ```tsx
162
- * <Checkbox.Indicator forceMount>
163
- * <CheckIcon />
164
- * </Checkbox.Indicator>
165
- * ```
166
- *
167
148
  * @throws if rendered outside a `Checkbox.Root`.
168
149
  */
169
150
  export function CheckboxIndicator({
170
151
  children,
171
- forceMount,
172
152
  asChild = false,
173
153
  ...rest
174
- }: CheckboxIndicatorProps): ReactElement | null {
154
+ }: CheckboxIndicatorProps): ReactElement {
175
155
  const { checked } = useCheckboxContext();
176
- const isVisible = checked !== false;
177
- if (!isVisible && !forceMount) return null;
178
156
  const indicatorProps = {
179
157
  ...rest,
180
158
  "aria-hidden": "true" as const,
@@ -196,31 +174,29 @@ export type TCheckboxCompound = typeof CheckboxRoot & {
196
174
  };
197
175
 
198
176
  /**
199
- * Headless, accessible **Checkbox** — a compound component built on a
200
- * native `<button role="checkbox">` that implements the
201
- * [WAI-ARIA Checkbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/)
202
- * including the tri-state ("mixed") variant. Zero styles ship.
177
+ * Headless, accessible **Checkbox** — a compound component built on a **real,
178
+ * visually-hidden native `<input type="checkbox">`**, including the platform's
179
+ * own tri-state (`indeterminate` → `aria-checked="mixed"`). Unlike a button
180
+ * dressed up with `role="checkbox"`, it participates in native forms. Zero
181
+ * styles ship.
203
182
  *
204
- * `Checkbox` is both callable (an alias of {@link CheckboxRoot | `Checkbox.Root`})
205
- * and carries its sub-components as static properties. Prefer the
206
- * namespaced form in application code for readability and grep-ability.
183
+ * `Checkbox` is both callable (an alias of
184
+ * {@link CheckboxRoot | `Checkbox.Root`}) and carries its sub-components as
185
+ * static properties.
207
186
  *
208
- * - {@link CheckboxRoot | `Checkbox.Root`} — state owner, context provider, toggle button.
209
- * - {@link CheckboxIndicator | `Checkbox.Indicator`} — decorative tick, conditional on checked state.
187
+ * - {@link CheckboxRoot | `Checkbox.Root`} — the styled box: label + hidden input, state owner, context provider.
188
+ * - {@link CheckboxIndicator | `Checkbox.Indicator`} — the decorative mark.
210
189
  *
211
190
  * @example Minimal usage
212
191
  * ```tsx
213
192
  * import { Checkbox } from "@primitiv-ui/react";
214
193
  *
215
- * <Checkbox.Root aria-label="Accept terms">
216
- * <Checkbox.Indicator>
217
- * <CheckIcon />
218
- * </Checkbox.Indicator>
194
+ * <Checkbox.Root name="terms" value="accepted" aria-label="Accept terms">
195
+ * <Checkbox.Indicator />
219
196
  * </Checkbox.Root>;
220
197
  * ```
221
198
  *
222
199
  * @see {@link CheckboxRoot} for state modes and tri-state semantics.
223
- * @see {@link CheckboxIndicator} for the mount gate and animation hooks.
224
200
  */
225
201
  const CheckboxCompound: TCheckboxCompound = Object.assign(CheckboxRoot, {
226
202
  Root: CheckboxRoot,
@@ -3,7 +3,15 @@ import { createStrictContext } from "../utils/index.ts";
3
3
  import { CheckedState } from "./types";
4
4
 
5
5
  export type CheckboxContextValue = {
6
+ /**
7
+ * Best-effort mirror of the input's tri-state value, for the `data-state`
8
+ * hook on the indicator. The shipped CSS reveals the mark off the input's
9
+ * native `:checked` / `:indeterminate` instead, so it stays correct through
10
+ * a form reset; `data-state` is a convenience mirror.
11
+ */
6
12
  checked: CheckedState;
13
+ /** Whether the checkbox is disabled, for the `data-disabled` hook. */
14
+ disabled: boolean;
7
15
  };
8
16
 
9
17
  export const [CheckboxContext, useCheckboxContext] =
@@ -1,107 +1,111 @@
1
1
  # Checkbox
2
2
 
3
- A headless, accessible compound component implementing the
4
- [WAI-ARIA Checkbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/),
5
- including the tri-state ("mixed") variant.
6
-
7
- Checkbox renders a native `<button role="checkbox">` so it gets
8
- keyboard activation (`Space` / `Enter`), focus ring, and disabled
9
- semantics for free from the browser. The React layer adds three-state
10
- support, `Indicator` mounting driven by the checked state, and the
11
- `asChild` composition every primitive in this package supports.
3
+ A headless, accessible Checkbox built on a **real native
4
+ `<input type="checkbox">`**, including the platform's own tri-state
5
+ ("mixed") variant.
6
+
7
+ Checkbox renders a `<label>` that is the visible box, wrapping a
8
+ visually-hidden native `<input type="checkbox">` (the focusable,
9
+ form-participating control) and a decorative `Indicator` mark. Because the
10
+ underlying element is a genuine native checkbox, it submits its `value`
11
+ under `name` with an enclosing form, resets with the form, and gets keyboard
12
+ activation and focus for free. The `"indeterminate"` state is the
13
+ platform's: it is applied via the input's `.indeterminate` DOM property, so
14
+ the browser exposes `aria-checked="mixed"` and the `:indeterminate`
15
+ pseudo-class for styling.
12
16
 
13
17
  ```tsx
14
18
  import { Checkbox } from "@primitiv-ui/react";
15
19
 
16
- <Checkbox.Root defaultChecked aria-label="Accept terms">
17
- <Checkbox.Indicator>
18
- <CheckIcon />
19
- </Checkbox.Indicator>
20
+ <Checkbox.Root name="terms" value="accepted" defaultChecked aria-label="Accept terms">
21
+ <Checkbox.Indicator />
20
22
  </Checkbox.Root>;
21
23
  ```
22
24
 
23
25
  ## Sub-components
24
26
 
25
- | Export | Element | Notes |
26
- | -------------------- | ---------- | ------------------------------------------------------------------------------------------ |
27
- | `Checkbox.Root` | `<button>` | `role="checkbox"`, `aria-checked`, `data-state`, `data-disabled`. `asChild` |
28
- | `Checkbox.Indicator` | `<span>` | `aria-hidden="true"`. Renders only while checked or indeterminate. `asChild`, `forceMount` |
27
+ | Export | Element | Notes |
28
+ | -------------------- | -------------------------------- | ------------------------------------------------------------------------------------------- |
29
+ | `Checkbox.Root` | `<label>` + hidden `<input>` | The styled box. `data-state`, `data-disabled` on the label; `name`/`value`/`aria-*`/`ref` go to the input. |
30
+ | `Checkbox.Indicator` | `<span>` | `aria-hidden="true"`. Always mounted; CSS reveals it. `asChild` |
31
+
32
+ `Checkbox.Root`'s `className` / `style` style the **box** (the `<label>` you
33
+ see). Every other prop spreads onto the hidden `<input>`, because semantically
34
+ the Root *is* the checkbox: pass `name`, `value`, `id`, `required`, `aria-*`,
35
+ `ref`, etc. straight to `Checkbox.Root`.
29
36
 
30
37
  ## Checked state
31
38
 
32
39
  Checkbox is tri-state. The `checked` / `defaultChecked` value is
33
40
  `boolean | "indeterminate"`:
34
41
 
35
- | Value | `aria-checked` | `data-state` | Indicator renders? |
36
- | ----------------- | -------------- | ----------------- | ------------------------ |
37
- | `true` | `"true"` | `"checked"` | Yes |
38
- | `false` | `"false"` | `"unchecked"` | No (unless `forceMount`) |
39
- | `"indeterminate"` | `"mixed"` | `"indeterminate"` | Yes |
42
+ | Value | Native | `data-state` | Mark visible? |
43
+ | ----------------- | ---------------- | ----------------- | ------------- |
44
+ | `true` | `:checked` | `"checked"` | Yes (tick) |
45
+ | `false` | — | `"unchecked"` | No |
46
+ | `"indeterminate"` | `:indeterminate` | `"indeterminate"` | Yes (bar) |
40
47
 
41
- Clicking an indeterminate checkbox resolves it to `true` per the
42
- WAI-ARIA tri-state convention; subsequent clicks flip between `true`
43
- and `false`.
48
+ Clicking an indeterminate checkbox resolves it to `true` per the WAI-ARIA
49
+ tri-state convention (the native default); subsequent clicks flip between
50
+ `true` and `false`.
44
51
 
45
52
  ## State modes
46
53
 
47
54
  - **Uncontrolled** — pass `defaultChecked` (or omit for unchecked on mount).
55
+ It may be `"indeterminate"`.
48
56
  - **Controlled** — pass `checked` **and** `onCheckedChange` together.
49
57
 
50
- The two shapes are statically discriminated at the type level;
51
- TypeScript rejects mixing them.
58
+ The two shapes are statically discriminated at the type level; TypeScript
59
+ rejects mixing them.
52
60
 
53
61
  ```tsx
54
62
  // Uncontrolled
55
- <Checkbox.Root defaultChecked>…</Checkbox.Root>;
63
+ <Checkbox.Root name="terms" value="accepted" defaultChecked>
64
+ <Checkbox.Indicator />
65
+ </Checkbox.Root>;
56
66
 
57
67
  // Controlled
58
68
  const [checked, setChecked] = useState<CheckedState>(false);
59
69
  <Checkbox.Root checked={checked} onCheckedChange={setChecked}>
60
- …
70
+ <Checkbox.Indicator />
61
71
  </Checkbox.Root>;
62
72
  ```
63
73
 
74
+ > **`data-state` is a best-effort mirror.** Drive the *visual* checked look off
75
+ > the input's native `:checked` / `:indeterminate` (and `:has(> input:checked)`
76
+ > on the box), as the shipped registry stylesheet does — it stays correct
77
+ > through a form reset, which fires no React event.
78
+
64
79
  ## Keyboard interaction
65
80
 
66
- | Key | Behaviour |
67
- | ------- | ---------------------------------------- |
68
- | `Space` | Toggles the checkbox (native `<button>`) |
69
- | `Enter` | Toggles the checkbox (native `<button>`) |
81
+ | Key | Behaviour |
82
+ | ------- | -------------------------------------------- |
83
+ | `Space` | Toggles the focused checkbox (native input) |
84
+ | `Tab` | Moves to / from the checkbox (native) |
70
85
 
71
- Keyboard handling comes from the underlying `<button>` element — no
72
- custom `keydown` listeners are needed.
86
+ Keyboard handling comes from the underlying native input — no custom `keydown`
87
+ listeners are needed.
73
88
 
74
89
  ## Disabled
75
90
 
76
- Passing `disabled` forwards the native `disabled` attribute to the
77
- button (removing it from the tab order and suppressing clicks) **and**
78
- sets `data-disabled=""` on the root so CSS can target
79
- `[data-disabled]` without reaching for `:disabled`.
91
+ Passing `disabled` forwards the native `disabled` attribute to the input
92
+ (removing it from the tab order and suppressing clicks) **and** sets
93
+ `data-disabled=""` on the box so CSS can target `[data-disabled]` without
94
+ reaching for `:disabled`.
80
95
 
81
96
  ```tsx
82
97
  <Checkbox.Root disabled aria-label="Locked setting">
83
- <Checkbox.Indicator>
84
- <CheckIcon />
85
- </Checkbox.Indicator>
98
+ <Checkbox.Indicator />
86
99
  </Checkbox.Root>
87
100
  ```
88
101
 
89
102
  ## `asChild` composition
90
103
 
91
- Both `Checkbox.Root` and `Checkbox.Indicator` accept an `asChild`
92
- boolean. When set, the component delegates rendering to its single
93
- child element and merges its own ARIA attributes, data-state, composed
94
- event handlers, and ref onto the child (the asChild contract: the
95
- child's handler runs first, the library's runs second unless the child
96
- calls `preventDefault`).
104
+ `Checkbox.Indicator` accepts an `asChild` boolean. When set, the component
105
+ delegates rendering to its single child element and merges its `aria-hidden`,
106
+ `data-state`, and ref onto the child — typically an `<svg>` tick:
97
107
 
98
108
  ```tsx
99
- // Menu-item checkbox — the same state machinery, different element + role.
100
- <Checkbox.Root asChild aria-label="Show hidden files">
101
- <li role="menuitemcheckbox">Show hidden files</li>
102
- </Checkbox.Root>
103
-
104
- // Icon-only indicator — the svg itself becomes the indicator.
105
109
  <Checkbox.Indicator asChild>
106
110
  <svg viewBox="0 0 10 10">
107
111
  <path d="M1 5l3 3 5-7" />
@@ -109,47 +113,24 @@ calls `preventDefault`).
109
113
  </Checkbox.Indicator>
110
114
  ```
111
115
 
112
- `Root`'s `asChild` is what lets `Dropdown.CheckboxItem` (in a later
113
- phase) wrap an `<li role="menuitemcheckbox">` around the Checkbox
114
- state / toggle behaviour without re-implementing any of it.
115
-
116
- ## Animation hooks
117
-
118
- `Checkbox.Indicator` accepts a `forceMount` boolean. When set, the
119
- indicator stays in the DOM regardless of checked state so a CSS
120
- animation can play against `data-state="unchecked"`:
121
-
122
- ```tsx
123
- <Checkbox.Indicator forceMount>
124
- <CheckIcon />
125
- </Checkbox.Indicator>
126
- ```
127
-
128
- ```css
129
- [data-state="checked"] {
130
- animation: tick-in 120ms ease-out;
131
- }
132
- [data-state="unchecked"] {
133
- animation: tick-out 100ms ease-in forwards;
134
- }
135
- ```
136
-
137
- Consumers using `forceMount` own the exit timing themselves.
138
-
139
116
  ## Styling hooks
140
117
 
141
- `data-state="checked" | "unchecked" | "indeterminate"` is set on both
142
- `Checkbox.Root` and `Checkbox.Indicator`, letting any CSS system target
143
- the three phases.
118
+ - The box (`Checkbox.Root`) carries
119
+ `data-state="checked" | "unchecked" | "indeterminate"` and `data-disabled=""`.
120
+ - The hidden input exposes the always-correct native states: `:checked`,
121
+ `:indeterminate`, `:focus-visible`, `:disabled`. Prefer these for the *marked*
122
+ look.
144
123
 
145
124
  ```css
146
- button[data-state="checked"] {
125
+ .checkbox:has(> input:checked),
126
+ .checkbox:has(> input:indeterminate) {
147
127
  background: oklch(65% 0.18 145);
148
128
  }
149
- button[data-state="indeterminate"] {
150
- background: oklch(70% 0.1 250);
129
+ .checkbox > input:checked ~ .checkbox__indicator,
130
+ .checkbox > input:indeterminate ~ .checkbox__indicator {
131
+ scale: 1;
151
132
  }
152
- button[data-disabled] {
133
+ .checkbox[data-disabled] {
153
134
  opacity: 0.5;
154
135
  cursor: not-allowed;
155
136
  }