@arun-dev/headless 3.0.1 → 4.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,21 +28,19 @@ import { Switch } from '@arun-dev/headless/switch';
28
28
 
29
29
  | Component | Parts | Props |
30
30
  | --------- | ----------------------- | --------------------------------------------------------------------------- |
31
- | `Button` | — | `disabled`, `href`, `type` |
31
+ | `Button` | — | `disabled`, `type` |
32
32
  | `Switch` | `Switch.Root`, `.Thumb` | `checked`, `defaultChecked`, `onCheckedChange`, `disabled`, `name`, `value` |
33
33
 
34
- `Button` renders a `<button>`, or a real `<a href>` when you give it an `href` — a control that
35
- navigates should be an anchor, so middle-click, cmd-click and "link" in assistive technology all
36
- keep working. It defaults `type="button"` so a button never submits a form by accident, and a
37
- disabled `href` renders a `<button disabled>`, because a link that navigates nowhere is not a link.
38
- A `render` component cannot be inspected, so a disabled Button gives it `aria-disabled` and
39
- `data-disabled` rather than `disabled` — actually disabling itself is up to the component.
34
+ `Button` renders a `<button>` and defaults `type="button"` so it never submits a form by accident.
35
+ It does not navigate — a link is a separate `Link` component. A `render` component cannot be
36
+ inspected, so a disabled Button gives it `aria-disabled` and `data-disabled` rather than
37
+ `disabled` — actually disabling itself is up to the component.
40
38
 
41
39
  ```tsx
42
40
  import { Button } from '@arun-dev/headless/button';
43
41
 
44
- <Button href="/docs" target="_blank" rel="noreferrer">
45
- Docs
42
+ <Button type="submit" disabled={saving}>
43
+ Save
46
44
  </Button>;
47
45
  ```
48
46
 
@@ -100,8 +98,8 @@ come last and win. An element that declares no children inherits yours, one that
100
98
  keeps them.
101
99
 
102
100
  ```tsx
103
- <Button render={<a href="/docs" />}>Docs</Button> // renders "Docs"
104
- <Button render={<a href="/docs">Read the docs</a>}>Docs</Button> // renders "Read the docs"
101
+ <Button render={<span />}>Docs</Button> // renders "Docs"
102
+ <Button render={<span>Read the docs</span>}>Docs</Button> // renders "Read the docs"
105
103
  ```
106
104
 
107
105
  Event handlers are chained rather than replaced, and **your handler runs before the component's**,
@@ -116,27 +116,17 @@ function useRender({
116
116
  // src/button/Button.tsx
117
117
  function Button({
118
118
  disabled = false,
119
- href,
120
119
  type = "button",
121
120
  className,
122
121
  children,
123
122
  render,
124
123
  ...rest
125
124
  }) {
126
- const linksOut = href !== void 0 && !disabled;
127
- const rendersButton = render === void 0 ? !linksOut : render.type === "button";
128
- if (process.env.NODE_ENV !== "production" && disabled && render !== void 0) {
129
- const renderProps = render.props;
130
- if (renderProps?.href !== void 0) {
131
- console.error(
132
- "Button: `disabled` cannot remove the `href` on a `render` element \u2014 its own props outrank the component. Pass the URL as `href` on Button instead."
133
- );
134
- }
135
- }
136
- const elementProps = rendersButton ? { type, disabled: disabled || void 0 } : { href: linksOut ? href : void 0, "aria-disabled": disabled || void 0 };
125
+ const rendersButton = render === void 0 || render.type === "button";
126
+ const elementProps = rendersButton ? { type, disabled: disabled || void 0 } : { "aria-disabled": disabled || void 0 };
137
127
  return useRender({
138
128
  render,
139
- defaultTagName: linksOut ? "a" : "button",
129
+ defaultTagName: "button",
140
130
  props: {
141
131
  ...elementProps,
142
132
  "data-disabled": disabled ? "" : void 0,
@@ -8,48 +8,34 @@ import { ReactElement, Ref, ComponentPropsWithRef } from 'react';
8
8
  */
9
9
  type ButtonOwnProps = {
10
10
  /**
11
- * Prevents activation. On a `<button>` the platform does it; on the `<a>` that
12
- * `href` renders, dropping the `href` does it — the DOM leaves an anchor without one
13
- * unfocusable, unactivatable and no longer a link.
11
+ * Prevents activation. On a `<button>` the platform does it.
14
12
  *
15
13
  * A `render` component cannot be inspected, so it gets `aria-disabled` and
16
14
  * `data-disabled` rather than `disabled`: actually disabling itself is up to it.
17
15
  */
18
16
  disabled?: boolean;
19
- /**
20
- * Renders an `<a href>` instead of a `<button>`.
21
- *
22
- * A control that navigates should be an anchor, so middle-click, cmd-click, the
23
- * status bar and "link" in assistive technology all work. It is a prop rather than
24
- * something to write on a `render` element because `disabled` has to be able to take
25
- * it away, and a `render` element's own props outrank everything.
26
- */
27
- href?: string;
28
17
  /**
29
18
  * Element or component to render instead of the default. Props, className, event
30
19
  * handlers and ref are merged onto it.
31
20
  *
32
- * @example <Button render={<NextLink href="/docs" />}>Docs</Button>
21
+ * @example <Button render={<motion.button />}>Save</Button>
33
22
  */
34
23
  render?: ReactElement;
35
24
  /** Ref to the rendered element. Merged with any ref on the `render` element. */
36
25
  ref?: Ref<HTMLElement>;
37
26
  };
38
- type ButtonProps = ButtonOwnProps & Omit<ComponentPropsWithRef<'button'>, keyof ButtonOwnProps> & Pick<ComponentPropsWithRef<'a'>, 'target' | 'rel' | 'download'>;
27
+ type ButtonProps = ButtonOwnProps & Omit<ComponentPropsWithRef<'button'>, keyof ButtonOwnProps>;
39
28
  /**
40
29
  * A button — behaviour only, no styling.
41
30
  *
42
- * Renders a `<button>`, or an `<a href>` when `href` is set. Both are natively
43
- * focusable and keyboard-activatable, so nothing here is synthesised: the component
44
- * picks the right element, defaults `type` so a button does not submit by accident,
45
- * and expresses `disabled` the way that element already understands.
31
+ * Renders a `<button>`, which is natively focusable and keyboard-activatable, so
32
+ * nothing here is synthesised: the component defaults `type` so it does not submit by
33
+ * accident, and expresses `disabled` the way the element already understands.
46
34
  *
47
- * A disabled `href` renders a `<button disabled>` rather than a dead link — the
48
- * platform then removes it from the tab order and suppresses activation, with no
49
- * handler stripping or `tabindex` bookkeeping of our own.
35
+ * Navigation is not a button's job — a link belongs to a separate `Link` component.
50
36
  *
51
37
  * Emits `data-disabled` so one selector styles a disabled control either way.
52
38
  */
53
- declare function Button({ disabled, href, type, className, children, render, ...rest }: ButtonProps): ReactElement<unknown, string | react.JSXElementConstructor<any>>;
39
+ declare function Button({ disabled, type, className, children, render, ...rest }: ButtonProps): ReactElement<unknown, string | react.JSXElementConstructor<any>>;
54
40
 
55
41
  export { Button, type ButtonProps };
@@ -8,48 +8,34 @@ import { ReactElement, Ref, ComponentPropsWithRef } from 'react';
8
8
  */
9
9
  type ButtonOwnProps = {
10
10
  /**
11
- * Prevents activation. On a `<button>` the platform does it; on the `<a>` that
12
- * `href` renders, dropping the `href` does it — the DOM leaves an anchor without one
13
- * unfocusable, unactivatable and no longer a link.
11
+ * Prevents activation. On a `<button>` the platform does it.
14
12
  *
15
13
  * A `render` component cannot be inspected, so it gets `aria-disabled` and
16
14
  * `data-disabled` rather than `disabled`: actually disabling itself is up to it.
17
15
  */
18
16
  disabled?: boolean;
19
- /**
20
- * Renders an `<a href>` instead of a `<button>`.
21
- *
22
- * A control that navigates should be an anchor, so middle-click, cmd-click, the
23
- * status bar and "link" in assistive technology all work. It is a prop rather than
24
- * something to write on a `render` element because `disabled` has to be able to take
25
- * it away, and a `render` element's own props outrank everything.
26
- */
27
- href?: string;
28
17
  /**
29
18
  * Element or component to render instead of the default. Props, className, event
30
19
  * handlers and ref are merged onto it.
31
20
  *
32
- * @example <Button render={<NextLink href="/docs" />}>Docs</Button>
21
+ * @example <Button render={<motion.button />}>Save</Button>
33
22
  */
34
23
  render?: ReactElement;
35
24
  /** Ref to the rendered element. Merged with any ref on the `render` element. */
36
25
  ref?: Ref<HTMLElement>;
37
26
  };
38
- type ButtonProps = ButtonOwnProps & Omit<ComponentPropsWithRef<'button'>, keyof ButtonOwnProps> & Pick<ComponentPropsWithRef<'a'>, 'target' | 'rel' | 'download'>;
27
+ type ButtonProps = ButtonOwnProps & Omit<ComponentPropsWithRef<'button'>, keyof ButtonOwnProps>;
39
28
  /**
40
29
  * A button — behaviour only, no styling.
41
30
  *
42
- * Renders a `<button>`, or an `<a href>` when `href` is set. Both are natively
43
- * focusable and keyboard-activatable, so nothing here is synthesised: the component
44
- * picks the right element, defaults `type` so a button does not submit by accident,
45
- * and expresses `disabled` the way that element already understands.
31
+ * Renders a `<button>`, which is natively focusable and keyboard-activatable, so
32
+ * nothing here is synthesised: the component defaults `type` so it does not submit by
33
+ * accident, and expresses `disabled` the way the element already understands.
46
34
  *
47
- * A disabled `href` renders a `<button disabled>` rather than a dead link — the
48
- * platform then removes it from the tab order and suppresses activation, with no
49
- * handler stripping or `tabindex` bookkeeping of our own.
35
+ * Navigation is not a button's job — a link belongs to a separate `Link` component.
50
36
  *
51
37
  * Emits `data-disabled` so one selector styles a disabled control either way.
52
38
  */
53
- declare function Button({ disabled, href, type, className, children, render, ...rest }: ButtonProps): ReactElement<unknown, string | react.JSXElementConstructor<any>>;
39
+ declare function Button({ disabled, type, className, children, render, ...rest }: ButtonProps): ReactElement<unknown, string | react.JSXElementConstructor<any>>;
54
40
 
55
41
  export { Button, type ButtonProps };
@@ -5,27 +5,17 @@ import {
5
5
  // src/button/Button.tsx
6
6
  function Button({
7
7
  disabled = false,
8
- href,
9
8
  type = "button",
10
9
  className,
11
10
  children,
12
11
  render,
13
12
  ...rest
14
13
  }) {
15
- const linksOut = href !== void 0 && !disabled;
16
- const rendersButton = render === void 0 ? !linksOut : render.type === "button";
17
- if (process.env.NODE_ENV !== "production" && disabled && render !== void 0) {
18
- const renderProps = render.props;
19
- if (renderProps?.href !== void 0) {
20
- console.error(
21
- "Button: `disabled` cannot remove the `href` on a `render` element \u2014 its own props outrank the component. Pass the URL as `href` on Button instead."
22
- );
23
- }
24
- }
25
- const elementProps = rendersButton ? { type, disabled: disabled || void 0 } : { href: linksOut ? href : void 0, "aria-disabled": disabled || void 0 };
14
+ const rendersButton = render === void 0 || render.type === "button";
15
+ const elementProps = rendersButton ? { type, disabled: disabled || void 0 } : { "aria-disabled": disabled || void 0 };
26
16
  return useRender({
27
17
  render,
28
- defaultTagName: linksOut ? "a" : "button",
18
+ defaultTagName: "button",
29
19
  props: {
30
20
  ...elementProps,
31
21
  "data-disabled": disabled ? "" : void 0,
@@ -196,11 +196,17 @@ function SwitchRoot({
196
196
  name: "Switch.Root",
197
197
  state: "checked"
198
198
  });
199
+ const commitChecked = (0, import_react4.useCallback)(
200
+ (next) => {
201
+ setChecked(next);
202
+ onCheckedChange?.(next);
203
+ },
204
+ [setChecked, onCheckedChange]
205
+ );
199
206
  const state = (0, import_react4.useMemo)(() => ({ checked, disabled }), [checked, disabled]);
200
207
  const elementRef = (0, import_react4.useRef)(null);
201
208
  const inputRef = (0, import_react4.useRef)(null);
202
- useNativeButtonWarning(elementRef);
203
- useFormReset({ elementRef, inputRef, checked, setChecked, onCheckedChange });
209
+ useFormReset({ elementRef, inputRef, checked, commitChecked });
204
210
  const element = useRender({
205
211
  render,
206
212
  defaultTagName: "button",
@@ -209,16 +215,15 @@ function SwitchRoot({
209
215
  type: "button",
210
216
  role: "switch",
211
217
  "aria-checked": checked,
212
- // The platform suppresses activation, focus and the click handler below.
218
+ // The platform suppresses activation and focus on a native button.
213
219
  disabled: disabled || void 0,
214
220
  ...switchDataAttributes(state),
215
221
  className,
216
222
  children,
217
223
  ref: elementRef,
218
224
  onClick() {
219
- const next = !checked;
220
- setChecked(next);
221
- onCheckedChange?.(next);
225
+ if (disabled) return;
226
+ commitChecked(!checked);
222
227
  }
223
228
  },
224
229
  consumerProps: rest
@@ -244,8 +249,7 @@ function useFormReset({
244
249
  elementRef,
245
250
  inputRef,
246
251
  checked,
247
- setChecked,
248
- onCheckedChange
252
+ commitChecked
249
253
  }) {
250
254
  const { current: initialChecked } = (0, import_react4.useRef)(checked);
251
255
  (0, import_react4.useEffect)(() => {
@@ -256,23 +260,12 @@ function useFormReset({
256
260
  if (event.defaultPrevented) return;
257
261
  if (inputRef.current) inputRef.current.checked = checked;
258
262
  if (checked === initialChecked) return;
259
- setChecked(initialChecked);
260
- onCheckedChange?.(initialChecked);
263
+ commitChecked(initialChecked);
261
264
  });
262
265
  }
263
266
  form.addEventListener("reset", onReset);
264
267
  return () => form.removeEventListener("reset", onReset);
265
- }, [elementRef, inputRef, checked, setChecked, onCheckedChange, initialChecked]);
266
- }
267
- function useNativeButtonWarning(elementRef) {
268
- (0, import_react4.useEffect)(() => {
269
- if (process.env.NODE_ENV === "production") return;
270
- const element = elementRef.current;
271
- if (!element || element.tagName === "BUTTON") return;
272
- console.error(
273
- `Switch.Root rendered <${element.tagName.toLowerCase()}> instead of <button>. Focus, Space and Enter activation and \`disabled\` all come from the button element; pass a \`render\` component that forwards its props to one.`
274
- );
275
- }, [elementRef]);
268
+ }, [elementRef, inputRef, checked, commitChecked, initialChecked]);
276
269
  }
277
270
 
278
271
  // src/switch/SwitchThumb.tsx
@@ -35,8 +35,8 @@ type SwitchRootOwnProps = {
35
35
  * Component to render instead of the default `<button>`. Props, className, event
36
36
  * handlers and ref are merged onto it.
37
37
  *
38
- * It must render a native `<button>` — a wrapper such as `<Tooltip.Trigger />` that
39
- * forwards its props to one. Anything else is reported in development.
38
+ * The default `<button>` is what supplies focus, Space and Enter activation and
39
+ * `disabled`; what `render` produces instead is the consumer's choice.
40
40
  */
41
41
  render?: ReactElement;
42
42
  /** Ref to the rendered element. Merged with any ref on the `render` element. */
@@ -35,8 +35,8 @@ type SwitchRootOwnProps = {
35
35
  * Component to render instead of the default `<button>`. Props, className, event
36
36
  * handlers and ref are merged onto it.
37
37
  *
38
- * It must render a native `<button>` — a wrapper such as `<Tooltip.Trigger />` that
39
- * forwards its props to one. Anything else is reported in development.
38
+ * The default `<button>` is what supplies focus, Space and Enter activation and
39
+ * `disabled`; what `render` produces instead is the consumer's choice.
40
40
  */
41
41
  render?: ReactElement;
42
42
  /** Ref to the rendered element. Merged with any ref on the `render` element. */
@@ -14,7 +14,7 @@ __export(index_parts_exports, {
14
14
  });
15
15
 
16
16
  // src/switch/SwitchRoot.tsx
17
- import { useEffect, useMemo, useRef } from "react";
17
+ import { useCallback, useEffect, useMemo, useRef } from "react";
18
18
 
19
19
  // src/switch/SwitchRootContext.ts
20
20
  import { createContext, useContext } from "react";
@@ -56,11 +56,17 @@ function SwitchRoot({
56
56
  name: "Switch.Root",
57
57
  state: "checked"
58
58
  });
59
+ const commitChecked = useCallback(
60
+ (next) => {
61
+ setChecked(next);
62
+ onCheckedChange?.(next);
63
+ },
64
+ [setChecked, onCheckedChange]
65
+ );
59
66
  const state = useMemo(() => ({ checked, disabled }), [checked, disabled]);
60
67
  const elementRef = useRef(null);
61
68
  const inputRef = useRef(null);
62
- useNativeButtonWarning(elementRef);
63
- useFormReset({ elementRef, inputRef, checked, setChecked, onCheckedChange });
69
+ useFormReset({ elementRef, inputRef, checked, commitChecked });
64
70
  const element = useRender({
65
71
  render,
66
72
  defaultTagName: "button",
@@ -69,16 +75,15 @@ function SwitchRoot({
69
75
  type: "button",
70
76
  role: "switch",
71
77
  "aria-checked": checked,
72
- // The platform suppresses activation, focus and the click handler below.
78
+ // The platform suppresses activation and focus on a native button.
73
79
  disabled: disabled || void 0,
74
80
  ...switchDataAttributes(state),
75
81
  className,
76
82
  children,
77
83
  ref: elementRef,
78
84
  onClick() {
79
- const next = !checked;
80
- setChecked(next);
81
- onCheckedChange?.(next);
85
+ if (disabled) return;
86
+ commitChecked(!checked);
82
87
  }
83
88
  },
84
89
  consumerProps: rest
@@ -104,8 +109,7 @@ function useFormReset({
104
109
  elementRef,
105
110
  inputRef,
106
111
  checked,
107
- setChecked,
108
- onCheckedChange
112
+ commitChecked
109
113
  }) {
110
114
  const { current: initialChecked } = useRef(checked);
111
115
  useEffect(() => {
@@ -116,23 +120,12 @@ function useFormReset({
116
120
  if (event.defaultPrevented) return;
117
121
  if (inputRef.current) inputRef.current.checked = checked;
118
122
  if (checked === initialChecked) return;
119
- setChecked(initialChecked);
120
- onCheckedChange?.(initialChecked);
123
+ commitChecked(initialChecked);
121
124
  });
122
125
  }
123
126
  form.addEventListener("reset", onReset);
124
127
  return () => form.removeEventListener("reset", onReset);
125
- }, [elementRef, inputRef, checked, setChecked, onCheckedChange, initialChecked]);
126
- }
127
- function useNativeButtonWarning(elementRef) {
128
- useEffect(() => {
129
- if (process.env.NODE_ENV === "production") return;
130
- const element = elementRef.current;
131
- if (!element || element.tagName === "BUTTON") return;
132
- console.error(
133
- `Switch.Root rendered <${element.tagName.toLowerCase()}> instead of <button>. Focus, Space and Enter activation and \`disabled\` all come from the button element; pass a \`render\` component that forwards its props to one.`
134
- );
135
- }, [elementRef]);
128
+ }, [elementRef, inputRef, checked, commitChecked, initialChecked]);
136
129
  }
137
130
 
138
131
  // src/switch/SwitchThumb.tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arun-dev/headless",
3
- "version": "3.0.1",
3
+ "version": "4.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",