@primitiv-ui/react 0.1.12 → 0.1.14

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 CHANGED
@@ -41,6 +41,7 @@ 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
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 |
45
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 |
46
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`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitiv-ui/react",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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",
@@ -0,0 +1,117 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ import { render, screen } from "@testing-library/react";
6
+
7
+ import { Checkbox } from "../Checkbox";
8
+
9
+ /**
10
+ * Auto-verification of the Checkbox styling contract (RFC 0004 §3.4 / D15). The
11
+ * `data-*` half of `registry/components/checkbox/contract.json` is *derived from
12
+ * and asserted against the rendered headless component* so it can never drift
13
+ * from what the component actually emits. Checkbox is tri-state: its surface is
14
+ * `data-state="checked" | "unchecked" | "indeterminate"` (always present) plus
15
+ * `data-disabled=""` when disabled. The authored half (root class, parts, custom
16
+ * properties) is a styling convention the headless layer does not emit and is
17
+ * checked by the generator drift-guards instead.
18
+ */
19
+ const contractPath = resolve(
20
+ dirname(fileURLToPath(import.meta.url)),
21
+ "../../../../../registry/components/checkbox/contract.json",
22
+ );
23
+
24
+ const contract = JSON.parse(readFileSync(contractPath, "utf8"));
25
+
26
+ /** Every `data-*` attribute name present on an element. */
27
+ function dataAttributeNames(element: Element): string[] {
28
+ return [...element.attributes]
29
+ .map((attribute) => attribute.name)
30
+ .filter((name) => name.startsWith("data-"))
31
+ .sort();
32
+ }
33
+
34
+ describe("Checkbox styling contract", () => {
35
+ const autoAttributes = contract.dataAttributes.filter(
36
+ (attribute: { source: string }) => attribute.source === "auto",
37
+ );
38
+
39
+ it("declares every data-* attribute as auto-verified against the component", () => {
40
+ // The Checkbox has no authored data-* surface; the whole contract is derived.
41
+ expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
42
+ });
43
+
44
+ it("emits exactly the contracted data-* attribute names across its states", () => {
45
+ const emitted = new Set<string>();
46
+ for (const ui of [
47
+ <Checkbox.Root key="checked" checked onCheckedChange={() => {}} aria-label="x">
48
+ <Checkbox.Indicator />
49
+ </Checkbox.Root>,
50
+ <Checkbox.Root
51
+ key="indeterminate"
52
+ checked="indeterminate"
53
+ onCheckedChange={() => {}}
54
+ aria-label="x"
55
+ >
56
+ <Checkbox.Indicator />
57
+ </Checkbox.Root>,
58
+ <Checkbox.Root key="disabled" disabled aria-label="x">
59
+ <Checkbox.Indicator />
60
+ </Checkbox.Root>,
61
+ ]) {
62
+ const { unmount } = render(ui);
63
+ for (const name of dataAttributeNames(screen.getByRole("checkbox"))) {
64
+ emitted.add(name);
65
+ }
66
+ unmount();
67
+ }
68
+
69
+ const contracted = new Set(
70
+ autoAttributes.map((attribute: { name: string }) => attribute.name),
71
+ );
72
+ expect([...emitted].sort()).toEqual([...contracted].sort());
73
+ });
74
+
75
+ it("emits data-state with the documented value for each state", () => {
76
+ const stateEntries = autoAttributes.filter(
77
+ (attribute: { name: string }) => attribute.name === "data-state",
78
+ );
79
+
80
+ for (const entry of stateEntries) {
81
+ const checked =
82
+ entry.value === "indeterminate"
83
+ ? ("indeterminate" as const)
84
+ : entry.value === "checked";
85
+ const { unmount } = render(
86
+ <Checkbox.Root checked={checked} onCheckedChange={() => {}} aria-label="x">
87
+ <Checkbox.Indicator />
88
+ </Checkbox.Root>,
89
+ );
90
+ expect(screen.getByRole("checkbox")).toHaveAttribute("data-state", entry.value);
91
+ unmount();
92
+ }
93
+ });
94
+
95
+ it("emits data-disabled exactly as the contract documents it", () => {
96
+ const entry = autoAttributes.find(
97
+ (attribute: { name: string }) => attribute.name === "data-disabled",
98
+ );
99
+
100
+ render(
101
+ <Checkbox.Root disabled aria-label="x">
102
+ <Checkbox.Indicator />
103
+ </Checkbox.Root>,
104
+ );
105
+ expect(screen.getByRole("checkbox")).toHaveAttribute(entry.name, entry.value);
106
+ });
107
+
108
+ it("omits data-disabled when the documented condition does not hold", () => {
109
+ render(
110
+ <Checkbox.Root aria-label="x">
111
+ <Checkbox.Indicator />
112
+ </Checkbox.Root>,
113
+ );
114
+
115
+ expect(screen.getByRole("checkbox")).not.toHaveAttribute("data-disabled");
116
+ });
117
+ });
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ import { render } from "@testing-library/react";
6
+
7
+ import { Field } from "../Field";
8
+
9
+ /**
10
+ * Auto-verification of the Field styling contract (RFC 0004 §3.4 / D15). The
11
+ * `data-*` half of `registry/components/field/contract.json` is *derived from and
12
+ * asserted against the rendered headless `Field.Root`* so it can never drift from
13
+ * what the component emits. The root always carries `data-field`, plus
14
+ * `data-field-invalid` / `data-field-disabled` / `data-field-required` when the
15
+ * matching flag is set. The authored half (part classes, custom properties) is a
16
+ * styling convention the headless layer does not emit and is checked by the
17
+ * generator drift-guards instead.
18
+ */
19
+ const contractPath = resolve(
20
+ dirname(fileURLToPath(import.meta.url)),
21
+ "../../../../../registry/components/field/contract.json",
22
+ );
23
+
24
+ const contract = JSON.parse(readFileSync(contractPath, "utf8"));
25
+
26
+ /** Every `data-*` attribute name present on an element. */
27
+ function dataAttributeNames(element: Element): string[] {
28
+ return [...element.attributes]
29
+ .map((attribute) => attribute.name)
30
+ .filter((name) => name.startsWith("data-"))
31
+ .sort();
32
+ }
33
+
34
+ /** Render a `Field.Root` with the given flags and return its `[data-field]` element. */
35
+ function renderRoot(props: Record<string, boolean>): Element {
36
+ const { container } = render(<Field.Root {...props} />);
37
+ return container.querySelector("[data-field]")!;
38
+ }
39
+
40
+ describe("Field styling contract", () => {
41
+ const autoAttributes = contract.dataAttributes.filter(
42
+ (attribute: { source: string }) => attribute.source === "auto",
43
+ );
44
+
45
+ it("declares every data-* attribute as auto-verified against the component", () => {
46
+ // The Field has no authored data-* surface; the whole contract is derived.
47
+ expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
48
+ });
49
+
50
+ it("emits exactly the contracted data-* attribute names across its states", () => {
51
+ const emitted = new Set<string>();
52
+ for (const props of [{}, { invalid: true, disabled: true, required: true }]) {
53
+ for (const name of dataAttributeNames(renderRoot(props))) {
54
+ emitted.add(name);
55
+ }
56
+ }
57
+
58
+ const contracted = new Set(
59
+ autoAttributes.map((attribute: { name: string }) => attribute.name),
60
+ );
61
+ expect([...emitted].sort()).toEqual([...contracted].sort());
62
+ });
63
+
64
+ it("always emits data-field regardless of state", () => {
65
+ expect(renderRoot({})).toHaveAttribute("data-field", "");
66
+ });
67
+
68
+ it("emits each conditional attribute exactly as the contract documents it", () => {
69
+ const conditional = autoAttributes.filter(
70
+ (attribute: { when: string }) => attribute.when !== "always",
71
+ );
72
+
73
+ for (const entry of conditional) {
74
+ const root = renderRoot({ [entry.when]: true });
75
+ expect(root).toHaveAttribute(entry.name, entry.value);
76
+ }
77
+ });
78
+
79
+ it("omits each conditional attribute when its flag is not set", () => {
80
+ const root = renderRoot({});
81
+ for (const entry of autoAttributes.filter(
82
+ (attribute: { when: string }) => attribute.when !== "always",
83
+ )) {
84
+ expect(root).not.toHaveAttribute(entry.name);
85
+ }
86
+ });
87
+ });
@@ -0,0 +1,77 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ import { render, screen } from "@testing-library/react";
6
+
7
+ import { Input } from "../Input";
8
+
9
+ /**
10
+ * Auto-verification of the Input styling contract (RFC 0004 §3.4 / D15). The
11
+ * `data-*` half of `registry/components/input/contract.json` is *derived from and
12
+ * asserted against the rendered headless component* so it can never drift from
13
+ * what the component actually emits. Input's only data-* surface is
14
+ * `data-disabled=""` when disabled — `invalid` is an `aria-invalid` concern, not
15
+ * a data attribute. The authored half (root class, custom properties) is a
16
+ * styling convention the headless layer does not emit and is checked by the
17
+ * generator drift-guards instead.
18
+ */
19
+ const contractPath = resolve(
20
+ dirname(fileURLToPath(import.meta.url)),
21
+ "../../../../../registry/components/input/contract.json",
22
+ );
23
+
24
+ const contract = JSON.parse(readFileSync(contractPath, "utf8"));
25
+
26
+ /** Every `data-*` attribute name present on an element. */
27
+ function dataAttributeNames(element: Element): string[] {
28
+ return [...element.attributes]
29
+ .map((attribute) => attribute.name)
30
+ .filter((name) => name.startsWith("data-"))
31
+ .sort();
32
+ }
33
+
34
+ describe("Input styling contract", () => {
35
+ const autoAttributes = contract.dataAttributes.filter(
36
+ (attribute: { source: string }) => attribute.source === "auto",
37
+ );
38
+
39
+ it("declares every data-* attribute as auto-verified against the component", () => {
40
+ // The Input has no authored data-* surface; the whole contract is derived.
41
+ expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
42
+ });
43
+
44
+ it("emits exactly the contracted data-* attribute names across its states", () => {
45
+ const emitted = new Set<string>();
46
+ for (const ui of [
47
+ <Input key="default" aria-label="x" />,
48
+ <Input key="disabled" aria-label="x" disabled />,
49
+ ]) {
50
+ const { unmount } = render(ui);
51
+ for (const name of dataAttributeNames(screen.getByRole("textbox"))) {
52
+ emitted.add(name);
53
+ }
54
+ unmount();
55
+ }
56
+
57
+ const contracted = new Set(
58
+ autoAttributes.map((attribute: { name: string }) => attribute.name),
59
+ );
60
+ expect([...emitted].sort()).toEqual([...contracted].sort());
61
+ });
62
+
63
+ it("emits data-disabled exactly as the contract documents it", () => {
64
+ const entry = autoAttributes.find(
65
+ (attribute: { name: string }) => attribute.name === "data-disabled",
66
+ );
67
+
68
+ render(<Input aria-label="x" disabled />);
69
+ expect(screen.getByRole("textbox")).toHaveAttribute(entry.name, entry.value);
70
+ });
71
+
72
+ it("omits data-disabled when the documented condition does not hold", () => {
73
+ render(<Input aria-label="x" />);
74
+
75
+ expect(screen.getByRole("textbox")).not.toHaveAttribute("data-disabled");
76
+ });
77
+ });
@@ -0,0 +1,60 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ import { render } from "@testing-library/react";
6
+
7
+ import { InputGroup } from "../InputGroup";
8
+
9
+ /**
10
+ * Auto-verification of the InputGroup styling contract (RFC 0004 §3.4 / D15). The
11
+ * `data-*` half of `registry/components/input-group/contract.json` is *derived
12
+ * from and asserted against the rendered headless `InputGroup.Root`* so it can
13
+ * never drift. The root always carries `data-input-group`; the adornment slots
14
+ * are styled through their part classes, not a contracted data attribute. The
15
+ * authored half (part classes, custom properties) is a styling convention the
16
+ * headless layer does not emit and is checked by the generator drift-guards.
17
+ */
18
+ const contractPath = resolve(
19
+ dirname(fileURLToPath(import.meta.url)),
20
+ "../../../../../registry/components/input-group/contract.json",
21
+ );
22
+
23
+ const contract = JSON.parse(readFileSync(contractPath, "utf8"));
24
+
25
+ /** Every `data-*` attribute name present on an element. */
26
+ function dataAttributeNames(element: Element): string[] {
27
+ return [...element.attributes]
28
+ .map((attribute) => attribute.name)
29
+ .filter((name) => name.startsWith("data-"))
30
+ .sort();
31
+ }
32
+
33
+ describe("InputGroup styling contract", () => {
34
+ const autoAttributes = contract.dataAttributes.filter(
35
+ (attribute: { source: string }) => attribute.source === "auto",
36
+ );
37
+
38
+ it("declares every data-* attribute as auto-verified against the component", () => {
39
+ // The InputGroup root has no authored data-* surface; the contract is derived.
40
+ expect(autoAttributes).toHaveLength(contract.dataAttributes.length);
41
+ });
42
+
43
+ it("emits exactly the contracted data-* attribute names on the root", () => {
44
+ const { container } = render(<InputGroup.Root />);
45
+ const root = container.querySelector("[data-input-group]")!;
46
+
47
+ const contracted = autoAttributes
48
+ .map((attribute: { name: string }) => attribute.name)
49
+ .sort();
50
+ expect(dataAttributeNames(root)).toEqual(contracted);
51
+ });
52
+
53
+ it("always emits data-input-group", () => {
54
+ const { container } = render(<InputGroup.Root />);
55
+ expect(container.querySelector("[data-input-group]")).toHaveAttribute(
56
+ "data-input-group",
57
+ "",
58
+ );
59
+ });
60
+ });
@@ -0,0 +1,157 @@
1
+ # Radio
2
+
3
+ A headless, accessible, **standalone** compound component implementing the
4
+ single-selection half of the
5
+ [WAI-ARIA Radio pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/).
6
+
7
+ Radio renders a native `<button role="radio">` so it gets keyboard
8
+ activation (`Space` / `Enter`), focus ring, and disabled semantics for free
9
+ from the browser. The React layer adds one-way selection, `Indicator`
10
+ mounting driven by the selected state, and the `asChild` composition every
11
+ primitive in this package supports.
12
+
13
+ > **Radio vs. RadioGroup.** `Radio` is the lone control for when you own the
14
+ > grouping (a native form `name`, a bespoke layout, a single opt-in). For a
15
+ > managed set with roving-tabindex keyboard navigation, arrow-key selection,
16
+ > and disabled-item skipping, reach for
17
+ > [`RadioGroup`](../RadioGroup/README.md) instead — it composes `role="radio"`
18
+ > items the same way but owns the shared selected value.
19
+
20
+ ```tsx
21
+ import { Radio } from "@primitiv-ui/react";
22
+
23
+ <Radio.Root defaultChecked aria-label="Compact">
24
+ <Radio.Indicator>
25
+ <DotIcon />
26
+ </Radio.Indicator>
27
+ </Radio.Root>;
28
+ ```
29
+
30
+ ## Sub-components
31
+
32
+ | Export | Element | Notes |
33
+ | ----------------- | ---------- | --------------------------------------------------------------------------- |
34
+ | `Radio.Root` | `<button>` | `role="radio"`, `aria-checked`, `data-state`, `data-disabled`. `asChild` |
35
+ | `Radio.Indicator` | `<span>` | `aria-hidden="true"`. Renders only while selected. `asChild`, `forceMount` |
36
+
37
+ ## Selected state
38
+
39
+ Radio is binary. The `checked` / `defaultChecked` value is a `boolean`:
40
+
41
+ | Value | `aria-checked` | `data-state` | Indicator renders? |
42
+ | ------- | -------------- | ------------- | ------------------------ |
43
+ | `true` | `"true"` | `"checked"` | Yes |
44
+ | `false` | `"false"` | `"unchecked"` | No (unless `forceMount`) |
45
+
46
+ Selection is **one-way**: clicking an unselected radio selects it, but
47
+ clicking the already-selected radio is a no-op — a lone radio never toggles
48
+ itself off, and `onCheckedChange` does not fire for a re-select. De-selection
49
+ happens when a sibling is chosen, which is the grouping consumer's concern.
50
+
51
+ ## State modes
52
+
53
+ - **Uncontrolled** — pass `defaultChecked` (or omit for unselected on mount).
54
+ - **Controlled** — pass `checked` **and** `onCheckedChange` together.
55
+
56
+ The two shapes are statically discriminated at the type level; TypeScript
57
+ rejects mixing them.
58
+
59
+ ```tsx
60
+ // Uncontrolled
61
+ <Radio.Root defaultChecked>…</Radio.Root>;
62
+
63
+ // Controlled — the consumer owns the group's selected value.
64
+ const [value, setValue] = useState("comfortable");
65
+ <Radio.Root
66
+ checked={value === "compact"}
67
+ onCheckedChange={() => setValue("compact")}
68
+ >
69
+ …
70
+ </Radio.Root>;
71
+ ```
72
+
73
+ ## Keyboard interaction
74
+
75
+ | Key | Behaviour |
76
+ | ------- | -------------------------------------- |
77
+ | `Space` | Selects the radio (native `<button>`) |
78
+ | `Enter` | Selects the radio (native `<button>`) |
79
+
80
+ Keyboard handling comes from the underlying `<button>` element — no custom
81
+ `keydown` listeners are needed. (Arrow-key navigation across a set is a
82
+ `RadioGroup` concern, not a standalone `Radio` one.)
83
+
84
+ ## Disabled
85
+
86
+ Passing `disabled` forwards the native `disabled` attribute to the button
87
+ (removing it from the tab order and suppressing clicks) **and** sets
88
+ `data-disabled=""` on the root so CSS can target `[data-disabled]` without
89
+ reaching for `:disabled`.
90
+
91
+ ```tsx
92
+ <Radio.Root disabled aria-label="Unavailable plan">
93
+ <Radio.Indicator>
94
+ <DotIcon />
95
+ </Radio.Indicator>
96
+ </Radio.Root>
97
+ ```
98
+
99
+ ## `asChild` composition
100
+
101
+ Both `Radio.Root` and `Radio.Indicator` accept an `asChild` boolean. When
102
+ set, the component delegates rendering to its single child element and merges
103
+ its own ARIA attributes, data-state, composed event handlers, and ref onto
104
+ the child (the asChild contract: the child's handler runs first, the
105
+ library's runs second unless the child calls `preventDefault`).
106
+
107
+ ```tsx
108
+ // Menu-item radio — the same state machinery, different element + role.
109
+ <Radio.Root asChild aria-label="Sort by name">
110
+ <li role="menuitemradio">Sort by name</li>
111
+ </Radio.Root>
112
+
113
+ // Icon-only indicator — the svg itself becomes the indicator.
114
+ <Radio.Indicator asChild>
115
+ <svg viewBox="0 0 10 10">
116
+ <circle cx="5" cy="5" r="3" />
117
+ </svg>
118
+ </Radio.Indicator>
119
+ ```
120
+
121
+ ## Animation hooks
122
+
123
+ `Radio.Indicator` accepts a `forceMount` boolean. When set, the indicator
124
+ stays in the DOM regardless of selected state so a CSS animation can play
125
+ against `data-state="unchecked"`:
126
+
127
+ ```tsx
128
+ <Radio.Indicator forceMount>
129
+ <DotIcon />
130
+ </Radio.Indicator>
131
+ ```
132
+
133
+ ```css
134
+ [data-state="checked"] {
135
+ animation: dot-in 120ms ease-out;
136
+ }
137
+ [data-state="unchecked"] {
138
+ animation: dot-out 100ms ease-in forwards;
139
+ }
140
+ ```
141
+
142
+ Consumers using `forceMount` own the exit timing themselves.
143
+
144
+ ## Styling hooks
145
+
146
+ `data-state="checked" | "unchecked"` is set on both `Radio.Root` and
147
+ `Radio.Indicator`, letting any CSS system target both phases.
148
+
149
+ ```css
150
+ button[data-state="checked"] {
151
+ border-color: oklch(55% 0.2 265);
152
+ }
153
+ button[data-disabled] {
154
+ opacity: 0.5;
155
+ cursor: not-allowed;
156
+ }
157
+ ```