@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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/Checkbox/Checkbox.tsx +96 -120
- package/src/Checkbox/CheckboxContext.ts +8 -0
- package/src/Checkbox/README.md +69 -88
- package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +48 -10
- package/src/Checkbox/__tests__/Checkbox.contract.test.tsx +29 -40
- package/src/Checkbox/__tests__/Checkbox.controlled-state.test.tsx +16 -24
- package/src/Checkbox/__tests__/Checkbox.disabled.test.tsx +33 -3
- package/src/Checkbox/__tests__/Checkbox.form.test.tsx +53 -0
- package/src/Checkbox/__tests__/Checkbox.indeterminate.test.tsx +28 -34
- package/src/Checkbox/__tests__/Checkbox.indicator.test.tsx +27 -52
- package/src/Checkbox/__tests__/Checkbox.uncontrolled-state.test.tsx +33 -17
- package/src/Checkbox/hooks/index.ts +1 -0
- package/src/Checkbox/hooks/useCheckboxInput.ts +49 -0
- package/src/Checkbox/types.ts +24 -17
- package/src/Radio/README.md +89 -86
- package/src/Radio/Radio.tsx +87 -98
- package/src/Radio/RadioContext.ts +9 -0
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +34 -9
- package/src/Radio/__tests__/Radio.contract.test.tsx +30 -36
- package/src/Radio/__tests__/Radio.controlled-state.test.tsx +21 -11
- package/src/Radio/__tests__/Radio.disabled.test.tsx +31 -6
- package/src/Radio/__tests__/Radio.native-grouping.test.tsx +77 -0
- package/src/Radio/__tests__/Radio.parts.test.tsx +76 -0
- package/src/Radio/__tests__/Radio.uncontrolled-state.test.tsx +39 -32
- package/src/Radio/hooks/useRadioRoot.ts +32 -16
- package/src/Radio/types.ts +30 -16
- package/src/Switch/README.md +45 -57
- package/src/Switch/Switch.tsx +78 -65
- package/src/Switch/SwitchContext.ts +3 -1
- package/src/Switch/__tests__/Switch.asChild.test.tsx +16 -34
- package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +47 -28
- package/src/Switch/__tests__/Switch.contract.test.tsx +21 -16
- package/src/Switch/__tests__/Switch.controlled-state.test.tsx +10 -12
- package/src/Switch/__tests__/Switch.disabled.test.tsx +12 -17
- package/src/Switch/__tests__/Switch.form.test.tsx +55 -0
- package/src/Switch/__tests__/Switch.keyboard-interaction.test.tsx +4 -4
- package/src/Switch/__tests__/Switch.uncontrolled-state.test.tsx +55 -20
- package/src/Switch/hooks/useSwitchRoot.ts +29 -11
- package/src/Switch/types.ts +20 -10
- package/src/Checkbox/__tests__/Checkbox.asChild.test.tsx +0 -69
- package/src/Radio/__tests__/Radio.asChild.test.tsx +0 -63
- package/src/Radio/__tests__/Radio.indicator.test.tsx +0 -103
package/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) |
|
|
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) |
|
|
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) |
|
|
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.
|
|
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,
|
|
4
|
+
import { Slot, composeRefs } from "../Slot/index.ts";
|
|
5
5
|
|
|
6
6
|
import { CheckboxContext } from "./CheckboxContext";
|
|
7
|
-
import { useCheckboxContext,
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* {@link CheckboxIndicator | `Checkbox.Indicator`}
|
|
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
|
|
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
|
-
*
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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>(
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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"
|
|
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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
|
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 `<
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
|
205
|
-
* and carries its sub-components as
|
|
206
|
-
*
|
|
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
|
|
209
|
-
* - {@link CheckboxIndicator | `Checkbox.Indicator`} — decorative
|
|
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] =
|
package/src/Checkbox/README.md
CHANGED
|
@@ -1,107 +1,111 @@
|
|
|
1
1
|
# Checkbox
|
|
2
2
|
|
|
3
|
-
A headless, accessible
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Checkbox renders a
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`
|
|
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
|
|
26
|
-
| -------------------- |
|
|
27
|
-
| `Checkbox.Root` | `<
|
|
28
|
-
| `Checkbox.Indicator` | `<span>`
|
|
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 |
|
|
36
|
-
| ----------------- |
|
|
37
|
-
| `true` | `
|
|
38
|
-
| `false` |
|
|
39
|
-
| `"indeterminate"` | `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
69
|
-
| `
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
142
|
-
`
|
|
143
|
-
the
|
|
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
|
-
|
|
125
|
+
.checkbox:has(> input:checked),
|
|
126
|
+
.checkbox:has(> input:indeterminate) {
|
|
147
127
|
background: oklch(65% 0.18 145);
|
|
148
128
|
}
|
|
149
|
-
|
|
150
|
-
|
|
129
|
+
.checkbox > input:checked ~ .checkbox__indicator,
|
|
130
|
+
.checkbox > input:indeterminate ~ .checkbox__indicator {
|
|
131
|
+
scale: 1;
|
|
151
132
|
}
|
|
152
|
-
|
|
133
|
+
.checkbox[data-disabled] {
|
|
153
134
|
opacity: 0.5;
|
|
154
135
|
cursor: not-allowed;
|
|
155
136
|
}
|