@arun-dev/headless 0.4.0 → 1.0.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 CHANGED
@@ -28,8 +28,20 @@ import { Switch } from '@arun-dev/headless/switch';
28
28
 
29
29
  | Component | Parts | Props |
30
30
  | --------- | ----------------------- | --------------------------------------------------------------------------- |
31
+ | `Button` | — | `disabled`, `nativeButton` |
31
32
  | `Switch` | `Switch.Root`, `.Thumb` | `checked`, `defaultChecked`, `onCheckedChange`, `disabled`, `name`, `value` |
32
33
 
34
+ `Button` exists for the moment `render` points at something that is not a `<button>`. It then
35
+ supplies what the platform stops giving you — focus, `Enter` and `Space` activation, and a
36
+ `disabled` state that actually disables. It takes no `href`: a control that navigates should be an
37
+ anchor, so pass one in and keep middle-click, cmd-click and "link" in assistive technology.
38
+
39
+ ```tsx
40
+ import { Button } from '@arun-dev/headless/button';
41
+
42
+ <Button render={<a href="/docs" target="_blank" rel="noreferrer" />}>Docs</Button>;
43
+ ```
44
+
33
45
  `Switch.Root` renders a native `<button>`, so focus, `Space`, `Enter` and disabled semantics come
34
46
  from the platform. It carries `role="switch"` and `aria-checked`, but **no accessible name** —
35
47
  wrap it in a `<label>` or pass `aria-label`. A headless component should not guess at your copy.
@@ -105,3 +117,9 @@ import {
105
117
  | `mergeProps` | Merges prop objects: handlers chain, `className` concatenates, refs merge |
106
118
  | `getStateAttributes` | Projects a state object onto `data-*` attributes via a declared mapping |
107
119
  | `booleanAttribute` | The common mapping — one attribute when true, another when false |
120
+ | `disabledAttribute` | The shared spelling of `data-disabled`, so every component agrees on it |
121
+ | `ComponentEvent` | Type for a handler that can call `preventComponentHandler()` |
122
+
123
+ Everything this library uses to implement its own components stays private, so it can
124
+ change without breaking anyone. The public surface is the table above plus the
125
+ components themselves.
@@ -0,0 +1,248 @@
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/button/index.ts
21
+ var button_exports = {};
22
+ __export(button_exports, {
23
+ Button: () => Button
24
+ });
25
+ module.exports = __toCommonJS(button_exports);
26
+
27
+ // src/button/Button.tsx
28
+ var import_react3 = require("react");
29
+
30
+ // src/core/useRender.ts
31
+ var import_react = require("react");
32
+
33
+ // src/core/mergeProps.ts
34
+ function isEventHandler(key) {
35
+ return key.length > 2 && key.startsWith("on") && key[2] === key[2]?.toUpperCase();
36
+ }
37
+ function mergeRefs(...refs) {
38
+ return (node) => {
39
+ const cleanups = refs.map((ref) => {
40
+ if (typeof ref === "function") {
41
+ const result = ref(node);
42
+ return typeof result === "function" ? result : () => ref(null);
43
+ }
44
+ if (ref && typeof ref === "object") {
45
+ ref.current = node;
46
+ return () => {
47
+ ref.current = null;
48
+ };
49
+ }
50
+ return void 0;
51
+ });
52
+ return () => {
53
+ cleanups.forEach((cleanup) => {
54
+ if (typeof cleanup === "function") cleanup();
55
+ });
56
+ };
57
+ };
58
+ }
59
+ function isSyntheticEvent(event) {
60
+ return typeof event === "object" && event !== null && "nativeEvent" in event;
61
+ }
62
+ function makeEventPreventable(event) {
63
+ const target = event;
64
+ target.preventComponentHandler = () => {
65
+ target.componentHandlerPrevented = true;
66
+ };
67
+ }
68
+ function isComponentHandlerPrevented(event) {
69
+ return typeof event === "object" && event !== null && event.componentHandlerPrevented === true;
70
+ }
71
+ function mergeProps(...objects) {
72
+ const merged = {};
73
+ for (const props of objects) {
74
+ if (!props) continue;
75
+ for (const key of Object.keys(props)) {
76
+ const value = props[key];
77
+ if (value === void 0) continue;
78
+ if (isEventHandler(key) && typeof value === "function") {
79
+ const earlier = merged[key];
80
+ merged[key] = typeof earlier === "function" ? (...args) => {
81
+ const event = args[0];
82
+ if (isSyntheticEvent(event)) makeEventPreventable(event);
83
+ value(...args);
84
+ if (isComponentHandlerPrevented(event)) return;
85
+ earlier(...args);
86
+ } : value;
87
+ } else if (key === "className") {
88
+ merged.className = merged.className ? `${String(merged.className)} ${String(value)}` : value;
89
+ } else if (key === "style") {
90
+ merged.style = { ...merged.style, ...value };
91
+ } else if (key === "ref") {
92
+ merged.ref = merged.ref ? mergeRefs(merged.ref, value) : value;
93
+ } else {
94
+ merged[key] = value;
95
+ }
96
+ }
97
+ }
98
+ return merged;
99
+ }
100
+
101
+ // src/core/stateAttributes.ts
102
+ function getStateAttributes(state, mapping) {
103
+ if (!mapping) return {};
104
+ const attributes = {};
105
+ for (const key of Object.keys(state)) {
106
+ const toAttributes = mapping[key];
107
+ if (!toAttributes) continue;
108
+ const result = toAttributes(state[key]);
109
+ if (result) Object.assign(attributes, result);
110
+ }
111
+ return attributes;
112
+ }
113
+ function booleanAttribute(whenTrue, whenFalse) {
114
+ return (value) => {
115
+ if (value) return { [whenTrue]: "" };
116
+ return whenFalse ? { [whenFalse]: "" } : null;
117
+ };
118
+ }
119
+ var disabledAttribute = booleanAttribute("data-disabled");
120
+
121
+ // src/core/useRender.ts
122
+ function useRender({
123
+ render,
124
+ defaultTagName,
125
+ props,
126
+ state,
127
+ stateAttributes
128
+ }) {
129
+ const attributes = state ? getStateAttributes(state, stateAttributes) : {};
130
+ const merged = mergeProps(attributes, ...props);
131
+ if ((0, import_react.isValidElement)(render)) {
132
+ return (0, import_react.cloneElement)(render, mergeProps(merged, render.props));
133
+ }
134
+ return (0, import_react.createElement)(defaultTagName, merged);
135
+ }
136
+
137
+ // src/useButton.ts
138
+ var import_react2 = require("react");
139
+ var ACTIVATION_HANDLERS = [
140
+ "onClick",
141
+ "onDoubleClick",
142
+ "onMouseDown",
143
+ "onMouseUp",
144
+ "onPointerDown",
145
+ "onPointerUp",
146
+ "onTouchStart",
147
+ "onTouchEnd",
148
+ "onKeyDown",
149
+ "onKeyUp",
150
+ "onKeyPress"
151
+ ];
152
+ function activationHandlers() {
153
+ return {
154
+ onKeyDown(event) {
155
+ if (event.target !== event.currentTarget || event.defaultPrevented) return;
156
+ if (event.key === " ") {
157
+ event.preventDefault();
158
+ return;
159
+ }
160
+ if (event.key === "Enter") {
161
+ event.preventDefault();
162
+ click(event.currentTarget);
163
+ }
164
+ },
165
+ onKeyUp(event) {
166
+ if (event.target !== event.currentTarget || event.defaultPrevented) return;
167
+ if (event.key === " ") click(event.currentTarget);
168
+ }
169
+ };
170
+ }
171
+ function click(element) {
172
+ if (element && typeof element.click === "function") {
173
+ element.click();
174
+ }
175
+ }
176
+ function useButton({
177
+ disabled = false,
178
+ native,
179
+ props = {}
180
+ }) {
181
+ const elementRef = (0, import_react2.useRef)(null);
182
+ (0, import_react2.useEffect)(() => {
183
+ if (process.env.NODE_ENV === "production") return;
184
+ const element = elementRef.current;
185
+ if (!element) return;
186
+ const isButtonTag = element.tagName === "BUTTON";
187
+ if (native && !isButtonTag) {
188
+ console.error(
189
+ `A component expected a native <button> but rendered <${element.tagName.toLowerCase()}>. Focus, keyboard activation and \`disabled\` will not behave natively.`
190
+ );
191
+ } else if (!native && isButtonTag) {
192
+ console.error(
193
+ "A component rendered a native <button> while treating it as a non-native element, so it carries synthesised attributes it does not need."
194
+ );
195
+ }
196
+ }, [native]);
197
+ return { props: buttonProps({ disabled, native, props }), ref: elementRef };
198
+ }
199
+ function buttonProps({ disabled, native, props }) {
200
+ if (!disabled) {
201
+ return native ? mergeProps({ type: "button" }, props) : mergeProps({ tabIndex: 0 }, activationHandlers(), props);
202
+ }
203
+ if (native) return mergeProps({ type: "button" }, props, { disabled: true, "data-disabled": "" });
204
+ const sanitised = {};
205
+ for (const key of Object.keys(props)) {
206
+ if (key === "href" || ACTIVATION_HANDLERS.includes(key)) continue;
207
+ sanitised[key] = props[key];
208
+ }
209
+ return {
210
+ ...sanitised,
211
+ "aria-disabled": true,
212
+ "data-disabled": "",
213
+ // Mirrors a native disabled button, which is not focusable.
214
+ tabIndex: -1
215
+ };
216
+ }
217
+ function retractActivationProps(props) {
218
+ const overrides = {};
219
+ for (const key of Object.keys(props)) {
220
+ if (key === "href" || ACTIVATION_HANDLERS.includes(key)) overrides[key] = void 0;
221
+ }
222
+ return overrides;
223
+ }
224
+
225
+ // src/button/Button.tsx
226
+ function Button({
227
+ disabled = false,
228
+ nativeButton,
229
+ className,
230
+ children,
231
+ render,
232
+ ...rest
233
+ }) {
234
+ const native = nativeButton ?? (render === void 0 || render.type === "button");
235
+ const isAnchor = render !== void 0 && render.type === "a";
236
+ const role = native || isAnchor ? void 0 : "button";
237
+ const { props: elementProps, ref: buttonRef } = useButton({ disabled, native, props: rest });
238
+ const safeRender = disabled && !native && render !== void 0 ? (0, import_react3.cloneElement)(render, retractActivationProps(render.props)) : render;
239
+ return useRender({
240
+ render: safeRender,
241
+ defaultTagName: "button",
242
+ props: [{ role, className, children }, elementProps, { ref: buttonRef }]
243
+ });
244
+ }
245
+ // Annotate the CommonJS export names for ESM import in node:
246
+ 0 && (module.exports = {
247
+ Button
248
+ });
@@ -0,0 +1,44 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ReactElement, Ref } from 'react';
3
+
4
+ interface ButtonProps {
5
+ /**
6
+ * Prevents activation. On a native `<button>` the platform handles it; on any other
7
+ * element the state is synthesised — `aria-disabled`, removal from the tab order,
8
+ * no activation, and a navigation target dropped.
9
+ */
10
+ disabled?: boolean;
11
+ /**
12
+ * Whether the rendered element is a native `<button>`.
13
+ *
14
+ * Inferred from `render`, which is right for an element literal. Set it explicitly
15
+ * when rendering a *component* — `render={<MyButton />}` cannot be inspected, and
16
+ * gets treated as non-native. A mismatch logs a development warning.
17
+ */
18
+ nativeButton?: boolean;
19
+ className?: string;
20
+ children?: ReactNode;
21
+ /**
22
+ * Element to render instead of the default `<button>`. Props, className, event
23
+ * handlers and ref are merged onto it.
24
+ */
25
+ render?: ReactElement;
26
+ ref?: Ref<HTMLElement>;
27
+ }
28
+ /**
29
+ * A button — behaviour only, no styling.
30
+ *
31
+ * A native `<button>` needs almost none of this; the platform supplies focus, `Enter`
32
+ * and `Space` activation, and `disabled`. The component earns its place the moment
33
+ * `render` points somewhere else, which is exactly when all of that stops holding.
34
+ *
35
+ * It has no `href`. A control that navigates should be an `<a>`, so that middle-click,
36
+ * cmd-click, the status bar and "link" in assistive technology all work — pass one in:
37
+ *
38
+ * <Button render={<a href="/docs" target="_blank" rel="noreferrer" />}>Docs</Button>
39
+ *
40
+ * Emits `data-disabled` so one selector styles a disabled control whatever the element.
41
+ */
42
+ declare function Button({ disabled, nativeButton, className, children, render, ...rest }: ButtonProps & Record<string, unknown>): ReactElement<unknown, string | react.JSXElementConstructor<any>>;
43
+
44
+ export { Button, type ButtonProps };
@@ -0,0 +1,44 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ReactElement, Ref } from 'react';
3
+
4
+ interface ButtonProps {
5
+ /**
6
+ * Prevents activation. On a native `<button>` the platform handles it; on any other
7
+ * element the state is synthesised — `aria-disabled`, removal from the tab order,
8
+ * no activation, and a navigation target dropped.
9
+ */
10
+ disabled?: boolean;
11
+ /**
12
+ * Whether the rendered element is a native `<button>`.
13
+ *
14
+ * Inferred from `render`, which is right for an element literal. Set it explicitly
15
+ * when rendering a *component* — `render={<MyButton />}` cannot be inspected, and
16
+ * gets treated as non-native. A mismatch logs a development warning.
17
+ */
18
+ nativeButton?: boolean;
19
+ className?: string;
20
+ children?: ReactNode;
21
+ /**
22
+ * Element to render instead of the default `<button>`. Props, className, event
23
+ * handlers and ref are merged onto it.
24
+ */
25
+ render?: ReactElement;
26
+ ref?: Ref<HTMLElement>;
27
+ }
28
+ /**
29
+ * A button — behaviour only, no styling.
30
+ *
31
+ * A native `<button>` needs almost none of this; the platform supplies focus, `Enter`
32
+ * and `Space` activation, and `disabled`. The component earns its place the moment
33
+ * `render` points somewhere else, which is exactly when all of that stops holding.
34
+ *
35
+ * It has no `href`. A control that navigates should be an `<a>`, so that middle-click,
36
+ * cmd-click, the status bar and "link" in assistive technology all work — pass one in:
37
+ *
38
+ * <Button render={<a href="/docs" target="_blank" rel="noreferrer" />}>Docs</Button>
39
+ *
40
+ * Emits `data-disabled` so one selector styles a disabled control whatever the element.
41
+ */
42
+ declare function Button({ disabled, nativeButton, className, children, render, ...rest }: ButtonProps & Record<string, unknown>): ReactElement<unknown, string | react.JSXElementConstructor<any>>;
43
+
44
+ export { Button, type ButtonProps };
@@ -0,0 +1,32 @@
1
+ import {
2
+ retractActivationProps,
3
+ useButton
4
+ } from "../chunk-2XSRAJHB.js";
5
+ import {
6
+ useRender
7
+ } from "../chunk-HWG3GMTO.js";
8
+
9
+ // src/button/Button.tsx
10
+ import { cloneElement } from "react";
11
+ function Button({
12
+ disabled = false,
13
+ nativeButton,
14
+ className,
15
+ children,
16
+ render,
17
+ ...rest
18
+ }) {
19
+ const native = nativeButton ?? (render === void 0 || render.type === "button");
20
+ const isAnchor = render !== void 0 && render.type === "a";
21
+ const role = native || isAnchor ? void 0 : "button";
22
+ const { props: elementProps, ref: buttonRef } = useButton({ disabled, native, props: rest });
23
+ const safeRender = disabled && !native && render !== void 0 ? cloneElement(render, retractActivationProps(render.props)) : render;
24
+ return useRender({
25
+ render: safeRender,
26
+ defaultTagName: "button",
27
+ props: [{ role, className, children }, elementProps, { ref: buttonRef }]
28
+ });
29
+ }
30
+ export {
31
+ Button
32
+ };
@@ -0,0 +1,96 @@
1
+ import {
2
+ mergeProps
3
+ } from "./chunk-HWG3GMTO.js";
4
+
5
+ // src/useButton.ts
6
+ import { useEffect, useRef } from "react";
7
+ var ACTIVATION_HANDLERS = [
8
+ "onClick",
9
+ "onDoubleClick",
10
+ "onMouseDown",
11
+ "onMouseUp",
12
+ "onPointerDown",
13
+ "onPointerUp",
14
+ "onTouchStart",
15
+ "onTouchEnd",
16
+ "onKeyDown",
17
+ "onKeyUp",
18
+ "onKeyPress"
19
+ ];
20
+ function activationHandlers() {
21
+ return {
22
+ onKeyDown(event) {
23
+ if (event.target !== event.currentTarget || event.defaultPrevented) return;
24
+ if (event.key === " ") {
25
+ event.preventDefault();
26
+ return;
27
+ }
28
+ if (event.key === "Enter") {
29
+ event.preventDefault();
30
+ click(event.currentTarget);
31
+ }
32
+ },
33
+ onKeyUp(event) {
34
+ if (event.target !== event.currentTarget || event.defaultPrevented) return;
35
+ if (event.key === " ") click(event.currentTarget);
36
+ }
37
+ };
38
+ }
39
+ function click(element) {
40
+ if (element && typeof element.click === "function") {
41
+ element.click();
42
+ }
43
+ }
44
+ function useButton({
45
+ disabled = false,
46
+ native,
47
+ props = {}
48
+ }) {
49
+ const elementRef = useRef(null);
50
+ useEffect(() => {
51
+ if (process.env.NODE_ENV === "production") return;
52
+ const element = elementRef.current;
53
+ if (!element) return;
54
+ const isButtonTag = element.tagName === "BUTTON";
55
+ if (native && !isButtonTag) {
56
+ console.error(
57
+ `A component expected a native <button> but rendered <${element.tagName.toLowerCase()}>. Focus, keyboard activation and \`disabled\` will not behave natively.`
58
+ );
59
+ } else if (!native && isButtonTag) {
60
+ console.error(
61
+ "A component rendered a native <button> while treating it as a non-native element, so it carries synthesised attributes it does not need."
62
+ );
63
+ }
64
+ }, [native]);
65
+ return { props: buttonProps({ disabled, native, props }), ref: elementRef };
66
+ }
67
+ function buttonProps({ disabled, native, props }) {
68
+ if (!disabled) {
69
+ return native ? mergeProps({ type: "button" }, props) : mergeProps({ tabIndex: 0 }, activationHandlers(), props);
70
+ }
71
+ if (native) return mergeProps({ type: "button" }, props, { disabled: true, "data-disabled": "" });
72
+ const sanitised = {};
73
+ for (const key of Object.keys(props)) {
74
+ if (key === "href" || ACTIVATION_HANDLERS.includes(key)) continue;
75
+ sanitised[key] = props[key];
76
+ }
77
+ return {
78
+ ...sanitised,
79
+ "aria-disabled": true,
80
+ "data-disabled": "",
81
+ // Mirrors a native disabled button, which is not focusable.
82
+ tabIndex: -1
83
+ };
84
+ }
85
+ function retractActivationProps(props) {
86
+ const overrides = {};
87
+ for (const key of Object.keys(props)) {
88
+ if (key === "href" || ACTIVATION_HANDLERS.includes(key)) overrides[key] = void 0;
89
+ }
90
+ return overrides;
91
+ }
92
+
93
+ export {
94
+ useButton,
95
+ retractActivationProps
96
+ };
@@ -109,85 +109,11 @@ function useRender({
109
109
  return createElement(defaultTagName, merged);
110
110
  }
111
111
 
112
- // src/core/useControlled.ts
113
- import { useCallback, useRef, useState } from "react";
114
- function useControlled({
115
- controlled,
116
- default: defaultValue,
117
- name = "Component",
118
- state = "value"
119
- }) {
120
- const { current: isControlled } = useRef(controlled !== void 0);
121
- const [uncontrolled, setUncontrolled] = useState(defaultValue);
122
- const defaultRef = useRef(defaultValue);
123
- const value = isControlled ? controlled : uncontrolled;
124
- if (process.env.NODE_ENV !== "production") {
125
- if (isControlled !== (controlled !== void 0)) {
126
- console.error(
127
- `${name}: cannot switch between controlled and uncontrolled \`${state}\`. Decide which one this component is for the whole of its life.`
128
- );
129
- }
130
- if (!isControlled && defaultRef.current !== defaultValue) {
131
- console.error(
132
- `${name}: cannot change the default \`${state}\` after mount. It is only read once, so later changes are silently ignored.`
133
- );
134
- }
135
- }
136
- const setValue = useCallback(
137
- (next) => {
138
- if (!isControlled) setUncontrolled(next);
139
- },
140
- [isControlled]
141
- );
142
- return [value, setValue];
143
- }
144
-
145
- // src/useButton.ts
146
- var ACTIVATION_HANDLERS = [
147
- "onClick",
148
- "onDoubleClick",
149
- "onMouseDown",
150
- "onMouseUp",
151
- "onPointerDown",
152
- "onPointerUp",
153
- "onTouchStart",
154
- "onTouchEnd",
155
- "onKeyDown",
156
- "onKeyUp",
157
- "onKeyPress"
158
- ];
159
- function useButton({ disabled = false, native, props = {} }) {
160
- if (!disabled) return props;
161
- if (native) return { ...props, disabled: true, "data-disabled": "" };
162
- const sanitised = {};
163
- for (const key of Object.keys(props)) {
164
- if (key === "href" || ACTIVATION_HANDLERS.includes(key)) continue;
165
- sanitised[key] = props[key];
166
- }
167
- return {
168
- ...sanitised,
169
- "aria-disabled": true,
170
- "data-disabled": "",
171
- // Mirrors a native disabled button, which is not focusable.
172
- tabIndex: -1
173
- };
174
- }
175
- function retractActivationProps(props) {
176
- const overrides = {};
177
- for (const key of Object.keys(props)) {
178
- if (key === "href" || ACTIVATION_HANDLERS.includes(key)) overrides[key] = void 0;
179
- }
180
- return overrides;
181
- }
182
-
183
112
  export {
184
113
  __export,
185
114
  mergeProps,
186
115
  getStateAttributes,
187
116
  booleanAttribute,
188
117
  disabledAttribute,
189
- useRender,
190
- useControlled,
191
- useButton,
192
- retractActivationProps
118
+ useRender
193
119
  };
@@ -0,0 +1,36 @@
1
+ // src/core/useControlled.ts
2
+ import { useCallback, useRef, useState } from "react";
3
+ function useControlled({
4
+ controlled,
5
+ default: defaultValue,
6
+ name = "Component",
7
+ state = "value"
8
+ }) {
9
+ const { current: isControlled } = useRef(controlled !== void 0);
10
+ const [uncontrolled, setUncontrolled] = useState(defaultValue);
11
+ const defaultRef = useRef(defaultValue);
12
+ const value = isControlled ? controlled : uncontrolled;
13
+ if (process.env.NODE_ENV !== "production") {
14
+ if (isControlled !== (controlled !== void 0)) {
15
+ console.error(
16
+ `${name}: cannot switch between controlled and uncontrolled \`${state}\`. Decide which one this component is for the whole of its life.`
17
+ );
18
+ }
19
+ if (!isControlled && defaultRef.current !== defaultValue) {
20
+ console.error(
21
+ `${name}: cannot change the default \`${state}\` after mount. It is only read once, so later changes are silently ignored.`
22
+ );
23
+ }
24
+ }
25
+ const setValue = useCallback(
26
+ (next) => {
27
+ if (!isControlled) setUncontrolled(next);
28
+ },
29
+ [isControlled]
30
+ );
31
+ return [value, setValue];
32
+ }
33
+
34
+ export {
35
+ useControlled
36
+ };
package/dist/index.cjs CHANGED
@@ -24,8 +24,6 @@ __export(index_exports, {
24
24
  disabledAttribute: () => disabledAttribute,
25
25
  getStateAttributes: () => getStateAttributes,
26
26
  mergeProps: () => mergeProps,
27
- retractActivationProps: () => retractActivationProps,
28
- useButton: () => useButton,
29
27
  useControlled: () => useControlled,
30
28
  useRender: () => useRender
31
29
  });
@@ -170,52 +168,12 @@ function useControlled({
170
168
  );
171
169
  return [value, setValue];
172
170
  }
173
-
174
- // src/useButton.ts
175
- var ACTIVATION_HANDLERS = [
176
- "onClick",
177
- "onDoubleClick",
178
- "onMouseDown",
179
- "onMouseUp",
180
- "onPointerDown",
181
- "onPointerUp",
182
- "onTouchStart",
183
- "onTouchEnd",
184
- "onKeyDown",
185
- "onKeyUp",
186
- "onKeyPress"
187
- ];
188
- function useButton({ disabled = false, native, props = {} }) {
189
- if (!disabled) return props;
190
- if (native) return { ...props, disabled: true, "data-disabled": "" };
191
- const sanitised = {};
192
- for (const key of Object.keys(props)) {
193
- if (key === "href" || ACTIVATION_HANDLERS.includes(key)) continue;
194
- sanitised[key] = props[key];
195
- }
196
- return {
197
- ...sanitised,
198
- "aria-disabled": true,
199
- "data-disabled": "",
200
- // Mirrors a native disabled button, which is not focusable.
201
- tabIndex: -1
202
- };
203
- }
204
- function retractActivationProps(props) {
205
- const overrides = {};
206
- for (const key of Object.keys(props)) {
207
- if (key === "href" || ACTIVATION_HANDLERS.includes(key)) overrides[key] = void 0;
208
- }
209
- return overrides;
210
- }
211
171
  // Annotate the CommonJS export names for ESM import in node:
212
172
  0 && (module.exports = {
213
173
  booleanAttribute,
214
174
  disabledAttribute,
215
175
  getStateAttributes,
216
176
  mergeProps,
217
- retractActivationProps,
218
- useButton,
219
177
  useControlled,
220
178
  useRender
221
179
  });
package/dist/index.d.cts CHANGED
@@ -125,26 +125,4 @@ declare function useControlled<T>({ controlled, default: defaultValue, name, sta
125
125
  state?: string;
126
126
  }): [T, (next: T) => void];
127
127
 
128
- interface UseButtonParams {
129
- disabled?: boolean;
130
- /** Whether the rendered element is a native `<button>`, which the platform disables for us. */
131
- native: boolean;
132
- /** Props destined for the element. Returned unchanged unless `disabled`. */
133
- props?: UnknownProps;
134
- }
135
- declare function useButton({ disabled, native, props }: UseButtonParams): UnknownProps;
136
- /**
137
- * Overrides that neutralise a consumer-supplied `render` element.
138
- *
139
- * `useButton` sanitises the props a component passes, but a `render` element's own
140
- * props merge at the highest precedence inside `useRender`, so a `href` written
141
- * directly on it survives. Those have to be retracted on the element itself:
142
- *
143
- * cloneElement(render, retractActivationProps(render.props))
144
- *
145
- * `cloneElement` overwrites with `undefined`, which `mergeProps` cannot do — it skips
146
- * `undefined` so an absent prop never clobbers a present one.
147
- */
148
- declare function retractActivationProps(props: UnknownProps): UnknownProps;
149
-
150
- export { type ComponentEvent, type StateAttributeMapping, type UnknownProps, type UseButtonParams, type UseRenderParams, booleanAttribute, disabledAttribute, getStateAttributes, mergeProps, retractActivationProps, useButton, useControlled, useRender };
128
+ export { type ComponentEvent, type StateAttributeMapping, type UnknownProps, type UseRenderParams, booleanAttribute, disabledAttribute, getStateAttributes, mergeProps, useControlled, useRender };
package/dist/index.d.ts CHANGED
@@ -125,26 +125,4 @@ declare function useControlled<T>({ controlled, default: defaultValue, name, sta
125
125
  state?: string;
126
126
  }): [T, (next: T) => void];
127
127
 
128
- interface UseButtonParams {
129
- disabled?: boolean;
130
- /** Whether the rendered element is a native `<button>`, which the platform disables for us. */
131
- native: boolean;
132
- /** Props destined for the element. Returned unchanged unless `disabled`. */
133
- props?: UnknownProps;
134
- }
135
- declare function useButton({ disabled, native, props }: UseButtonParams): UnknownProps;
136
- /**
137
- * Overrides that neutralise a consumer-supplied `render` element.
138
- *
139
- * `useButton` sanitises the props a component passes, but a `render` element's own
140
- * props merge at the highest precedence inside `useRender`, so a `href` written
141
- * directly on it survives. Those have to be retracted on the element itself:
142
- *
143
- * cloneElement(render, retractActivationProps(render.props))
144
- *
145
- * `cloneElement` overwrites with `undefined`, which `mergeProps` cannot do — it skips
146
- * `undefined` so an absent prop never clobbers a present one.
147
- */
148
- declare function retractActivationProps(props: UnknownProps): UnknownProps;
149
-
150
- export { type ComponentEvent, type StateAttributeMapping, type UnknownProps, type UseButtonParams, type UseRenderParams, booleanAttribute, disabledAttribute, getStateAttributes, mergeProps, retractActivationProps, useButton, useControlled, useRender };
128
+ export { type ComponentEvent, type StateAttributeMapping, type UnknownProps, type UseRenderParams, booleanAttribute, disabledAttribute, getStateAttributes, mergeProps, useControlled, useRender };
package/dist/index.js CHANGED
@@ -1,20 +1,18 @@
1
+ import {
2
+ useControlled
3
+ } from "./chunk-UEIB3I3T.js";
1
4
  import {
2
5
  booleanAttribute,
3
6
  disabledAttribute,
4
7
  getStateAttributes,
5
8
  mergeProps,
6
- retractActivationProps,
7
- useButton,
8
- useControlled,
9
9
  useRender
10
- } from "./chunk-I477HMLD.js";
10
+ } from "./chunk-HWG3GMTO.js";
11
11
  export {
12
12
  booleanAttribute,
13
13
  disabledAttribute,
14
14
  getStateAttributes,
15
15
  mergeProps,
16
- retractActivationProps,
17
- useButton,
18
16
  useControlled,
19
17
  useRender
20
18
  };
@@ -32,74 +32,10 @@ __export(index_parts_exports, {
32
32
  });
33
33
 
34
34
  // src/switch/root/SwitchRoot.tsx
35
- var import_react4 = require("react");
35
+ var import_react5 = require("react");
36
36
 
37
37
  // src/useButton.ts
38
- var ACTIVATION_HANDLERS = [
39
- "onClick",
40
- "onDoubleClick",
41
- "onMouseDown",
42
- "onMouseUp",
43
- "onPointerDown",
44
- "onPointerUp",
45
- "onTouchStart",
46
- "onTouchEnd",
47
- "onKeyDown",
48
- "onKeyUp",
49
- "onKeyPress"
50
- ];
51
- function useButton({ disabled = false, native, props = {} }) {
52
- if (!disabled) return props;
53
- if (native) return { ...props, disabled: true, "data-disabled": "" };
54
- const sanitised = {};
55
- for (const key of Object.keys(props)) {
56
- if (key === "href" || ACTIVATION_HANDLERS.includes(key)) continue;
57
- sanitised[key] = props[key];
58
- }
59
- return {
60
- ...sanitised,
61
- "aria-disabled": true,
62
- "data-disabled": "",
63
- // Mirrors a native disabled button, which is not focusable.
64
- tabIndex: -1
65
- };
66
- }
67
-
68
- // src/core/useControlled.ts
69
38
  var import_react = require("react");
70
- function useControlled({
71
- controlled,
72
- default: defaultValue,
73
- name = "Component",
74
- state = "value"
75
- }) {
76
- const { current: isControlled } = (0, import_react.useRef)(controlled !== void 0);
77
- const [uncontrolled, setUncontrolled] = (0, import_react.useState)(defaultValue);
78
- const defaultRef = (0, import_react.useRef)(defaultValue);
79
- const value = isControlled ? controlled : uncontrolled;
80
- if (process.env.NODE_ENV !== "production") {
81
- if (isControlled !== (controlled !== void 0)) {
82
- console.error(
83
- `${name}: cannot switch between controlled and uncontrolled \`${state}\`. Decide which one this component is for the whole of its life.`
84
- );
85
- }
86
- if (!isControlled && defaultRef.current !== defaultValue) {
87
- console.error(
88
- `${name}: cannot change the default \`${state}\` after mount. It is only read once, so later changes are silently ignored.`
89
- );
90
- }
91
- }
92
- const setValue = (0, import_react.useCallback)(
93
- (next) => {
94
- if (!isControlled) setUncontrolled(next);
95
- },
96
- [isControlled]
97
- );
98
- return [value, setValue];
99
- }
100
-
101
- // src/core/useRender.ts
102
- var import_react2 = require("react");
103
39
 
104
40
  // src/core/mergeProps.ts
105
41
  function isEventHandler(key) {
@@ -169,6 +105,129 @@ function mergeProps(...objects) {
169
105
  return merged;
170
106
  }
171
107
 
108
+ // src/useButton.ts
109
+ var ACTIVATION_HANDLERS = [
110
+ "onClick",
111
+ "onDoubleClick",
112
+ "onMouseDown",
113
+ "onMouseUp",
114
+ "onPointerDown",
115
+ "onPointerUp",
116
+ "onTouchStart",
117
+ "onTouchEnd",
118
+ "onKeyDown",
119
+ "onKeyUp",
120
+ "onKeyPress"
121
+ ];
122
+ function activationHandlers() {
123
+ return {
124
+ onKeyDown(event) {
125
+ if (event.target !== event.currentTarget || event.defaultPrevented) return;
126
+ if (event.key === " ") {
127
+ event.preventDefault();
128
+ return;
129
+ }
130
+ if (event.key === "Enter") {
131
+ event.preventDefault();
132
+ click(event.currentTarget);
133
+ }
134
+ },
135
+ onKeyUp(event) {
136
+ if (event.target !== event.currentTarget || event.defaultPrevented) return;
137
+ if (event.key === " ") click(event.currentTarget);
138
+ }
139
+ };
140
+ }
141
+ function click(element) {
142
+ if (element && typeof element.click === "function") {
143
+ element.click();
144
+ }
145
+ }
146
+ function useButton({
147
+ disabled = false,
148
+ native,
149
+ props = {}
150
+ }) {
151
+ const elementRef = (0, import_react.useRef)(null);
152
+ (0, import_react.useEffect)(() => {
153
+ if (process.env.NODE_ENV === "production") return;
154
+ const element = elementRef.current;
155
+ if (!element) return;
156
+ const isButtonTag = element.tagName === "BUTTON";
157
+ if (native && !isButtonTag) {
158
+ console.error(
159
+ `A component expected a native <button> but rendered <${element.tagName.toLowerCase()}>. Focus, keyboard activation and \`disabled\` will not behave natively.`
160
+ );
161
+ } else if (!native && isButtonTag) {
162
+ console.error(
163
+ "A component rendered a native <button> while treating it as a non-native element, so it carries synthesised attributes it does not need."
164
+ );
165
+ }
166
+ }, [native]);
167
+ return { props: buttonProps({ disabled, native, props }), ref: elementRef };
168
+ }
169
+ function buttonProps({ disabled, native, props }) {
170
+ if (!disabled) {
171
+ return native ? mergeProps({ type: "button" }, props) : mergeProps({ tabIndex: 0 }, activationHandlers(), props);
172
+ }
173
+ if (native) return mergeProps({ type: "button" }, props, { disabled: true, "data-disabled": "" });
174
+ const sanitised = {};
175
+ for (const key of Object.keys(props)) {
176
+ if (key === "href" || ACTIVATION_HANDLERS.includes(key)) continue;
177
+ sanitised[key] = props[key];
178
+ }
179
+ return {
180
+ ...sanitised,
181
+ "aria-disabled": true,
182
+ "data-disabled": "",
183
+ // Mirrors a native disabled button, which is not focusable.
184
+ tabIndex: -1
185
+ };
186
+ }
187
+ function retractActivationProps(props) {
188
+ const overrides = {};
189
+ for (const key of Object.keys(props)) {
190
+ if (key === "href" || ACTIVATION_HANDLERS.includes(key)) overrides[key] = void 0;
191
+ }
192
+ return overrides;
193
+ }
194
+
195
+ // src/core/useControlled.ts
196
+ var import_react2 = require("react");
197
+ function useControlled({
198
+ controlled,
199
+ default: defaultValue,
200
+ name = "Component",
201
+ state = "value"
202
+ }) {
203
+ const { current: isControlled } = (0, import_react2.useRef)(controlled !== void 0);
204
+ const [uncontrolled, setUncontrolled] = (0, import_react2.useState)(defaultValue);
205
+ const defaultRef = (0, import_react2.useRef)(defaultValue);
206
+ const value = isControlled ? controlled : uncontrolled;
207
+ if (process.env.NODE_ENV !== "production") {
208
+ if (isControlled !== (controlled !== void 0)) {
209
+ console.error(
210
+ `${name}: cannot switch between controlled and uncontrolled \`${state}\`. Decide which one this component is for the whole of its life.`
211
+ );
212
+ }
213
+ if (!isControlled && defaultRef.current !== defaultValue) {
214
+ console.error(
215
+ `${name}: cannot change the default \`${state}\` after mount. It is only read once, so later changes are silently ignored.`
216
+ );
217
+ }
218
+ }
219
+ const setValue = (0, import_react2.useCallback)(
220
+ (next) => {
221
+ if (!isControlled) setUncontrolled(next);
222
+ },
223
+ [isControlled]
224
+ );
225
+ return [value, setValue];
226
+ }
227
+
228
+ // src/core/useRender.ts
229
+ var import_react3 = require("react");
230
+
172
231
  // src/core/stateAttributes.ts
173
232
  function getStateAttributes(state, mapping) {
174
233
  if (!mapping) return {};
@@ -199,17 +258,17 @@ function useRender({
199
258
  }) {
200
259
  const attributes = state ? getStateAttributes(state, stateAttributes) : {};
201
260
  const merged = mergeProps(attributes, ...props);
202
- if ((0, import_react2.isValidElement)(render)) {
203
- return (0, import_react2.cloneElement)(render, mergeProps(merged, render.props));
261
+ if ((0, import_react3.isValidElement)(render)) {
262
+ return (0, import_react3.cloneElement)(render, mergeProps(merged, render.props));
204
263
  }
205
- return (0, import_react2.createElement)(defaultTagName, merged);
264
+ return (0, import_react3.createElement)(defaultTagName, merged);
206
265
  }
207
266
 
208
267
  // src/switch/SwitchRootContext.ts
209
- var import_react3 = require("react");
210
- var SwitchRootContext = (0, import_react3.createContext)(null);
268
+ var import_react4 = require("react");
269
+ var SwitchRootContext = (0, import_react4.createContext)(null);
211
270
  function useSwitchRootContext() {
212
- const context = (0, import_react3.useContext)(SwitchRootContext);
271
+ const context = (0, import_react4.useContext)(SwitchRootContext);
213
272
  if (context === null) {
214
273
  throw new Error("<Switch.Thumb> must be rendered inside <Switch.Root>.");
215
274
  }
@@ -242,17 +301,21 @@ function SwitchRoot({
242
301
  name: "Switch.Root",
243
302
  state: "checked"
244
303
  });
245
- const state = (0, import_react4.useMemo)(() => ({ checked, disabled }), [checked, disabled]);
304
+ const state = (0, import_react5.useMemo)(() => ({ checked, disabled }), [checked, disabled]);
246
305
  const isNativeButton = render === void 0 || render.type === "button";
247
- const consumerProps = useButton({ disabled, native: isNativeButton, props: rest });
306
+ const { props: consumerProps, ref: buttonRef } = useButton({
307
+ disabled,
308
+ native: isNativeButton,
309
+ props: rest
310
+ });
311
+ const safeRender = disabled && !isNativeButton && render !== void 0 ? (0, import_react5.cloneElement)(render, retractActivationProps(render.props)) : render;
248
312
  const element = useRender({
249
- render,
313
+ render: safeRender,
250
314
  defaultTagName: "button",
251
315
  state,
252
316
  stateAttributes: switchStateAttributes,
253
317
  props: [
254
318
  {
255
- type: "button",
256
319
  role: "switch",
257
320
  "aria-checked": checked,
258
321
  className,
@@ -264,7 +327,8 @@ function SwitchRoot({
264
327
  onCheckedChange?.(next);
265
328
  }
266
329
  },
267
- consumerProps
330
+ consumerProps,
331
+ { ref: buttonRef }
268
332
  ]
269
333
  });
270
334
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(SwitchRootContext.Provider, { value: state, children: [
@@ -55,10 +55,11 @@ interface SwitchThumbProps {
55
55
  * The moving part of the switch.
56
56
  *
57
57
  * Reads state from the Root rather than taking props, so a consumer cannot get the
58
- * two out of step. Carries the same `data-*` attributes as the Root, which is what
59
- * lets CSS move it:
58
+ * two out of step. Carries the same `data-*` attributes as the Root, so it can be
59
+ * styled from either — on the thumb itself, or by descending from the Root:
60
60
  *
61
- * .switch-thumb[data-checked] { transform: translateX(100%); }
61
+ * .thumb[data-checked] { transform: translateX(100%); }
62
+ * .switch[data-checked] .thumb { transform: translateX(100%); }
62
63
  *
63
64
  * Purely presentational — hidden from assistive technology, since the Root already
64
65
  * announces the state.
@@ -55,10 +55,11 @@ interface SwitchThumbProps {
55
55
  * The moving part of the switch.
56
56
  *
57
57
  * Reads state from the Root rather than taking props, so a consumer cannot get the
58
- * two out of step. Carries the same `data-*` attributes as the Root, which is what
59
- * lets CSS move it:
58
+ * two out of step. Carries the same `data-*` attributes as the Root, so it can be
59
+ * styled from either — on the thumb itself, or by descending from the Root:
60
60
  *
61
- * .switch-thumb[data-checked] { transform: translateX(100%); }
61
+ * .thumb[data-checked] { transform: translateX(100%); }
62
+ * .switch[data-checked] .thumb { transform: translateX(100%); }
62
63
  *
63
64
  * Purely presentational — hidden from assistive technology, since the Root already
64
65
  * announces the state.
@@ -1,11 +1,16 @@
1
+ import {
2
+ useControlled
3
+ } from "../chunk-UEIB3I3T.js";
4
+ import {
5
+ retractActivationProps,
6
+ useButton
7
+ } from "../chunk-2XSRAJHB.js";
1
8
  import {
2
9
  __export,
3
10
  booleanAttribute,
4
11
  disabledAttribute,
5
- useButton,
6
- useControlled,
7
12
  useRender
8
- } from "../chunk-I477HMLD.js";
13
+ } from "../chunk-HWG3GMTO.js";
9
14
 
10
15
  // src/switch/index.parts.ts
11
16
  var index_parts_exports = {};
@@ -15,7 +20,7 @@ __export(index_parts_exports, {
15
20
  });
16
21
 
17
22
  // src/switch/root/SwitchRoot.tsx
18
- import { useMemo } from "react";
23
+ import { cloneElement, useMemo } from "react";
19
24
 
20
25
  // src/switch/SwitchRootContext.ts
21
26
  import { createContext, useContext } from "react";
@@ -56,15 +61,19 @@ function SwitchRoot({
56
61
  });
57
62
  const state = useMemo(() => ({ checked, disabled }), [checked, disabled]);
58
63
  const isNativeButton = render === void 0 || render.type === "button";
59
- const consumerProps = useButton({ disabled, native: isNativeButton, props: rest });
64
+ const { props: consumerProps, ref: buttonRef } = useButton({
65
+ disabled,
66
+ native: isNativeButton,
67
+ props: rest
68
+ });
69
+ const safeRender = disabled && !isNativeButton && render !== void 0 ? cloneElement(render, retractActivationProps(render.props)) : render;
60
70
  const element = useRender({
61
- render,
71
+ render: safeRender,
62
72
  defaultTagName: "button",
63
73
  state,
64
74
  stateAttributes: switchStateAttributes,
65
75
  props: [
66
76
  {
67
- type: "button",
68
77
  role: "switch",
69
78
  "aria-checked": checked,
70
79
  className,
@@ -76,7 +85,8 @@ function SwitchRoot({
76
85
  onCheckedChange?.(next);
77
86
  }
78
87
  },
79
- consumerProps
88
+ consumerProps,
89
+ { ref: buttonRef }
80
90
  ]
81
91
  });
82
92
  return /* @__PURE__ */ jsxs(SwitchRootContext.Provider, { value: state, children: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arun-dev/headless",
3
- "version": "0.4.0",
3
+ "version": "1.0.0",
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",
@@ -34,6 +34,16 @@
34
34
  "default": "./dist/index.cjs"
35
35
  }
36
36
  },
37
+ "./button": {
38
+ "import": {
39
+ "types": "./dist/button/index.d.ts",
40
+ "default": "./dist/button/index.js"
41
+ },
42
+ "require": {
43
+ "types": "./dist/button/index.d.cts",
44
+ "default": "./dist/button/index.cjs"
45
+ }
46
+ },
37
47
  "./switch": {
38
48
  "import": {
39
49
  "types": "./dist/switch/index.d.ts",