@arun-dev/headless 0.1.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/LICENSE +21 -0
- package/dist/index.cjs +161 -0
- package/dist/index.d.cts +99 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +130 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arun Tripathi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
booleanAttribute: () => booleanAttribute,
|
|
24
|
+
getStateAttributes: () => getStateAttributes,
|
|
25
|
+
mergeProps: () => mergeProps,
|
|
26
|
+
useControlled: () => useControlled,
|
|
27
|
+
useRender: () => useRender
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/core/mergeProps.ts
|
|
32
|
+
function isEventHandler(key) {
|
|
33
|
+
return key.length > 2 && key.startsWith("on") && key[2] === key[2]?.toUpperCase();
|
|
34
|
+
}
|
|
35
|
+
function mergeRefs(...refs) {
|
|
36
|
+
return (node) => {
|
|
37
|
+
const cleanups = refs.map((ref) => {
|
|
38
|
+
if (typeof ref === "function") {
|
|
39
|
+
const result = ref(node);
|
|
40
|
+
return typeof result === "function" ? result : () => ref(null);
|
|
41
|
+
}
|
|
42
|
+
if (ref && typeof ref === "object") {
|
|
43
|
+
ref.current = node;
|
|
44
|
+
return () => {
|
|
45
|
+
ref.current = null;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return void 0;
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
cleanups.forEach((cleanup) => {
|
|
52
|
+
if (typeof cleanup === "function") cleanup();
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function mergeProps(...objects) {
|
|
58
|
+
const merged = {};
|
|
59
|
+
for (const props of objects) {
|
|
60
|
+
if (!props) continue;
|
|
61
|
+
for (const key of Object.keys(props)) {
|
|
62
|
+
const value = props[key];
|
|
63
|
+
if (value === void 0) continue;
|
|
64
|
+
if (isEventHandler(key) && typeof value === "function") {
|
|
65
|
+
const existing = merged[key];
|
|
66
|
+
merged[key] = typeof existing === "function" ? (...args) => {
|
|
67
|
+
existing(...args);
|
|
68
|
+
value(...args);
|
|
69
|
+
} : value;
|
|
70
|
+
} else if (key === "className") {
|
|
71
|
+
merged.className = merged.className ? `${String(merged.className)} ${String(value)}` : value;
|
|
72
|
+
} else if (key === "style") {
|
|
73
|
+
merged.style = { ...merged.style, ...value };
|
|
74
|
+
} else if (key === "ref") {
|
|
75
|
+
merged.ref = merged.ref ? mergeRefs(merged.ref, value) : value;
|
|
76
|
+
} else {
|
|
77
|
+
merged[key] = value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return merged;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/core/useRender.ts
|
|
85
|
+
var import_react = require("react");
|
|
86
|
+
|
|
87
|
+
// src/core/stateAttributes.ts
|
|
88
|
+
function getStateAttributes(state, mapping) {
|
|
89
|
+
if (!mapping) return {};
|
|
90
|
+
const attributes = {};
|
|
91
|
+
for (const key of Object.keys(state)) {
|
|
92
|
+
const toAttributes = mapping[key];
|
|
93
|
+
if (!toAttributes) continue;
|
|
94
|
+
const result = toAttributes(state[key]);
|
|
95
|
+
if (result) Object.assign(attributes, result);
|
|
96
|
+
}
|
|
97
|
+
return attributes;
|
|
98
|
+
}
|
|
99
|
+
function booleanAttribute(whenTrue, whenFalse) {
|
|
100
|
+
return (value) => {
|
|
101
|
+
if (value) return { [whenTrue]: "" };
|
|
102
|
+
return whenFalse ? { [whenFalse]: "" } : null;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/core/useRender.ts
|
|
107
|
+
function useRender({
|
|
108
|
+
render,
|
|
109
|
+
defaultTagName,
|
|
110
|
+
props,
|
|
111
|
+
state,
|
|
112
|
+
stateAttributes
|
|
113
|
+
}) {
|
|
114
|
+
const attributes = state ? getStateAttributes(state, stateAttributes) : {};
|
|
115
|
+
const merged = mergeProps(attributes, ...props);
|
|
116
|
+
if ((0, import_react.isValidElement)(render)) {
|
|
117
|
+
return (0, import_react.cloneElement)(render, mergeProps(merged, render.props));
|
|
118
|
+
}
|
|
119
|
+
return (0, import_react.createElement)(defaultTagName, merged);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/core/useControlled.ts
|
|
123
|
+
var import_react2 = require("react");
|
|
124
|
+
function useControlled({
|
|
125
|
+
controlled,
|
|
126
|
+
default: defaultValue,
|
|
127
|
+
name = "Component",
|
|
128
|
+
state = "value"
|
|
129
|
+
}) {
|
|
130
|
+
const { current: isControlled } = (0, import_react2.useRef)(controlled !== void 0);
|
|
131
|
+
const [uncontrolled, setUncontrolled] = (0, import_react2.useState)(defaultValue);
|
|
132
|
+
const defaultRef = (0, import_react2.useRef)(defaultValue);
|
|
133
|
+
const value = isControlled ? controlled : uncontrolled;
|
|
134
|
+
if (process.env.NODE_ENV !== "production") {
|
|
135
|
+
if (isControlled !== (controlled !== void 0)) {
|
|
136
|
+
console.error(
|
|
137
|
+
`${name}: cannot switch between controlled and uncontrolled \`${state}\`. Decide which one this component is for the whole of its life.`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!isControlled && defaultRef.current !== defaultValue) {
|
|
141
|
+
console.error(
|
|
142
|
+
`${name}: cannot change the default \`${state}\` after mount. It is only read once, so later changes are silently ignored.`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const setValue = (0, import_react2.useCallback)(
|
|
147
|
+
(next) => {
|
|
148
|
+
if (!isControlled) setUncontrolled(next);
|
|
149
|
+
},
|
|
150
|
+
[isControlled]
|
|
151
|
+
);
|
|
152
|
+
return [value, setValue];
|
|
153
|
+
}
|
|
154
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
155
|
+
0 && (module.exports = {
|
|
156
|
+
booleanAttribute,
|
|
157
|
+
getStateAttributes,
|
|
158
|
+
mergeProps,
|
|
159
|
+
useControlled,
|
|
160
|
+
useRender
|
|
161
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prop merging for headless components.
|
|
5
|
+
*
|
|
6
|
+
* A component and its consumer both want to put things on the same element. Naive
|
|
7
|
+
* spreading lets whichever runs last silently destroy the other's behaviour, so each
|
|
8
|
+
* kind of prop is combined rather than replaced.
|
|
9
|
+
*/
|
|
10
|
+
type UnknownProps = Record<string, unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* Merge prop objects left to right. Later objects win for plain values, but:
|
|
13
|
+
* - event handlers are chained rather than replaced
|
|
14
|
+
* - className is concatenated
|
|
15
|
+
* - style is shallow-merged
|
|
16
|
+
* - refs are merged
|
|
17
|
+
*
|
|
18
|
+
* `undefined` values are skipped, so an absent key on a later object never
|
|
19
|
+
* clobbers a value set by an earlier one.
|
|
20
|
+
*/
|
|
21
|
+
declare function mergeProps(...objects: (UnknownProps | undefined)[]): UnknownProps;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Projects a component's state onto the DOM as `data-*` attributes.
|
|
25
|
+
*
|
|
26
|
+
* This is the contract between behaviour and styling. A headless component owns no
|
|
27
|
+
* class names, so the only way CSS can react to `checked` or `disabled` is for the
|
|
28
|
+
* state to be visible in the DOM:
|
|
29
|
+
*
|
|
30
|
+
* .switch[data-checked] { background: var(--switch-track-bg-checked); }
|
|
31
|
+
*
|
|
32
|
+
* Doing it through a declared mapping rather than by hand keeps the attributes
|
|
33
|
+
* consistent across components — Base UI's Checkbox emits eleven of them, which is
|
|
34
|
+
* not something anyone maintains by hand — and makes the state a component exposes
|
|
35
|
+
* a single readable object rather than scattered JSX.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Maps one state value to the attributes it should produce. Return `null` for
|
|
39
|
+
* "no attribute". A single value may produce several, and mutually exclusive
|
|
40
|
+
* attributes are how a third state stays addressable: with only `data-checked`,
|
|
41
|
+
* `:not([data-checked])` would match both unchecked *and* indeterminate.
|
|
42
|
+
*/
|
|
43
|
+
type StateAttributeMapping<State> = {
|
|
44
|
+
[Key in keyof State]?: (value: State[Key]) => Record<string, string> | null;
|
|
45
|
+
};
|
|
46
|
+
declare function getStateAttributes<State extends Record<string, unknown>>(state: State, mapping: StateAttributeMapping<State> | undefined): UnknownProps;
|
|
47
|
+
/**
|
|
48
|
+
* The common case: a boolean that emits one attribute when true and another when
|
|
49
|
+
* false, so both sides are addressable at equal specificity.
|
|
50
|
+
*
|
|
51
|
+
* checked: booleanAttribute('data-checked', 'data-unchecked')
|
|
52
|
+
*/
|
|
53
|
+
declare function booleanAttribute(whenTrue: string, whenFalse?: string): (value: unknown) => Record<string, string> | null;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves what a component part actually renders.
|
|
57
|
+
*
|
|
58
|
+
* Every part goes through here, which is what makes the whole library consistent:
|
|
59
|
+
* one place decides how props merge, how state reaches the DOM, and what element
|
|
60
|
+
* comes out. A part supplies its default element, its state, and the props it wants;
|
|
61
|
+
* the consumer can replace the element entirely via `render` without losing any of it.
|
|
62
|
+
*
|
|
63
|
+
* Currently uses no hooks. The `use` prefix matches the API it will grow into
|
|
64
|
+
* (memoised merging, so parts stop re-rendering their children needlessly) and keeps
|
|
65
|
+
* call sites stable when that lands.
|
|
66
|
+
*/
|
|
67
|
+
interface UseRenderParams<State extends Record<string, unknown>> {
|
|
68
|
+
/** Element to render instead of the default. Props, className and ref are merged onto it. */
|
|
69
|
+
render?: ReactElement | undefined;
|
|
70
|
+
/** Tag rendered when `render` is not supplied. */
|
|
71
|
+
defaultTagName: string;
|
|
72
|
+
/** Prop objects to merge, in precedence order (later wins). */
|
|
73
|
+
props: (UnknownProps | undefined)[];
|
|
74
|
+
/** The part's state, projected onto the DOM through `stateAttributes`. */
|
|
75
|
+
state?: State;
|
|
76
|
+
/** How each state field becomes `data-*` attributes. */
|
|
77
|
+
stateAttributes?: StateAttributeMapping<State>;
|
|
78
|
+
}
|
|
79
|
+
declare function useRender<State extends Record<string, unknown> = Record<string, never>>({ render, defaultTagName, props, state, stateAttributes, }: UseRenderParams<State>): ReactElement;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Supports both controlled and uncontrolled use of a single value.
|
|
83
|
+
*
|
|
84
|
+
* The mode is decided once, at mount, and never re-evaluated. Recomputing it per
|
|
85
|
+
* render would let a parent that briefly passes `undefined` flip the component to
|
|
86
|
+
* uncontrolled and hand it stale internal state — a bug that is very hard to see.
|
|
87
|
+
*
|
|
88
|
+
* In controlled mode the setter deliberately does not write state: the parent owns
|
|
89
|
+
* the value. Callers still fire their `onChange` in both modes, so the component
|
|
90
|
+
* notifies either way.
|
|
91
|
+
*/
|
|
92
|
+
declare function useControlled<T>({ controlled, default: defaultValue, name, state, }: {
|
|
93
|
+
controlled: T | undefined;
|
|
94
|
+
default: T;
|
|
95
|
+
name?: string;
|
|
96
|
+
state?: string;
|
|
97
|
+
}): [T, (next: T) => void];
|
|
98
|
+
|
|
99
|
+
export { type StateAttributeMapping, type UnknownProps, type UseRenderParams, booleanAttribute, getStateAttributes, mergeProps, useControlled, useRender };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prop merging for headless components.
|
|
5
|
+
*
|
|
6
|
+
* A component and its consumer both want to put things on the same element. Naive
|
|
7
|
+
* spreading lets whichever runs last silently destroy the other's behaviour, so each
|
|
8
|
+
* kind of prop is combined rather than replaced.
|
|
9
|
+
*/
|
|
10
|
+
type UnknownProps = Record<string, unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* Merge prop objects left to right. Later objects win for plain values, but:
|
|
13
|
+
* - event handlers are chained rather than replaced
|
|
14
|
+
* - className is concatenated
|
|
15
|
+
* - style is shallow-merged
|
|
16
|
+
* - refs are merged
|
|
17
|
+
*
|
|
18
|
+
* `undefined` values are skipped, so an absent key on a later object never
|
|
19
|
+
* clobbers a value set by an earlier one.
|
|
20
|
+
*/
|
|
21
|
+
declare function mergeProps(...objects: (UnknownProps | undefined)[]): UnknownProps;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Projects a component's state onto the DOM as `data-*` attributes.
|
|
25
|
+
*
|
|
26
|
+
* This is the contract between behaviour and styling. A headless component owns no
|
|
27
|
+
* class names, so the only way CSS can react to `checked` or `disabled` is for the
|
|
28
|
+
* state to be visible in the DOM:
|
|
29
|
+
*
|
|
30
|
+
* .switch[data-checked] { background: var(--switch-track-bg-checked); }
|
|
31
|
+
*
|
|
32
|
+
* Doing it through a declared mapping rather than by hand keeps the attributes
|
|
33
|
+
* consistent across components — Base UI's Checkbox emits eleven of them, which is
|
|
34
|
+
* not something anyone maintains by hand — and makes the state a component exposes
|
|
35
|
+
* a single readable object rather than scattered JSX.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Maps one state value to the attributes it should produce. Return `null` for
|
|
39
|
+
* "no attribute". A single value may produce several, and mutually exclusive
|
|
40
|
+
* attributes are how a third state stays addressable: with only `data-checked`,
|
|
41
|
+
* `:not([data-checked])` would match both unchecked *and* indeterminate.
|
|
42
|
+
*/
|
|
43
|
+
type StateAttributeMapping<State> = {
|
|
44
|
+
[Key in keyof State]?: (value: State[Key]) => Record<string, string> | null;
|
|
45
|
+
};
|
|
46
|
+
declare function getStateAttributes<State extends Record<string, unknown>>(state: State, mapping: StateAttributeMapping<State> | undefined): UnknownProps;
|
|
47
|
+
/**
|
|
48
|
+
* The common case: a boolean that emits one attribute when true and another when
|
|
49
|
+
* false, so both sides are addressable at equal specificity.
|
|
50
|
+
*
|
|
51
|
+
* checked: booleanAttribute('data-checked', 'data-unchecked')
|
|
52
|
+
*/
|
|
53
|
+
declare function booleanAttribute(whenTrue: string, whenFalse?: string): (value: unknown) => Record<string, string> | null;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves what a component part actually renders.
|
|
57
|
+
*
|
|
58
|
+
* Every part goes through here, which is what makes the whole library consistent:
|
|
59
|
+
* one place decides how props merge, how state reaches the DOM, and what element
|
|
60
|
+
* comes out. A part supplies its default element, its state, and the props it wants;
|
|
61
|
+
* the consumer can replace the element entirely via `render` without losing any of it.
|
|
62
|
+
*
|
|
63
|
+
* Currently uses no hooks. The `use` prefix matches the API it will grow into
|
|
64
|
+
* (memoised merging, so parts stop re-rendering their children needlessly) and keeps
|
|
65
|
+
* call sites stable when that lands.
|
|
66
|
+
*/
|
|
67
|
+
interface UseRenderParams<State extends Record<string, unknown>> {
|
|
68
|
+
/** Element to render instead of the default. Props, className and ref are merged onto it. */
|
|
69
|
+
render?: ReactElement | undefined;
|
|
70
|
+
/** Tag rendered when `render` is not supplied. */
|
|
71
|
+
defaultTagName: string;
|
|
72
|
+
/** Prop objects to merge, in precedence order (later wins). */
|
|
73
|
+
props: (UnknownProps | undefined)[];
|
|
74
|
+
/** The part's state, projected onto the DOM through `stateAttributes`. */
|
|
75
|
+
state?: State;
|
|
76
|
+
/** How each state field becomes `data-*` attributes. */
|
|
77
|
+
stateAttributes?: StateAttributeMapping<State>;
|
|
78
|
+
}
|
|
79
|
+
declare function useRender<State extends Record<string, unknown> = Record<string, never>>({ render, defaultTagName, props, state, stateAttributes, }: UseRenderParams<State>): ReactElement;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Supports both controlled and uncontrolled use of a single value.
|
|
83
|
+
*
|
|
84
|
+
* The mode is decided once, at mount, and never re-evaluated. Recomputing it per
|
|
85
|
+
* render would let a parent that briefly passes `undefined` flip the component to
|
|
86
|
+
* uncontrolled and hand it stale internal state — a bug that is very hard to see.
|
|
87
|
+
*
|
|
88
|
+
* In controlled mode the setter deliberately does not write state: the parent owns
|
|
89
|
+
* the value. Callers still fire their `onChange` in both modes, so the component
|
|
90
|
+
* notifies either way.
|
|
91
|
+
*/
|
|
92
|
+
declare function useControlled<T>({ controlled, default: defaultValue, name, state, }: {
|
|
93
|
+
controlled: T | undefined;
|
|
94
|
+
default: T;
|
|
95
|
+
name?: string;
|
|
96
|
+
state?: string;
|
|
97
|
+
}): [T, (next: T) => void];
|
|
98
|
+
|
|
99
|
+
export { type StateAttributeMapping, type UnknownProps, type UseRenderParams, booleanAttribute, getStateAttributes, mergeProps, useControlled, useRender };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// src/core/mergeProps.ts
|
|
2
|
+
function isEventHandler(key) {
|
|
3
|
+
return key.length > 2 && key.startsWith("on") && key[2] === key[2]?.toUpperCase();
|
|
4
|
+
}
|
|
5
|
+
function mergeRefs(...refs) {
|
|
6
|
+
return (node) => {
|
|
7
|
+
const cleanups = refs.map((ref) => {
|
|
8
|
+
if (typeof ref === "function") {
|
|
9
|
+
const result = ref(node);
|
|
10
|
+
return typeof result === "function" ? result : () => ref(null);
|
|
11
|
+
}
|
|
12
|
+
if (ref && typeof ref === "object") {
|
|
13
|
+
ref.current = node;
|
|
14
|
+
return () => {
|
|
15
|
+
ref.current = null;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return void 0;
|
|
19
|
+
});
|
|
20
|
+
return () => {
|
|
21
|
+
cleanups.forEach((cleanup) => {
|
|
22
|
+
if (typeof cleanup === "function") cleanup();
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function mergeProps(...objects) {
|
|
28
|
+
const merged = {};
|
|
29
|
+
for (const props of objects) {
|
|
30
|
+
if (!props) continue;
|
|
31
|
+
for (const key of Object.keys(props)) {
|
|
32
|
+
const value = props[key];
|
|
33
|
+
if (value === void 0) continue;
|
|
34
|
+
if (isEventHandler(key) && typeof value === "function") {
|
|
35
|
+
const existing = merged[key];
|
|
36
|
+
merged[key] = typeof existing === "function" ? (...args) => {
|
|
37
|
+
existing(...args);
|
|
38
|
+
value(...args);
|
|
39
|
+
} : value;
|
|
40
|
+
} else if (key === "className") {
|
|
41
|
+
merged.className = merged.className ? `${String(merged.className)} ${String(value)}` : value;
|
|
42
|
+
} else if (key === "style") {
|
|
43
|
+
merged.style = { ...merged.style, ...value };
|
|
44
|
+
} else if (key === "ref") {
|
|
45
|
+
merged.ref = merged.ref ? mergeRefs(merged.ref, value) : value;
|
|
46
|
+
} else {
|
|
47
|
+
merged[key] = value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return merged;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/core/useRender.ts
|
|
55
|
+
import { cloneElement, createElement, isValidElement } from "react";
|
|
56
|
+
|
|
57
|
+
// src/core/stateAttributes.ts
|
|
58
|
+
function getStateAttributes(state, mapping) {
|
|
59
|
+
if (!mapping) return {};
|
|
60
|
+
const attributes = {};
|
|
61
|
+
for (const key of Object.keys(state)) {
|
|
62
|
+
const toAttributes = mapping[key];
|
|
63
|
+
if (!toAttributes) continue;
|
|
64
|
+
const result = toAttributes(state[key]);
|
|
65
|
+
if (result) Object.assign(attributes, result);
|
|
66
|
+
}
|
|
67
|
+
return attributes;
|
|
68
|
+
}
|
|
69
|
+
function booleanAttribute(whenTrue, whenFalse) {
|
|
70
|
+
return (value) => {
|
|
71
|
+
if (value) return { [whenTrue]: "" };
|
|
72
|
+
return whenFalse ? { [whenFalse]: "" } : null;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/core/useRender.ts
|
|
77
|
+
function useRender({
|
|
78
|
+
render,
|
|
79
|
+
defaultTagName,
|
|
80
|
+
props,
|
|
81
|
+
state,
|
|
82
|
+
stateAttributes
|
|
83
|
+
}) {
|
|
84
|
+
const attributes = state ? getStateAttributes(state, stateAttributes) : {};
|
|
85
|
+
const merged = mergeProps(attributes, ...props);
|
|
86
|
+
if (isValidElement(render)) {
|
|
87
|
+
return cloneElement(render, mergeProps(merged, render.props));
|
|
88
|
+
}
|
|
89
|
+
return createElement(defaultTagName, merged);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/core/useControlled.ts
|
|
93
|
+
import { useCallback, useRef, useState } from "react";
|
|
94
|
+
function useControlled({
|
|
95
|
+
controlled,
|
|
96
|
+
default: defaultValue,
|
|
97
|
+
name = "Component",
|
|
98
|
+
state = "value"
|
|
99
|
+
}) {
|
|
100
|
+
const { current: isControlled } = useRef(controlled !== void 0);
|
|
101
|
+
const [uncontrolled, setUncontrolled] = useState(defaultValue);
|
|
102
|
+
const defaultRef = useRef(defaultValue);
|
|
103
|
+
const value = isControlled ? controlled : uncontrolled;
|
|
104
|
+
if (process.env.NODE_ENV !== "production") {
|
|
105
|
+
if (isControlled !== (controlled !== void 0)) {
|
|
106
|
+
console.error(
|
|
107
|
+
`${name}: cannot switch between controlled and uncontrolled \`${state}\`. Decide which one this component is for the whole of its life.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (!isControlled && defaultRef.current !== defaultValue) {
|
|
111
|
+
console.error(
|
|
112
|
+
`${name}: cannot change the default \`${state}\` after mount. It is only read once, so later changes are silently ignored.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const setValue = useCallback(
|
|
117
|
+
(next) => {
|
|
118
|
+
if (!isControlled) setUncontrolled(next);
|
|
119
|
+
},
|
|
120
|
+
[isControlled]
|
|
121
|
+
);
|
|
122
|
+
return [value, setValue];
|
|
123
|
+
}
|
|
124
|
+
export {
|
|
125
|
+
booleanAttribute,
|
|
126
|
+
getStateAttributes,
|
|
127
|
+
mergeProps,
|
|
128
|
+
useControlled,
|
|
129
|
+
useRender
|
|
130
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arun-dev/headless",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Unstyled React behaviour primitives — the render engine and state plumbing @arun-dev/ui is built on. Ships no CSS and no class names.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/arun9483/arun-design-system.git",
|
|
10
|
+
"directory": "packages/headless"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react",
|
|
14
|
+
"headless",
|
|
15
|
+
"unstyled",
|
|
16
|
+
"design-system",
|
|
17
|
+
"primitives"
|
|
18
|
+
],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"main": "./dist/index.cjs",
|
|
21
|
+
"module": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"import": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"require": {
|
|
33
|
+
"types": "./dist/index.d.cts",
|
|
34
|
+
"default": "./dist/index.cjs"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@types/react": ">=19.0.0",
|
|
43
|
+
"react": ">=19.0.0",
|
|
44
|
+
"react-dom": ">=19.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@types/react": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@testing-library/jest-dom": "6.9.1",
|
|
53
|
+
"@testing-library/react": "16.3.0",
|
|
54
|
+
"@types/react": "19.2.18",
|
|
55
|
+
"@types/react-dom": "19.2.5",
|
|
56
|
+
"eslint": "9.39.4",
|
|
57
|
+
"jsdom": "27.0.0",
|
|
58
|
+
"react": "19.2.8",
|
|
59
|
+
"react-dom": "19.2.8",
|
|
60
|
+
"tsup": "8.5.1",
|
|
61
|
+
"typescript": "5.9.3",
|
|
62
|
+
"vitest": "3.2.6",
|
|
63
|
+
"@arun-dev/config": "0.0.0"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "tsup",
|
|
67
|
+
"clean": "rm -rf dist .turbo",
|
|
68
|
+
"typecheck": "tsc --noEmit",
|
|
69
|
+
"lint": "eslint .",
|
|
70
|
+
"lint:fix": "eslint . --fix",
|
|
71
|
+
"test": "pnpm run test:unit",
|
|
72
|
+
"test:unit": "vitest run --passWithNoTests --config vitest.unit.config.ts"
|
|
73
|
+
}
|
|
74
|
+
}
|