@arun-dev/headless 0.3.0 → 0.4.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
@@ -72,9 +72,17 @@ Every part takes a `render` prop to change the element, and spreads unrecognised
72
72
  <Switch.Root render={<Tooltip.Trigger />} />
73
73
  ```
74
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.
75
+ `className` is concatenated, `style` is merged, and refs are merged — so a `ref` on the `render`
76
+ element and a `ref` on the component both receive the node.
77
+
78
+ Event handlers are chained rather than replaced, and **your handler runs before the component's**,
79
+ so you can stop it:
80
+
81
+ ```tsx
82
+ <Switch.Root onClick={(event) => event.preventComponentHandler()} />
83
+ ```
84
+
85
+ `preventDefault()` is left alone — it still means only "cancel the browser's default action".
78
86
 
79
87
  ## Engine
80
88
 
@@ -30,6 +30,18 @@ function mergeRefs(...refs) {
30
30
  };
31
31
  };
32
32
  }
33
+ function isSyntheticEvent(event) {
34
+ return typeof event === "object" && event !== null && "nativeEvent" in event;
35
+ }
36
+ function makeEventPreventable(event) {
37
+ const target = event;
38
+ target.preventComponentHandler = () => {
39
+ target.componentHandlerPrevented = true;
40
+ };
41
+ }
42
+ function isComponentHandlerPrevented(event) {
43
+ return typeof event === "object" && event !== null && event.componentHandlerPrevented === true;
44
+ }
33
45
  function mergeProps(...objects) {
34
46
  const merged = {};
35
47
  for (const props of objects) {
@@ -38,10 +50,13 @@ function mergeProps(...objects) {
38
50
  const value = props[key];
39
51
  if (value === void 0) continue;
40
52
  if (isEventHandler(key) && typeof value === "function") {
41
- const existing = merged[key];
42
- merged[key] = typeof existing === "function" ? (...args) => {
43
- existing(...args);
53
+ const earlier = merged[key];
54
+ merged[key] = typeof earlier === "function" ? (...args) => {
55
+ const event = args[0];
56
+ if (isSyntheticEvent(event)) makeEventPreventable(event);
44
57
  value(...args);
58
+ if (isComponentHandlerPrevented(event)) return;
59
+ earlier(...args);
45
60
  } : value;
46
61
  } else if (key === "className") {
47
62
  merged.className = merged.className ? `${String(merged.className)} ${String(value)}` : value;
@@ -127,6 +142,44 @@ function useControlled({
127
142
  return [value, setValue];
128
143
  }
129
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
+
130
183
  export {
131
184
  __export,
132
185
  mergeProps,
@@ -134,5 +187,7 @@ export {
134
187
  booleanAttribute,
135
188
  disabledAttribute,
136
189
  useRender,
137
- useControlled
190
+ useControlled,
191
+ useButton,
192
+ retractActivationProps
138
193
  };
package/dist/index.cjs CHANGED
@@ -57,6 +57,18 @@ function mergeRefs(...refs) {
57
57
  };
58
58
  };
59
59
  }
60
+ function isSyntheticEvent(event) {
61
+ return typeof event === "object" && event !== null && "nativeEvent" in event;
62
+ }
63
+ function makeEventPreventable(event) {
64
+ const target = event;
65
+ target.preventComponentHandler = () => {
66
+ target.componentHandlerPrevented = true;
67
+ };
68
+ }
69
+ function isComponentHandlerPrevented(event) {
70
+ return typeof event === "object" && event !== null && event.componentHandlerPrevented === true;
71
+ }
60
72
  function mergeProps(...objects) {
61
73
  const merged = {};
62
74
  for (const props of objects) {
@@ -65,10 +77,13 @@ function mergeProps(...objects) {
65
77
  const value = props[key];
66
78
  if (value === void 0) continue;
67
79
  if (isEventHandler(key) && typeof value === "function") {
68
- const existing = merged[key];
69
- merged[key] = typeof existing === "function" ? (...args) => {
70
- existing(...args);
80
+ const earlier = merged[key];
81
+ merged[key] = typeof earlier === "function" ? (...args) => {
82
+ const event = args[0];
83
+ if (isSyntheticEvent(event)) makeEventPreventable(event);
71
84
  value(...args);
85
+ if (isComponentHandlerPrevented(event)) return;
86
+ earlier(...args);
72
87
  } : value;
73
88
  } else if (key === "className") {
74
89
  merged.className = merged.className ? `${String(merged.className)} ${String(value)}` : value;
package/dist/index.d.cts CHANGED
@@ -8,6 +8,18 @@ import { ReactElement } from 'react';
8
8
  * kind of prop is combined rather than replaced.
9
9
  */
10
10
  type UnknownProps = Record<string, unknown>;
11
+ /**
12
+ * An event a consumer's handler can use to stop the component's own handler running.
13
+ *
14
+ * `preventDefault()` is deliberately not overloaded for this: it already means "cancel
15
+ * the browser's default action", which is a different statement. A link inside a
16
+ * component may well want one without the other.
17
+ */
18
+ type ComponentEvent<E = unknown> = E & {
19
+ /** Stops the component's own handler for this event. */
20
+ preventComponentHandler(): void;
21
+ readonly componentHandlerPrevented?: boolean;
22
+ };
11
23
  /**
12
24
  * Merge prop objects left to right. Later objects win for plain values, but:
13
25
  * - event handlers are chained rather than replaced
@@ -17,6 +29,17 @@ type UnknownProps = Record<string, unknown>;
17
29
  *
18
30
  * `undefined` values are skipped, so an absent key on a later object never
19
31
  * clobbers a value set by an earlier one.
32
+ *
33
+ * **Event handlers run right to left** — the last object's handler first, the first
34
+ * object's last. Since a component passes its own props first and the consumer's last,
35
+ * the consumer's handler runs before the component's and can stop it:
36
+ *
37
+ * <Switch.Root onClick={(event) => event.preventComponentHandler()} />
38
+ *
39
+ * That is the opposite direction to plain values, where later objects win. The escape
40
+ * hatch belongs to the consumer: a component already decides which props reach the
41
+ * element, so it never needs the chain to suppress anything — it declines to attach the
42
+ * handler instead. See docs/architecture.md decision 8.
20
43
  */
21
44
  declare function mergeProps(...objects: (UnknownProps | undefined)[]): UnknownProps;
22
45
 
@@ -124,4 +147,4 @@ declare function useButton({ disabled, native, props }: UseButtonParams): Unknow
124
147
  */
125
148
  declare function retractActivationProps(props: UnknownProps): UnknownProps;
126
149
 
127
- export { type StateAttributeMapping, type UnknownProps, type UseButtonParams, type UseRenderParams, booleanAttribute, disabledAttribute, getStateAttributes, mergeProps, retractActivationProps, useButton, useControlled, useRender };
150
+ export { type ComponentEvent, type StateAttributeMapping, type UnknownProps, type UseButtonParams, type UseRenderParams, booleanAttribute, disabledAttribute, getStateAttributes, mergeProps, retractActivationProps, useButton, useControlled, useRender };
package/dist/index.d.ts CHANGED
@@ -8,6 +8,18 @@ import { ReactElement } from 'react';
8
8
  * kind of prop is combined rather than replaced.
9
9
  */
10
10
  type UnknownProps = Record<string, unknown>;
11
+ /**
12
+ * An event a consumer's handler can use to stop the component's own handler running.
13
+ *
14
+ * `preventDefault()` is deliberately not overloaded for this: it already means "cancel
15
+ * the browser's default action", which is a different statement. A link inside a
16
+ * component may well want one without the other.
17
+ */
18
+ type ComponentEvent<E = unknown> = E & {
19
+ /** Stops the component's own handler for this event. */
20
+ preventComponentHandler(): void;
21
+ readonly componentHandlerPrevented?: boolean;
22
+ };
11
23
  /**
12
24
  * Merge prop objects left to right. Later objects win for plain values, but:
13
25
  * - event handlers are chained rather than replaced
@@ -17,6 +29,17 @@ type UnknownProps = Record<string, unknown>;
17
29
  *
18
30
  * `undefined` values are skipped, so an absent key on a later object never
19
31
  * clobbers a value set by an earlier one.
32
+ *
33
+ * **Event handlers run right to left** — the last object's handler first, the first
34
+ * object's last. Since a component passes its own props first and the consumer's last,
35
+ * the consumer's handler runs before the component's and can stop it:
36
+ *
37
+ * <Switch.Root onClick={(event) => event.preventComponentHandler()} />
38
+ *
39
+ * That is the opposite direction to plain values, where later objects win. The escape
40
+ * hatch belongs to the consumer: a component already decides which props reach the
41
+ * element, so it never needs the chain to suppress anything — it declines to attach the
42
+ * handler instead. See docs/architecture.md decision 8.
20
43
  */
21
44
  declare function mergeProps(...objects: (UnknownProps | undefined)[]): UnknownProps;
22
45
 
@@ -124,4 +147,4 @@ declare function useButton({ disabled, native, props }: UseButtonParams): Unknow
124
147
  */
125
148
  declare function retractActivationProps(props: UnknownProps): UnknownProps;
126
149
 
127
- export { type StateAttributeMapping, type UnknownProps, type UseButtonParams, type UseRenderParams, booleanAttribute, disabledAttribute, getStateAttributes, mergeProps, retractActivationProps, useButton, useControlled, useRender };
150
+ export { type ComponentEvent, type StateAttributeMapping, type UnknownProps, type UseButtonParams, type UseRenderParams, booleanAttribute, disabledAttribute, getStateAttributes, mergeProps, retractActivationProps, useButton, useControlled, useRender };
package/dist/index.js CHANGED
@@ -3,47 +3,11 @@ import {
3
3
  disabledAttribute,
4
4
  getStateAttributes,
5
5
  mergeProps,
6
+ retractActivationProps,
7
+ useButton,
6
8
  useControlled,
7
9
  useRender
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
- }
10
+ } from "./chunk-I477HMLD.js";
47
11
  export {
48
12
  booleanAttribute,
49
13
  disabledAttribute,
@@ -34,6 +34,37 @@ __export(index_parts_exports, {
34
34
  // src/switch/root/SwitchRoot.tsx
35
35
  var import_react4 = require("react");
36
36
 
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
+
37
68
  // src/core/useControlled.ts
38
69
  var import_react = require("react");
39
70
  function useControlled({
@@ -96,6 +127,18 @@ function mergeRefs(...refs) {
96
127
  };
97
128
  };
98
129
  }
130
+ function isSyntheticEvent(event) {
131
+ return typeof event === "object" && event !== null && "nativeEvent" in event;
132
+ }
133
+ function makeEventPreventable(event) {
134
+ const target = event;
135
+ target.preventComponentHandler = () => {
136
+ target.componentHandlerPrevented = true;
137
+ };
138
+ }
139
+ function isComponentHandlerPrevented(event) {
140
+ return typeof event === "object" && event !== null && event.componentHandlerPrevented === true;
141
+ }
99
142
  function mergeProps(...objects) {
100
143
  const merged = {};
101
144
  for (const props of objects) {
@@ -104,10 +147,13 @@ function mergeProps(...objects) {
104
147
  const value = props[key];
105
148
  if (value === void 0) continue;
106
149
  if (isEventHandler(key) && typeof value === "function") {
107
- const existing = merged[key];
108
- merged[key] = typeof existing === "function" ? (...args) => {
109
- existing(...args);
150
+ const earlier = merged[key];
151
+ merged[key] = typeof earlier === "function" ? (...args) => {
152
+ const event = args[0];
153
+ if (isSyntheticEvent(event)) makeEventPreventable(event);
110
154
  value(...args);
155
+ if (isComponentHandlerPrevented(event)) return;
156
+ earlier(...args);
111
157
  } : value;
112
158
  } else if (key === "className") {
113
159
  merged.className = merged.className ? `${String(merged.className)} ${String(value)}` : value;
@@ -197,6 +243,8 @@ function SwitchRoot({
197
243
  state: "checked"
198
244
  });
199
245
  const state = (0, import_react4.useMemo)(() => ({ checked, disabled }), [checked, disabled]);
246
+ const isNativeButton = render === void 0 || render.type === "button";
247
+ const consumerProps = useButton({ disabled, native: isNativeButton, props: rest });
200
248
  const element = useRender({
201
249
  render,
202
250
  defaultTagName: "button",
@@ -207,16 +255,16 @@ function SwitchRoot({
207
255
  type: "button",
208
256
  role: "switch",
209
257
  "aria-checked": checked,
210
- disabled,
211
258
  className,
212
259
  children,
213
260
  onClick() {
261
+ if (disabled) return;
214
262
  const next = !checked;
215
263
  setChecked(next);
216
264
  onCheckedChange?.(next);
217
265
  }
218
266
  },
219
- rest
267
+ consumerProps
220
268
  ]
221
269
  });
222
270
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(SwitchRootContext.Provider, { value: state, children: [
@@ -2,9 +2,10 @@ import {
2
2
  __export,
3
3
  booleanAttribute,
4
4
  disabledAttribute,
5
+ useButton,
5
6
  useControlled,
6
7
  useRender
7
- } from "../chunk-LU4ZQE3V.js";
8
+ } from "../chunk-I477HMLD.js";
8
9
 
9
10
  // src/switch/index.parts.ts
10
11
  var index_parts_exports = {};
@@ -54,6 +55,8 @@ function SwitchRoot({
54
55
  state: "checked"
55
56
  });
56
57
  const state = useMemo(() => ({ checked, disabled }), [checked, disabled]);
58
+ const isNativeButton = render === void 0 || render.type === "button";
59
+ const consumerProps = useButton({ disabled, native: isNativeButton, props: rest });
57
60
  const element = useRender({
58
61
  render,
59
62
  defaultTagName: "button",
@@ -64,16 +67,16 @@ function SwitchRoot({
64
67
  type: "button",
65
68
  role: "switch",
66
69
  "aria-checked": checked,
67
- disabled,
68
70
  className,
69
71
  children,
70
72
  onClick() {
73
+ if (disabled) return;
71
74
  const next = !checked;
72
75
  setChecked(next);
73
76
  onCheckedChange?.(next);
74
77
  }
75
78
  },
76
- rest
79
+ consumerProps
77
80
  ]
78
81
  });
79
82
  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.3.0",
3
+ "version": "0.4.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",