@arun-dev/headless 3.0.0 → 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,19 +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.
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.
38
38
 
39
39
  ```tsx
40
40
  import { Button } from '@arun-dev/headless/button';
41
41
 
42
- <Button href="/docs" target="_blank" rel="noreferrer">
43
- Docs
42
+ <Button type="submit" disabled={saving}>
43
+ Save
44
44
  </Button>;
45
45
  ```
46
46
 
@@ -48,6 +48,10 @@ import { Button } from '@arun-dev/headless/button';
48
48
  from the platform. It carries `role="switch"` and `aria-checked`, but **no accessible name** —
49
49
  wrap it in a `<label>` or pass `aria-label`. A headless component should not guess at your copy.
50
50
 
51
+ In a form it behaves like a native checkbox: with a `name` it submits its `value` only when checked
52
+ and enabled, and `form.reset()` returns it to the state it mounted with, reported through
53
+ `onCheckedChange`.
54
+
51
55
  ## State reaches CSS through `data-*`
52
56
 
53
57
  A headless component owns no class names, so state is projected onto the DOM instead. That
@@ -94,8 +98,8 @@ come last and win. An element that declares no children inherits yours, one that
94
98
  keeps them.
95
99
 
96
100
  ```tsx
97
- <Button render={<a href="/docs" />}>Docs</Button> // renders "Docs"
98
- <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"
99
103
  ```
100
104
 
101
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,45 +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.
14
- */
15
- disabled?: boolean;
16
- /**
17
- * Renders an `<a href>` instead of a `<button>`.
11
+ * Prevents activation. On a `<button>` the platform does it.
18
12
  *
19
- * A control that navigates should be an anchor, so middle-click, cmd-click, the
20
- * status bar and "link" in assistive technology all work. It is a prop rather than
21
- * something to write on a `render` element because `disabled` has to be able to take
22
- * it away, and a `render` element's own props outrank everything.
13
+ * A `render` component cannot be inspected, so it gets `aria-disabled` and
14
+ * `data-disabled` rather than `disabled`: actually disabling itself is up to it.
23
15
  */
24
- href?: string;
16
+ disabled?: boolean;
25
17
  /**
26
18
  * Element or component to render instead of the default. Props, className, event
27
19
  * handlers and ref are merged onto it.
28
20
  *
29
- * @example <Button render={<NextLink href="/docs" />}>Docs</Button>
21
+ * @example <Button render={<motion.button />}>Save</Button>
30
22
  */
31
23
  render?: ReactElement;
32
24
  /** Ref to the rendered element. Merged with any ref on the `render` element. */
33
25
  ref?: Ref<HTMLElement>;
34
26
  };
35
- type ButtonProps = ButtonOwnProps & Omit<ComponentPropsWithRef<'button'>, keyof ButtonOwnProps> & Pick<ComponentPropsWithRef<'a'>, 'target' | 'rel' | 'download'>;
27
+ type ButtonProps = ButtonOwnProps & Omit<ComponentPropsWithRef<'button'>, keyof ButtonOwnProps>;
36
28
  /**
37
29
  * A button — behaviour only, no styling.
38
30
  *
39
- * Renders a `<button>`, or an `<a href>` when `href` is set. Both are natively
40
- * focusable and keyboard-activatable, so nothing here is synthesised: the component
41
- * picks the right element, defaults `type` so a button does not submit by accident,
42
- * 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.
43
34
  *
44
- * A disabled `href` renders a `<button disabled>` rather than a dead link — the
45
- * platform then removes it from the tab order and suppresses activation, with no
46
- * handler stripping or `tabindex` bookkeeping of our own.
35
+ * Navigation is not a button's job — a link belongs to a separate `Link` component.
47
36
  *
48
37
  * Emits `data-disabled` so one selector styles a disabled control either way.
49
38
  */
50
- 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>>;
51
40
 
52
41
  export { Button, type ButtonProps };
@@ -8,45 +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.
14
- */
15
- disabled?: boolean;
16
- /**
17
- * Renders an `<a href>` instead of a `<button>`.
11
+ * Prevents activation. On a `<button>` the platform does it.
18
12
  *
19
- * A control that navigates should be an anchor, so middle-click, cmd-click, the
20
- * status bar and "link" in assistive technology all work. It is a prop rather than
21
- * something to write on a `render` element because `disabled` has to be able to take
22
- * it away, and a `render` element's own props outrank everything.
13
+ * A `render` component cannot be inspected, so it gets `aria-disabled` and
14
+ * `data-disabled` rather than `disabled`: actually disabling itself is up to it.
23
15
  */
24
- href?: string;
16
+ disabled?: boolean;
25
17
  /**
26
18
  * Element or component to render instead of the default. Props, className, event
27
19
  * handlers and ref are merged onto it.
28
20
  *
29
- * @example <Button render={<NextLink href="/docs" />}>Docs</Button>
21
+ * @example <Button render={<motion.button />}>Save</Button>
30
22
  */
31
23
  render?: ReactElement;
32
24
  /** Ref to the rendered element. Merged with any ref on the `render` element. */
33
25
  ref?: Ref<HTMLElement>;
34
26
  };
35
- type ButtonProps = ButtonOwnProps & Omit<ComponentPropsWithRef<'button'>, keyof ButtonOwnProps> & Pick<ComponentPropsWithRef<'a'>, 'target' | 'rel' | 'download'>;
27
+ type ButtonProps = ButtonOwnProps & Omit<ComponentPropsWithRef<'button'>, keyof ButtonOwnProps>;
36
28
  /**
37
29
  * A button — behaviour only, no styling.
38
30
  *
39
- * Renders a `<button>`, or an `<a href>` when `href` is set. Both are natively
40
- * focusable and keyboard-activatable, so nothing here is synthesised: the component
41
- * picks the right element, defaults `type` so a button does not submit by accident,
42
- * 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.
43
34
  *
44
- * A disabled `href` renders a `<button disabled>` rather than a dead link — the
45
- * platform then removes it from the tab order and suppresses activation, with no
46
- * handler stripping or `tabindex` bookkeeping of our own.
35
+ * Navigation is not a button's job — a link belongs to a separate `Link` component.
47
36
  *
48
37
  * Emits `data-disabled` so one selector styles a disabled control either way.
49
38
  */
50
- 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>>;
51
40
 
52
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,8 +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
- const elementRef = useNativeButtonWarning();
207
+ const elementRef = (0, import_react4.useRef)(null);
208
+ const inputRef = (0, import_react4.useRef)(null);
209
+ useFormReset({ elementRef, inputRef, checked, commitChecked });
201
210
  const element = useRender({
202
211
  render,
203
212
  defaultTagName: "button",
@@ -206,36 +215,57 @@ function SwitchRoot({
206
215
  type: "button",
207
216
  role: "switch",
208
217
  "aria-checked": checked,
209
- // The platform suppresses activation, focus and the click handler below.
218
+ // The platform suppresses activation and focus on a native button.
210
219
  disabled: disabled || void 0,
211
220
  ...switchDataAttributes(state),
212
221
  className,
213
222
  children,
214
223
  ref: elementRef,
215
224
  onClick() {
216
- const next = !checked;
217
- setChecked(next);
218
- onCheckedChange?.(next);
225
+ if (disabled) return;
226
+ commitChecked(!checked);
219
227
  }
220
228
  },
221
229
  consumerProps: rest
222
230
  });
223
231
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(SwitchRootContext.Provider, { value: state, children: [
224
232
  element,
225
- name !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", hidden: true, readOnly: true, name, value, checked }) : null
233
+ name !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
234
+ "input",
235
+ {
236
+ ref: inputRef,
237
+ type: "checkbox",
238
+ hidden: true,
239
+ readOnly: true,
240
+ name,
241
+ value,
242
+ checked,
243
+ disabled
244
+ }
245
+ ) : null
226
246
  ] });
227
247
  }
228
- function useNativeButtonWarning() {
229
- const elementRef = (0, import_react4.useRef)(null);
248
+ function useFormReset({
249
+ elementRef,
250
+ inputRef,
251
+ checked,
252
+ commitChecked
253
+ }) {
254
+ const { current: initialChecked } = (0, import_react4.useRef)(checked);
230
255
  (0, import_react4.useEffect)(() => {
231
- if (process.env.NODE_ENV === "production") return;
232
- const element = elementRef.current;
233
- if (!element || element.tagName === "BUTTON") return;
234
- console.error(
235
- `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.`
236
- );
237
- }, []);
238
- return elementRef;
256
+ const form = elementRef.current?.form;
257
+ if (!form) return;
258
+ function onReset(event) {
259
+ queueMicrotask(() => {
260
+ if (event.defaultPrevented) return;
261
+ if (inputRef.current) inputRef.current.checked = checked;
262
+ if (checked === initialChecked) return;
263
+ commitChecked(initialChecked);
264
+ });
265
+ }
266
+ form.addEventListener("reset", onReset);
267
+ return () => form.removeEventListener("reset", onReset);
268
+ }, [elementRef, inputRef, checked, commitChecked, initialChecked]);
239
269
  }
240
270
 
241
271
  // src/switch/SwitchThumb.tsx
@@ -7,8 +7,9 @@ import { ReactElement, Ref, ComponentPropsWithRef } from 'react';
7
7
  * and checked without being declared here.
8
8
  *
9
9
  * `id` in particular is how a switch gets an accessible name, paired with a
10
- * `<label htmlFor>`: the rendered `<button>` is not named implicitly by a wrapping
11
- * `<label>`, and `jsx-a11y/label-has-associated-control` rejects it as a nested control.
10
+ * `<label htmlFor>`. A wrapping `<label>` names the rendered `<button>` too, but
11
+ * `jsx-a11y/label-has-associated-control` rejects a button as a nested control, so the
12
+ * explicit pairing is the one that passes lint.
12
13
  */
13
14
  type SwitchRootOwnProps = {
14
15
  /**
@@ -25,7 +26,7 @@ type SwitchRootOwnProps = {
25
26
  disabled?: boolean;
26
27
  /**
27
28
  * Submits with the enclosing form when checked, mirroring a native checkbox:
28
- * an unchecked control contributes nothing.
29
+ * an unchecked or disabled control contributes nothing.
29
30
  */
30
31
  name?: string;
31
32
  /** Value submitted when checked. Defaults to `"on"`, as a native checkbox does. */
@@ -34,8 +35,8 @@ type SwitchRootOwnProps = {
34
35
  * Component to render instead of the default `<button>`. Props, className, event
35
36
  * handlers and ref are merged onto it.
36
37
  *
37
- * It must render a native `<button>` — a wrapper such as `<Tooltip.Trigger />` that
38
- * 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.
39
40
  */
40
41
  render?: ReactElement;
41
42
  /** Ref to the rendered element. Merged with any ref on the `render` element. */
@@ -51,6 +52,10 @@ type SwitchRootProps = SwitchRootOwnProps & Omit<ComponentPropsWithRef<'button'>
51
52
  * of it is synthesised here. Per the WAI-ARIA switch pattern it carries `role="switch"`
52
53
  * and `aria-checked`.
53
54
  *
55
+ * Inside a form it behaves as a checkbox would there: `form.reset()` returns it to the
56
+ * state it mounted with. The change is reported through `onCheckedChange`, so a
57
+ * controlled switch moves only if its parent accepts it.
58
+ *
54
59
  * It has no accessible name of its own — pair it with a `<label htmlFor>` by `id`, or
55
60
  * pass `aria-label` or `aria-labelledby`. That is the consumer's decision, not one a
56
61
  * headless component should guess.
@@ -87,11 +92,7 @@ declare namespace index_parts {
87
92
  export { SwitchRoot as Root, SwitchThumb as Thumb };
88
93
  }
89
94
 
90
- /**
91
- * A type alias rather than an interface: only aliases get an implicit index
92
- * signature, which is what lets this satisfy the `Record<string, unknown>` the
93
- * state-attribute mapper is generic over.
94
- */
95
+ /** The state Switch.Root shares with its parts, and projects as `data-*` attributes. */
95
96
  type SwitchState = {
96
97
  checked: boolean;
97
98
  disabled: boolean;
@@ -7,8 +7,9 @@ import { ReactElement, Ref, ComponentPropsWithRef } from 'react';
7
7
  * and checked without being declared here.
8
8
  *
9
9
  * `id` in particular is how a switch gets an accessible name, paired with a
10
- * `<label htmlFor>`: the rendered `<button>` is not named implicitly by a wrapping
11
- * `<label>`, and `jsx-a11y/label-has-associated-control` rejects it as a nested control.
10
+ * `<label htmlFor>`. A wrapping `<label>` names the rendered `<button>` too, but
11
+ * `jsx-a11y/label-has-associated-control` rejects a button as a nested control, so the
12
+ * explicit pairing is the one that passes lint.
12
13
  */
13
14
  type SwitchRootOwnProps = {
14
15
  /**
@@ -25,7 +26,7 @@ type SwitchRootOwnProps = {
25
26
  disabled?: boolean;
26
27
  /**
27
28
  * Submits with the enclosing form when checked, mirroring a native checkbox:
28
- * an unchecked control contributes nothing.
29
+ * an unchecked or disabled control contributes nothing.
29
30
  */
30
31
  name?: string;
31
32
  /** Value submitted when checked. Defaults to `"on"`, as a native checkbox does. */
@@ -34,8 +35,8 @@ type SwitchRootOwnProps = {
34
35
  * Component to render instead of the default `<button>`. Props, className, event
35
36
  * handlers and ref are merged onto it.
36
37
  *
37
- * It must render a native `<button>` — a wrapper such as `<Tooltip.Trigger />` that
38
- * 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.
39
40
  */
40
41
  render?: ReactElement;
41
42
  /** Ref to the rendered element. Merged with any ref on the `render` element. */
@@ -51,6 +52,10 @@ type SwitchRootProps = SwitchRootOwnProps & Omit<ComponentPropsWithRef<'button'>
51
52
  * of it is synthesised here. Per the WAI-ARIA switch pattern it carries `role="switch"`
52
53
  * and `aria-checked`.
53
54
  *
55
+ * Inside a form it behaves as a checkbox would there: `form.reset()` returns it to the
56
+ * state it mounted with. The change is reported through `onCheckedChange`, so a
57
+ * controlled switch moves only if its parent accepts it.
58
+ *
54
59
  * It has no accessible name of its own — pair it with a `<label htmlFor>` by `id`, or
55
60
  * pass `aria-label` or `aria-labelledby`. That is the consumer's decision, not one a
56
61
  * headless component should guess.
@@ -87,11 +92,7 @@ declare namespace index_parts {
87
92
  export { SwitchRoot as Root, SwitchThumb as Thumb };
88
93
  }
89
94
 
90
- /**
91
- * A type alias rather than an interface: only aliases get an implicit index
92
- * signature, which is what lets this satisfy the `Record<string, unknown>` the
93
- * state-attribute mapper is generic over.
94
- */
95
+ /** The state Switch.Root shares with its parts, and projects as `data-*` attributes. */
95
96
  type SwitchState = {
96
97
  checked: boolean;
97
98
  disabled: boolean;
@@ -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,8 +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
- const elementRef = useNativeButtonWarning();
67
+ const elementRef = useRef(null);
68
+ const inputRef = useRef(null);
69
+ useFormReset({ elementRef, inputRef, checked, commitChecked });
61
70
  const element = useRender({
62
71
  render,
63
72
  defaultTagName: "button",
@@ -66,36 +75,57 @@ function SwitchRoot({
66
75
  type: "button",
67
76
  role: "switch",
68
77
  "aria-checked": checked,
69
- // The platform suppresses activation, focus and the click handler below.
78
+ // The platform suppresses activation and focus on a native button.
70
79
  disabled: disabled || void 0,
71
80
  ...switchDataAttributes(state),
72
81
  className,
73
82
  children,
74
83
  ref: elementRef,
75
84
  onClick() {
76
- const next = !checked;
77
- setChecked(next);
78
- onCheckedChange?.(next);
85
+ if (disabled) return;
86
+ commitChecked(!checked);
79
87
  }
80
88
  },
81
89
  consumerProps: rest
82
90
  });
83
91
  return /* @__PURE__ */ jsxs(SwitchRootContext.Provider, { value: state, children: [
84
92
  element,
85
- name !== void 0 ? /* @__PURE__ */ jsx("input", { type: "checkbox", hidden: true, readOnly: true, name, value, checked }) : null
93
+ name !== void 0 ? /* @__PURE__ */ jsx(
94
+ "input",
95
+ {
96
+ ref: inputRef,
97
+ type: "checkbox",
98
+ hidden: true,
99
+ readOnly: true,
100
+ name,
101
+ value,
102
+ checked,
103
+ disabled
104
+ }
105
+ ) : null
86
106
  ] });
87
107
  }
88
- function useNativeButtonWarning() {
89
- const elementRef = useRef(null);
108
+ function useFormReset({
109
+ elementRef,
110
+ inputRef,
111
+ checked,
112
+ commitChecked
113
+ }) {
114
+ const { current: initialChecked } = useRef(checked);
90
115
  useEffect(() => {
91
- if (process.env.NODE_ENV === "production") return;
92
- const element = elementRef.current;
93
- if (!element || element.tagName === "BUTTON") return;
94
- console.error(
95
- `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.`
96
- );
97
- }, []);
98
- return elementRef;
116
+ const form = elementRef.current?.form;
117
+ if (!form) return;
118
+ function onReset(event) {
119
+ queueMicrotask(() => {
120
+ if (event.defaultPrevented) return;
121
+ if (inputRef.current) inputRef.current.checked = checked;
122
+ if (checked === initialChecked) return;
123
+ commitChecked(initialChecked);
124
+ });
125
+ }
126
+ form.addEventListener("reset", onReset);
127
+ return () => form.removeEventListener("reset", onReset);
128
+ }, [elementRef, inputRef, checked, commitChecked, initialChecked]);
99
129
  }
100
130
 
101
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.0",
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",
@@ -79,7 +79,7 @@
79
79
  "react-dom": "19.2.8",
80
80
  "tsup": "8.5.1",
81
81
  "typescript": "5.9.3",
82
- "vitest": "3.2.6",
82
+ "vitest": "4.1.11",
83
83
  "@arun-dev/config": "0.0.0"
84
84
  },
85
85
  "scripts": {