@arun-dev/headless 3.0.0 → 3.0.1
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 +6 -0
- package/dist/button/index.d.cts +3 -0
- package/dist/button/index.d.ts +3 -0
- package/dist/switch/index.cjs +43 -6
- package/dist/switch/index.d.cts +9 -8
- package/dist/switch/index.d.ts +9 -8
- package/dist/switch/index.js +43 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ import { Switch } from '@arun-dev/headless/switch';
|
|
|
35
35
|
navigates should be an anchor, so middle-click, cmd-click and "link" in assistive technology all
|
|
36
36
|
keep working. It defaults `type="button"` so a button never submits a form by accident, and a
|
|
37
37
|
disabled `href` renders a `<button disabled>`, because a link that navigates nowhere is not a link.
|
|
38
|
+
A `render` component cannot be inspected, so a disabled Button gives it `aria-disabled` and
|
|
39
|
+
`data-disabled` rather than `disabled` — actually disabling itself is up to the component.
|
|
38
40
|
|
|
39
41
|
```tsx
|
|
40
42
|
import { Button } from '@arun-dev/headless/button';
|
|
@@ -48,6 +50,10 @@ import { Button } from '@arun-dev/headless/button';
|
|
|
48
50
|
from the platform. It carries `role="switch"` and `aria-checked`, but **no accessible name** —
|
|
49
51
|
wrap it in a `<label>` or pass `aria-label`. A headless component should not guess at your copy.
|
|
50
52
|
|
|
53
|
+
In a form it behaves like a native checkbox: with a `name` it submits its `value` only when checked
|
|
54
|
+
and enabled, and `form.reset()` returns it to the state it mounted with, reported through
|
|
55
|
+
`onCheckedChange`.
|
|
56
|
+
|
|
51
57
|
## State reaches CSS through `data-*`
|
|
52
58
|
|
|
53
59
|
A headless component owns no class names, so state is projected onto the DOM instead. That
|
package/dist/button/index.d.cts
CHANGED
|
@@ -11,6 +11,9 @@ type ButtonOwnProps = {
|
|
|
11
11
|
* Prevents activation. On a `<button>` the platform does it; on the `<a>` that
|
|
12
12
|
* `href` renders, dropping the `href` does it — the DOM leaves an anchor without one
|
|
13
13
|
* unfocusable, unactivatable and no longer a link.
|
|
14
|
+
*
|
|
15
|
+
* A `render` component cannot be inspected, so it gets `aria-disabled` and
|
|
16
|
+
* `data-disabled` rather than `disabled`: actually disabling itself is up to it.
|
|
14
17
|
*/
|
|
15
18
|
disabled?: boolean;
|
|
16
19
|
/**
|
package/dist/button/index.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ type ButtonOwnProps = {
|
|
|
11
11
|
* Prevents activation. On a `<button>` the platform does it; on the `<a>` that
|
|
12
12
|
* `href` renders, dropping the `href` does it — the DOM leaves an anchor without one
|
|
13
13
|
* unfocusable, unactivatable and no longer a link.
|
|
14
|
+
*
|
|
15
|
+
* A `render` component cannot be inspected, so it gets `aria-disabled` and
|
|
16
|
+
* `data-disabled` rather than `disabled`: actually disabling itself is up to it.
|
|
14
17
|
*/
|
|
15
18
|
disabled?: boolean;
|
|
16
19
|
/**
|
package/dist/switch/index.cjs
CHANGED
|
@@ -197,7 +197,10 @@ function SwitchRoot({
|
|
|
197
197
|
state: "checked"
|
|
198
198
|
});
|
|
199
199
|
const state = (0, import_react4.useMemo)(() => ({ checked, disabled }), [checked, disabled]);
|
|
200
|
-
const elementRef =
|
|
200
|
+
const elementRef = (0, import_react4.useRef)(null);
|
|
201
|
+
const inputRef = (0, import_react4.useRef)(null);
|
|
202
|
+
useNativeButtonWarning(elementRef);
|
|
203
|
+
useFormReset({ elementRef, inputRef, checked, setChecked, onCheckedChange });
|
|
201
204
|
const element = useRender({
|
|
202
205
|
render,
|
|
203
206
|
defaultTagName: "button",
|
|
@@ -222,11 +225,46 @@ function SwitchRoot({
|
|
|
222
225
|
});
|
|
223
226
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(SwitchRootContext.Provider, { value: state, children: [
|
|
224
227
|
element,
|
|
225
|
-
name !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
228
|
+
name !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
229
|
+
"input",
|
|
230
|
+
{
|
|
231
|
+
ref: inputRef,
|
|
232
|
+
type: "checkbox",
|
|
233
|
+
hidden: true,
|
|
234
|
+
readOnly: true,
|
|
235
|
+
name,
|
|
236
|
+
value,
|
|
237
|
+
checked,
|
|
238
|
+
disabled
|
|
239
|
+
}
|
|
240
|
+
) : null
|
|
226
241
|
] });
|
|
227
242
|
}
|
|
228
|
-
function
|
|
229
|
-
|
|
243
|
+
function useFormReset({
|
|
244
|
+
elementRef,
|
|
245
|
+
inputRef,
|
|
246
|
+
checked,
|
|
247
|
+
setChecked,
|
|
248
|
+
onCheckedChange
|
|
249
|
+
}) {
|
|
250
|
+
const { current: initialChecked } = (0, import_react4.useRef)(checked);
|
|
251
|
+
(0, import_react4.useEffect)(() => {
|
|
252
|
+
const form = elementRef.current?.form;
|
|
253
|
+
if (!form) return;
|
|
254
|
+
function onReset(event) {
|
|
255
|
+
queueMicrotask(() => {
|
|
256
|
+
if (event.defaultPrevented) return;
|
|
257
|
+
if (inputRef.current) inputRef.current.checked = checked;
|
|
258
|
+
if (checked === initialChecked) return;
|
|
259
|
+
setChecked(initialChecked);
|
|
260
|
+
onCheckedChange?.(initialChecked);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
form.addEventListener("reset", onReset);
|
|
264
|
+
return () => form.removeEventListener("reset", onReset);
|
|
265
|
+
}, [elementRef, inputRef, checked, setChecked, onCheckedChange, initialChecked]);
|
|
266
|
+
}
|
|
267
|
+
function useNativeButtonWarning(elementRef) {
|
|
230
268
|
(0, import_react4.useEffect)(() => {
|
|
231
269
|
if (process.env.NODE_ENV === "production") return;
|
|
232
270
|
const element = elementRef.current;
|
|
@@ -234,8 +272,7 @@ function useNativeButtonWarning() {
|
|
|
234
272
|
console.error(
|
|
235
273
|
`Switch.Root rendered <${element.tagName.toLowerCase()}> instead of <button>. Focus, Space and Enter activation and \`disabled\` all come from the button element; pass a \`render\` component that forwards its props to one.`
|
|
236
274
|
);
|
|
237
|
-
}, []);
|
|
238
|
-
return elementRef;
|
|
275
|
+
}, [elementRef]);
|
|
239
276
|
}
|
|
240
277
|
|
|
241
278
|
// src/switch/SwitchThumb.tsx
|
package/dist/switch/index.d.cts
CHANGED
|
@@ -7,8 +7,9 @@ import { ReactElement, Ref, ComponentPropsWithRef } from 'react';
|
|
|
7
7
|
* and checked without being declared here.
|
|
8
8
|
*
|
|
9
9
|
* `id` in particular is how a switch gets an accessible name, paired with a
|
|
10
|
-
* `<label htmlFor
|
|
11
|
-
*
|
|
10
|
+
* `<label htmlFor>`. A wrapping `<label>` names the rendered `<button>` too, but
|
|
11
|
+
* `jsx-a11y/label-has-associated-control` rejects a button as a nested control, so the
|
|
12
|
+
* explicit pairing is the one that passes lint.
|
|
12
13
|
*/
|
|
13
14
|
type SwitchRootOwnProps = {
|
|
14
15
|
/**
|
|
@@ -25,7 +26,7 @@ type SwitchRootOwnProps = {
|
|
|
25
26
|
disabled?: boolean;
|
|
26
27
|
/**
|
|
27
28
|
* Submits with the enclosing form when checked, mirroring a native checkbox:
|
|
28
|
-
* an unchecked control contributes nothing.
|
|
29
|
+
* an unchecked or disabled control contributes nothing.
|
|
29
30
|
*/
|
|
30
31
|
name?: string;
|
|
31
32
|
/** Value submitted when checked. Defaults to `"on"`, as a native checkbox does. */
|
|
@@ -51,6 +52,10 @@ type SwitchRootProps = SwitchRootOwnProps & Omit<ComponentPropsWithRef<'button'>
|
|
|
51
52
|
* of it is synthesised here. Per the WAI-ARIA switch pattern it carries `role="switch"`
|
|
52
53
|
* and `aria-checked`.
|
|
53
54
|
*
|
|
55
|
+
* Inside a form it behaves as a checkbox would there: `form.reset()` returns it to the
|
|
56
|
+
* state it mounted with. The change is reported through `onCheckedChange`, so a
|
|
57
|
+
* controlled switch moves only if its parent accepts it.
|
|
58
|
+
*
|
|
54
59
|
* It has no accessible name of its own — pair it with a `<label htmlFor>` by `id`, or
|
|
55
60
|
* pass `aria-label` or `aria-labelledby`. That is the consumer's decision, not one a
|
|
56
61
|
* headless component should guess.
|
|
@@ -87,11 +92,7 @@ declare namespace index_parts {
|
|
|
87
92
|
export { SwitchRoot as Root, SwitchThumb as Thumb };
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
/**
|
|
91
|
-
* A type alias rather than an interface: only aliases get an implicit index
|
|
92
|
-
* signature, which is what lets this satisfy the `Record<string, unknown>` the
|
|
93
|
-
* state-attribute mapper is generic over.
|
|
94
|
-
*/
|
|
95
|
+
/** The state Switch.Root shares with its parts, and projects as `data-*` attributes. */
|
|
95
96
|
type SwitchState = {
|
|
96
97
|
checked: boolean;
|
|
97
98
|
disabled: boolean;
|
package/dist/switch/index.d.ts
CHANGED
|
@@ -7,8 +7,9 @@ import { ReactElement, Ref, ComponentPropsWithRef } from 'react';
|
|
|
7
7
|
* and checked without being declared here.
|
|
8
8
|
*
|
|
9
9
|
* `id` in particular is how a switch gets an accessible name, paired with a
|
|
10
|
-
* `<label htmlFor
|
|
11
|
-
*
|
|
10
|
+
* `<label htmlFor>`. A wrapping `<label>` names the rendered `<button>` too, but
|
|
11
|
+
* `jsx-a11y/label-has-associated-control` rejects a button as a nested control, so the
|
|
12
|
+
* explicit pairing is the one that passes lint.
|
|
12
13
|
*/
|
|
13
14
|
type SwitchRootOwnProps = {
|
|
14
15
|
/**
|
|
@@ -25,7 +26,7 @@ type SwitchRootOwnProps = {
|
|
|
25
26
|
disabled?: boolean;
|
|
26
27
|
/**
|
|
27
28
|
* Submits with the enclosing form when checked, mirroring a native checkbox:
|
|
28
|
-
* an unchecked control contributes nothing.
|
|
29
|
+
* an unchecked or disabled control contributes nothing.
|
|
29
30
|
*/
|
|
30
31
|
name?: string;
|
|
31
32
|
/** Value submitted when checked. Defaults to `"on"`, as a native checkbox does. */
|
|
@@ -51,6 +52,10 @@ type SwitchRootProps = SwitchRootOwnProps & Omit<ComponentPropsWithRef<'button'>
|
|
|
51
52
|
* of it is synthesised here. Per the WAI-ARIA switch pattern it carries `role="switch"`
|
|
52
53
|
* and `aria-checked`.
|
|
53
54
|
*
|
|
55
|
+
* Inside a form it behaves as a checkbox would there: `form.reset()` returns it to the
|
|
56
|
+
* state it mounted with. The change is reported through `onCheckedChange`, so a
|
|
57
|
+
* controlled switch moves only if its parent accepts it.
|
|
58
|
+
*
|
|
54
59
|
* It has no accessible name of its own — pair it with a `<label htmlFor>` by `id`, or
|
|
55
60
|
* pass `aria-label` or `aria-labelledby`. That is the consumer's decision, not one a
|
|
56
61
|
* headless component should guess.
|
|
@@ -87,11 +92,7 @@ declare namespace index_parts {
|
|
|
87
92
|
export { SwitchRoot as Root, SwitchThumb as Thumb };
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
/**
|
|
91
|
-
* A type alias rather than an interface: only aliases get an implicit index
|
|
92
|
-
* signature, which is what lets this satisfy the `Record<string, unknown>` the
|
|
93
|
-
* state-attribute mapper is generic over.
|
|
94
|
-
*/
|
|
95
|
+
/** The state Switch.Root shares with its parts, and projects as `data-*` attributes. */
|
|
95
96
|
type SwitchState = {
|
|
96
97
|
checked: boolean;
|
|
97
98
|
disabled: boolean;
|
package/dist/switch/index.js
CHANGED
|
@@ -57,7 +57,10 @@ function SwitchRoot({
|
|
|
57
57
|
state: "checked"
|
|
58
58
|
});
|
|
59
59
|
const state = useMemo(() => ({ checked, disabled }), [checked, disabled]);
|
|
60
|
-
const elementRef =
|
|
60
|
+
const elementRef = useRef(null);
|
|
61
|
+
const inputRef = useRef(null);
|
|
62
|
+
useNativeButtonWarning(elementRef);
|
|
63
|
+
useFormReset({ elementRef, inputRef, checked, setChecked, onCheckedChange });
|
|
61
64
|
const element = useRender({
|
|
62
65
|
render,
|
|
63
66
|
defaultTagName: "button",
|
|
@@ -82,11 +85,46 @@ function SwitchRoot({
|
|
|
82
85
|
});
|
|
83
86
|
return /* @__PURE__ */ jsxs(SwitchRootContext.Provider, { value: state, children: [
|
|
84
87
|
element,
|
|
85
|
-
name !== void 0 ? /* @__PURE__ */ jsx(
|
|
88
|
+
name !== void 0 ? /* @__PURE__ */ jsx(
|
|
89
|
+
"input",
|
|
90
|
+
{
|
|
91
|
+
ref: inputRef,
|
|
92
|
+
type: "checkbox",
|
|
93
|
+
hidden: true,
|
|
94
|
+
readOnly: true,
|
|
95
|
+
name,
|
|
96
|
+
value,
|
|
97
|
+
checked,
|
|
98
|
+
disabled
|
|
99
|
+
}
|
|
100
|
+
) : null
|
|
86
101
|
] });
|
|
87
102
|
}
|
|
88
|
-
function
|
|
89
|
-
|
|
103
|
+
function useFormReset({
|
|
104
|
+
elementRef,
|
|
105
|
+
inputRef,
|
|
106
|
+
checked,
|
|
107
|
+
setChecked,
|
|
108
|
+
onCheckedChange
|
|
109
|
+
}) {
|
|
110
|
+
const { current: initialChecked } = useRef(checked);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const form = elementRef.current?.form;
|
|
113
|
+
if (!form) return;
|
|
114
|
+
function onReset(event) {
|
|
115
|
+
queueMicrotask(() => {
|
|
116
|
+
if (event.defaultPrevented) return;
|
|
117
|
+
if (inputRef.current) inputRef.current.checked = checked;
|
|
118
|
+
if (checked === initialChecked) return;
|
|
119
|
+
setChecked(initialChecked);
|
|
120
|
+
onCheckedChange?.(initialChecked);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
form.addEventListener("reset", onReset);
|
|
124
|
+
return () => form.removeEventListener("reset", onReset);
|
|
125
|
+
}, [elementRef, inputRef, checked, setChecked, onCheckedChange, initialChecked]);
|
|
126
|
+
}
|
|
127
|
+
function useNativeButtonWarning(elementRef) {
|
|
90
128
|
useEffect(() => {
|
|
91
129
|
if (process.env.NODE_ENV === "production") return;
|
|
92
130
|
const element = elementRef.current;
|
|
@@ -94,8 +132,7 @@ function useNativeButtonWarning() {
|
|
|
94
132
|
console.error(
|
|
95
133
|
`Switch.Root rendered <${element.tagName.toLowerCase()}> instead of <button>. Focus, Space and Enter activation and \`disabled\` all come from the button element; pass a \`render\` component that forwards its props to one.`
|
|
96
134
|
);
|
|
97
|
-
}, []);
|
|
98
|
-
return elementRef;
|
|
135
|
+
}, [elementRef]);
|
|
99
136
|
}
|
|
100
137
|
|
|
101
138
|
// src/switch/SwitchThumb.tsx
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arun-dev/headless",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Unstyled React behaviour primitives — render engine, controlled/uncontrolled state, and data-* state projection. Ships no CSS and no class names.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"react-dom": "19.2.8",
|
|
80
80
|
"tsup": "8.5.1",
|
|
81
81
|
"typescript": "5.9.3",
|
|
82
|
-
"vitest": "
|
|
82
|
+
"vitest": "4.1.11",
|
|
83
83
|
"@arun-dev/config": "0.0.0"
|
|
84
84
|
},
|
|
85
85
|
"scripts": {
|