@arun-dev/headless 0.2.0 → 0.3.0
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 +99 -0
- package/dist/{chunk-DAO4JK6U.js → chunk-LU4ZQE3V.js} +2 -0
- package/dist/index.cjs +45 -0
- package/dist/index.d.cts +29 -1
- package/dist/index.d.ts +29 -1
- package/dist/index.js +43 -1
- package/dist/switch/index.cjs +2 -1
- package/dist/switch/index.d.cts +7 -1
- package/dist/switch/index.d.ts +7 -1
- package/dist/switch/index.js +3 -2
- package/package.json +2 -2
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @arun-dev/headless
|
|
2
|
+
|
|
3
|
+
Unstyled React behaviour primitives. Components bring their behaviour, keyboard handling and
|
|
4
|
+
accessibility, and nothing else — no CSS, no class names, no colour. The styling is entirely
|
|
5
|
+
yours.
|
|
6
|
+
|
|
7
|
+
Every part takes a `render` prop, spreads unrecognised props onto the element it renders, and
|
|
8
|
+
projects its state as `data-*` attributes, so any styling approach works: plain CSS, CSS modules,
|
|
9
|
+
utility classes, or a component library of your own.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @arun-dev/headless
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Peer dependencies: `react >= 19`, `react-dom >= 19`.
|
|
18
|
+
|
|
19
|
+
## Components
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { Switch } from '@arun-dev/headless/switch';
|
|
23
|
+
|
|
24
|
+
<Switch.Root defaultChecked onCheckedChange={save} aria-label="Notifications">
|
|
25
|
+
<Switch.Thumb />
|
|
26
|
+
</Switch.Root>;
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Component | Parts | Props |
|
|
30
|
+
| --------- | ----------------------- | --------------------------------------------------------------------------- |
|
|
31
|
+
| `Switch` | `Switch.Root`, `.Thumb` | `checked`, `defaultChecked`, `onCheckedChange`, `disabled`, `name`, `value` |
|
|
32
|
+
|
|
33
|
+
`Switch.Root` renders a native `<button>`, so focus, `Space`, `Enter` and disabled semantics come
|
|
34
|
+
from the platform. It carries `role="switch"` and `aria-checked`, but **no accessible name** —
|
|
35
|
+
wrap it in a `<label>` or pass `aria-label`. A headless component should not guess at your copy.
|
|
36
|
+
|
|
37
|
+
## State reaches CSS through `data-*`
|
|
38
|
+
|
|
39
|
+
A headless component owns no class names, so state is projected onto the DOM instead. That
|
|
40
|
+
attribute name is the entire contract between behaviour and styling:
|
|
41
|
+
|
|
42
|
+
```css
|
|
43
|
+
.my-switch[data-checked] {
|
|
44
|
+
background: rebeccapurple;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Both parts of `Switch` emit `data-checked` / `data-unchecked` / `data-disabled`. The negative form
|
|
49
|
+
is emitted deliberately: `:not([data-checked])` would also match any third state added later, so
|
|
50
|
+
matching the state you mean keeps future states additive.
|
|
51
|
+
|
|
52
|
+
## Controlled and uncontrolled
|
|
53
|
+
|
|
54
|
+
Pass `checked` with `onCheckedChange` and the parent owns the value — the switch will not move on
|
|
55
|
+
its own. Pass `defaultChecked` and the component owns it. `onCheckedChange` fires in both modes.
|
|
56
|
+
|
|
57
|
+
The mode is decided **once, at mount**, and never re-evaluated. A `checked` of `undefined` on the
|
|
58
|
+
first render therefore makes the component uncontrolled for the rest of its life, and every value
|
|
59
|
+
passed afterwards is ignored. When the value arrives asynchronously, coalesce at the call site:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
<Switch.Root checked={enabled ?? false} onCheckedChange={setEnabled} />
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Mixing the modes, or changing the default after mount, logs a development-only warning.
|
|
66
|
+
|
|
67
|
+
## Composition
|
|
68
|
+
|
|
69
|
+
Every part takes a `render` prop to change the element, and spreads unrecognised props onto it:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
<Switch.Root render={<Tooltip.Trigger />} />
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`className` is concatenated, event handlers are chained rather than replaced, `style` is merged,
|
|
76
|
+
and refs are merged — so a `ref` on the `render` element and a `ref` on the component both receive
|
|
77
|
+
the node.
|
|
78
|
+
|
|
79
|
+
## Engine
|
|
80
|
+
|
|
81
|
+
The primitives the components are built from are exported from the root, for building your own:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import {
|
|
85
|
+
useRender,
|
|
86
|
+
useControlled,
|
|
87
|
+
mergeProps,
|
|
88
|
+
getStateAttributes,
|
|
89
|
+
booleanAttribute,
|
|
90
|
+
} from '@arun-dev/headless';
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Export | Purpose |
|
|
94
|
+
| -------------------- | ----------------------------------------------------------------------------- |
|
|
95
|
+
| `useRender` | Resolves what a part renders — merges props, projects state, applies `render` |
|
|
96
|
+
| `useControlled` | One value, controlled or uncontrolled, decided at mount |
|
|
97
|
+
| `mergeProps` | Merges prop objects: handlers chain, `className` concatenates, refs merge |
|
|
98
|
+
| `getStateAttributes` | Projects a state object onto `data-*` attributes via a declared mapping |
|
|
99
|
+
| `booleanAttribute` | The common mapping — one attribute when true, another when false |
|
|
@@ -75,6 +75,7 @@ function booleanAttribute(whenTrue, whenFalse) {
|
|
|
75
75
|
return whenFalse ? { [whenFalse]: "" } : null;
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
+
var disabledAttribute = booleanAttribute("data-disabled");
|
|
78
79
|
|
|
79
80
|
// src/core/useRender.ts
|
|
80
81
|
import { cloneElement, createElement, isValidElement } from "react";
|
|
@@ -131,6 +132,7 @@ export {
|
|
|
131
132
|
mergeProps,
|
|
132
133
|
getStateAttributes,
|
|
133
134
|
booleanAttribute,
|
|
135
|
+
disabledAttribute,
|
|
134
136
|
useRender,
|
|
135
137
|
useControlled
|
|
136
138
|
};
|
package/dist/index.cjs
CHANGED
|
@@ -21,8 +21,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
booleanAttribute: () => booleanAttribute,
|
|
24
|
+
disabledAttribute: () => disabledAttribute,
|
|
24
25
|
getStateAttributes: () => getStateAttributes,
|
|
25
26
|
mergeProps: () => mergeProps,
|
|
27
|
+
retractActivationProps: () => retractActivationProps,
|
|
28
|
+
useButton: () => useButton,
|
|
26
29
|
useControlled: () => useControlled,
|
|
27
30
|
useRender: () => useRender
|
|
28
31
|
});
|
|
@@ -102,6 +105,7 @@ function booleanAttribute(whenTrue, whenFalse) {
|
|
|
102
105
|
return whenFalse ? { [whenFalse]: "" } : null;
|
|
103
106
|
};
|
|
104
107
|
}
|
|
108
|
+
var disabledAttribute = booleanAttribute("data-disabled");
|
|
105
109
|
|
|
106
110
|
// src/core/useRender.ts
|
|
107
111
|
function useRender({
|
|
@@ -151,11 +155,52 @@ function useControlled({
|
|
|
151
155
|
);
|
|
152
156
|
return [value, setValue];
|
|
153
157
|
}
|
|
158
|
+
|
|
159
|
+
// src/useButton.ts
|
|
160
|
+
var ACTIVATION_HANDLERS = [
|
|
161
|
+
"onClick",
|
|
162
|
+
"onDoubleClick",
|
|
163
|
+
"onMouseDown",
|
|
164
|
+
"onMouseUp",
|
|
165
|
+
"onPointerDown",
|
|
166
|
+
"onPointerUp",
|
|
167
|
+
"onTouchStart",
|
|
168
|
+
"onTouchEnd",
|
|
169
|
+
"onKeyDown",
|
|
170
|
+
"onKeyUp",
|
|
171
|
+
"onKeyPress"
|
|
172
|
+
];
|
|
173
|
+
function useButton({ disabled = false, native, props = {} }) {
|
|
174
|
+
if (!disabled) return props;
|
|
175
|
+
if (native) return { ...props, disabled: true, "data-disabled": "" };
|
|
176
|
+
const sanitised = {};
|
|
177
|
+
for (const key of Object.keys(props)) {
|
|
178
|
+
if (key === "href" || ACTIVATION_HANDLERS.includes(key)) continue;
|
|
179
|
+
sanitised[key] = props[key];
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
...sanitised,
|
|
183
|
+
"aria-disabled": true,
|
|
184
|
+
"data-disabled": "",
|
|
185
|
+
// Mirrors a native disabled button, which is not focusable.
|
|
186
|
+
tabIndex: -1
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function retractActivationProps(props) {
|
|
190
|
+
const overrides = {};
|
|
191
|
+
for (const key of Object.keys(props)) {
|
|
192
|
+
if (key === "href" || ACTIVATION_HANDLERS.includes(key)) overrides[key] = void 0;
|
|
193
|
+
}
|
|
194
|
+
return overrides;
|
|
195
|
+
}
|
|
154
196
|
// Annotate the CommonJS export names for ESM import in node:
|
|
155
197
|
0 && (module.exports = {
|
|
156
198
|
booleanAttribute,
|
|
199
|
+
disabledAttribute,
|
|
157
200
|
getStateAttributes,
|
|
158
201
|
mergeProps,
|
|
202
|
+
retractActivationProps,
|
|
203
|
+
useButton,
|
|
159
204
|
useControlled,
|
|
160
205
|
useRender
|
|
161
206
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -51,6 +51,12 @@ declare function getStateAttributes<State extends Record<string, unknown>>(state
|
|
|
51
51
|
* checked: booleanAttribute('data-checked', 'data-unchecked')
|
|
52
52
|
*/
|
|
53
53
|
declare function booleanAttribute(whenTrue: string, whenFalse?: string): (value: unknown) => Record<string, string> | null;
|
|
54
|
+
/**
|
|
55
|
+
* The shared spelling of the disabled state. Every component uses this rather than
|
|
56
|
+
* writing the string again, so `[data-disabled]` means the same thing system-wide and
|
|
57
|
+
* a typo cannot silently split the CSS contract.
|
|
58
|
+
*/
|
|
59
|
+
declare const disabledAttribute: (value: unknown) => Record<string, string> | null;
|
|
54
60
|
|
|
55
61
|
/**
|
|
56
62
|
* Resolves what a component part actually renders.
|
|
@@ -96,4 +102,26 @@ declare function useControlled<T>({ controlled, default: defaultValue, name, sta
|
|
|
96
102
|
state?: string;
|
|
97
103
|
}): [T, (next: T) => void];
|
|
98
104
|
|
|
99
|
-
|
|
105
|
+
interface UseButtonParams {
|
|
106
|
+
disabled?: boolean;
|
|
107
|
+
/** Whether the rendered element is a native `<button>`, which the platform disables for us. */
|
|
108
|
+
native: boolean;
|
|
109
|
+
/** Props destined for the element. Returned unchanged unless `disabled`. */
|
|
110
|
+
props?: UnknownProps;
|
|
111
|
+
}
|
|
112
|
+
declare function useButton({ disabled, native, props }: UseButtonParams): UnknownProps;
|
|
113
|
+
/**
|
|
114
|
+
* Overrides that neutralise a consumer-supplied `render` element.
|
|
115
|
+
*
|
|
116
|
+
* `useButton` sanitises the props a component passes, but a `render` element's own
|
|
117
|
+
* props merge at the highest precedence inside `useRender`, so a `href` written
|
|
118
|
+
* directly on it survives. Those have to be retracted on the element itself:
|
|
119
|
+
*
|
|
120
|
+
* cloneElement(render, retractActivationProps(render.props))
|
|
121
|
+
*
|
|
122
|
+
* `cloneElement` overwrites with `undefined`, which `mergeProps` cannot do — it skips
|
|
123
|
+
* `undefined` so an absent prop never clobbers a present one.
|
|
124
|
+
*/
|
|
125
|
+
declare function retractActivationProps(props: UnknownProps): UnknownProps;
|
|
126
|
+
|
|
127
|
+
export { type StateAttributeMapping, type UnknownProps, type UseButtonParams, type UseRenderParams, booleanAttribute, disabledAttribute, getStateAttributes, mergeProps, retractActivationProps, useButton, useControlled, useRender };
|
package/dist/index.d.ts
CHANGED
|
@@ -51,6 +51,12 @@ declare function getStateAttributes<State extends Record<string, unknown>>(state
|
|
|
51
51
|
* checked: booleanAttribute('data-checked', 'data-unchecked')
|
|
52
52
|
*/
|
|
53
53
|
declare function booleanAttribute(whenTrue: string, whenFalse?: string): (value: unknown) => Record<string, string> | null;
|
|
54
|
+
/**
|
|
55
|
+
* The shared spelling of the disabled state. Every component uses this rather than
|
|
56
|
+
* writing the string again, so `[data-disabled]` means the same thing system-wide and
|
|
57
|
+
* a typo cannot silently split the CSS contract.
|
|
58
|
+
*/
|
|
59
|
+
declare const disabledAttribute: (value: unknown) => Record<string, string> | null;
|
|
54
60
|
|
|
55
61
|
/**
|
|
56
62
|
* Resolves what a component part actually renders.
|
|
@@ -96,4 +102,26 @@ declare function useControlled<T>({ controlled, default: defaultValue, name, sta
|
|
|
96
102
|
state?: string;
|
|
97
103
|
}): [T, (next: T) => void];
|
|
98
104
|
|
|
99
|
-
|
|
105
|
+
interface UseButtonParams {
|
|
106
|
+
disabled?: boolean;
|
|
107
|
+
/** Whether the rendered element is a native `<button>`, which the platform disables for us. */
|
|
108
|
+
native: boolean;
|
|
109
|
+
/** Props destined for the element. Returned unchanged unless `disabled`. */
|
|
110
|
+
props?: UnknownProps;
|
|
111
|
+
}
|
|
112
|
+
declare function useButton({ disabled, native, props }: UseButtonParams): UnknownProps;
|
|
113
|
+
/**
|
|
114
|
+
* Overrides that neutralise a consumer-supplied `render` element.
|
|
115
|
+
*
|
|
116
|
+
* `useButton` sanitises the props a component passes, but a `render` element's own
|
|
117
|
+
* props merge at the highest precedence inside `useRender`, so a `href` written
|
|
118
|
+
* directly on it survives. Those have to be retracted on the element itself:
|
|
119
|
+
*
|
|
120
|
+
* cloneElement(render, retractActivationProps(render.props))
|
|
121
|
+
*
|
|
122
|
+
* `cloneElement` overwrites with `undefined`, which `mergeProps` cannot do — it skips
|
|
123
|
+
* `undefined` so an absent prop never clobbers a present one.
|
|
124
|
+
*/
|
|
125
|
+
declare function retractActivationProps(props: UnknownProps): UnknownProps;
|
|
126
|
+
|
|
127
|
+
export { type StateAttributeMapping, type UnknownProps, type UseButtonParams, type UseRenderParams, booleanAttribute, disabledAttribute, getStateAttributes, mergeProps, retractActivationProps, useButton, useControlled, useRender };
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,56 @@
|
|
|
1
1
|
import {
|
|
2
2
|
booleanAttribute,
|
|
3
|
+
disabledAttribute,
|
|
3
4
|
getStateAttributes,
|
|
4
5
|
mergeProps,
|
|
5
6
|
useControlled,
|
|
6
7
|
useRender
|
|
7
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-LU4ZQE3V.js";
|
|
9
|
+
|
|
10
|
+
// src/useButton.ts
|
|
11
|
+
var ACTIVATION_HANDLERS = [
|
|
12
|
+
"onClick",
|
|
13
|
+
"onDoubleClick",
|
|
14
|
+
"onMouseDown",
|
|
15
|
+
"onMouseUp",
|
|
16
|
+
"onPointerDown",
|
|
17
|
+
"onPointerUp",
|
|
18
|
+
"onTouchStart",
|
|
19
|
+
"onTouchEnd",
|
|
20
|
+
"onKeyDown",
|
|
21
|
+
"onKeyUp",
|
|
22
|
+
"onKeyPress"
|
|
23
|
+
];
|
|
24
|
+
function useButton({ disabled = false, native, props = {} }) {
|
|
25
|
+
if (!disabled) return props;
|
|
26
|
+
if (native) return { ...props, disabled: true, "data-disabled": "" };
|
|
27
|
+
const sanitised = {};
|
|
28
|
+
for (const key of Object.keys(props)) {
|
|
29
|
+
if (key === "href" || ACTIVATION_HANDLERS.includes(key)) continue;
|
|
30
|
+
sanitised[key] = props[key];
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
...sanitised,
|
|
34
|
+
"aria-disabled": true,
|
|
35
|
+
"data-disabled": "",
|
|
36
|
+
// Mirrors a native disabled button, which is not focusable.
|
|
37
|
+
tabIndex: -1
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function retractActivationProps(props) {
|
|
41
|
+
const overrides = {};
|
|
42
|
+
for (const key of Object.keys(props)) {
|
|
43
|
+
if (key === "href" || ACTIVATION_HANDLERS.includes(key)) overrides[key] = void 0;
|
|
44
|
+
}
|
|
45
|
+
return overrides;
|
|
46
|
+
}
|
|
8
47
|
export {
|
|
9
48
|
booleanAttribute,
|
|
49
|
+
disabledAttribute,
|
|
10
50
|
getStateAttributes,
|
|
11
51
|
mergeProps,
|
|
52
|
+
retractActivationProps,
|
|
53
|
+
useButton,
|
|
12
54
|
useControlled,
|
|
13
55
|
useRender
|
|
14
56
|
};
|
package/dist/switch/index.cjs
CHANGED
|
@@ -141,6 +141,7 @@ function booleanAttribute(whenTrue, whenFalse) {
|
|
|
141
141
|
return whenFalse ? { [whenFalse]: "" } : null;
|
|
142
142
|
};
|
|
143
143
|
}
|
|
144
|
+
var disabledAttribute = booleanAttribute("data-disabled");
|
|
144
145
|
|
|
145
146
|
// src/core/useRender.ts
|
|
146
147
|
function useRender({
|
|
@@ -172,7 +173,7 @@ function useSwitchRootContext() {
|
|
|
172
173
|
// src/switch/stateAttributes.ts
|
|
173
174
|
var switchStateAttributes = {
|
|
174
175
|
checked: booleanAttribute("data-checked", "data-unchecked"),
|
|
175
|
-
disabled:
|
|
176
|
+
disabled: disabledAttribute
|
|
176
177
|
};
|
|
177
178
|
|
|
178
179
|
// src/switch/root/SwitchRoot.tsx
|
package/dist/switch/index.d.cts
CHANGED
|
@@ -2,7 +2,13 @@ import * as react from 'react';
|
|
|
2
2
|
import { ReactNode, ReactElement, Ref } from 'react';
|
|
3
3
|
|
|
4
4
|
interface SwitchRootProps {
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Controlled state. Provide `onCheckedChange` alongside it.
|
|
7
|
+
*
|
|
8
|
+
* Never `undefined` once mounted. The mode is latched at mount, so an `undefined`
|
|
9
|
+
* first render makes the switch uncontrolled for good and every value passed later
|
|
10
|
+
* is ignored. Coalesce at the call site — `checked={x ?? false}`.
|
|
11
|
+
*/
|
|
6
12
|
checked?: boolean;
|
|
7
13
|
/** Initial state when uncontrolled. Read once, at mount. */
|
|
8
14
|
defaultChecked?: boolean;
|
package/dist/switch/index.d.ts
CHANGED
|
@@ -2,7 +2,13 @@ import * as react from 'react';
|
|
|
2
2
|
import { ReactNode, ReactElement, Ref } from 'react';
|
|
3
3
|
|
|
4
4
|
interface SwitchRootProps {
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Controlled state. Provide `onCheckedChange` alongside it.
|
|
7
|
+
*
|
|
8
|
+
* Never `undefined` once mounted. The mode is latched at mount, so an `undefined`
|
|
9
|
+
* first render makes the switch uncontrolled for good and every value passed later
|
|
10
|
+
* is ignored. Coalesce at the call site — `checked={x ?? false}`.
|
|
11
|
+
*/
|
|
6
12
|
checked?: boolean;
|
|
7
13
|
/** Initial state when uncontrolled. Read once, at mount. */
|
|
8
14
|
defaultChecked?: boolean;
|
package/dist/switch/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
__export,
|
|
3
3
|
booleanAttribute,
|
|
4
|
+
disabledAttribute,
|
|
4
5
|
useControlled,
|
|
5
6
|
useRender
|
|
6
|
-
} from "../chunk-
|
|
7
|
+
} from "../chunk-LU4ZQE3V.js";
|
|
7
8
|
|
|
8
9
|
// src/switch/index.parts.ts
|
|
9
10
|
var index_parts_exports = {};
|
|
@@ -29,7 +30,7 @@ function useSwitchRootContext() {
|
|
|
29
30
|
// src/switch/stateAttributes.ts
|
|
30
31
|
var switchStateAttributes = {
|
|
31
32
|
checked: booleanAttribute("data-checked", "data-unchecked"),
|
|
32
|
-
disabled:
|
|
33
|
+
disabled: disabledAttribute
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
// src/switch/root/SwitchRoot.tsx
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arun-dev/headless",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Unstyled React behaviour primitives —
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
7
7
|
"repository": {
|