@arun-dev/headless 0.1.0 → 0.2.1

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 ADDED
@@ -0,0 +1,99 @@
1
+ # @arun-dev/headless
2
+
3
+ Unstyled React behaviour primitives. Components bring their behaviour, keyboard handling and
4
+ accessibility, and nothing else — no CSS, no class names, no colour. The styling is entirely
5
+ yours.
6
+
7
+ Every part takes a `render` prop, spreads unrecognised props onto the element it renders, and
8
+ projects its state as `data-*` attributes, so any styling approach works: plain CSS, CSS modules,
9
+ utility classes, or a component library of your own.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @arun-dev/headless
15
+ ```
16
+
17
+ Peer dependencies: `react >= 19`, `react-dom >= 19`.
18
+
19
+ ## Components
20
+
21
+ ```tsx
22
+ import { Switch } from '@arun-dev/headless/switch';
23
+
24
+ <Switch.Root defaultChecked onCheckedChange={save} aria-label="Notifications">
25
+ <Switch.Thumb />
26
+ </Switch.Root>;
27
+ ```
28
+
29
+ | Component | Parts | Props |
30
+ | --------- | ----------------------- | --------------------------------------------------------------------------- |
31
+ | `Switch` | `Switch.Root`, `.Thumb` | `checked`, `defaultChecked`, `onCheckedChange`, `disabled`, `name`, `value` |
32
+
33
+ `Switch.Root` renders a native `<button>`, so focus, `Space`, `Enter` and disabled semantics come
34
+ from the platform. It carries `role="switch"` and `aria-checked`, but **no accessible name** —
35
+ wrap it in a `<label>` or pass `aria-label`. A headless component should not guess at your copy.
36
+
37
+ ## State reaches CSS through `data-*`
38
+
39
+ A headless component owns no class names, so state is projected onto the DOM instead. That
40
+ attribute name is the entire contract between behaviour and styling:
41
+
42
+ ```css
43
+ .my-switch[data-checked] {
44
+ background: rebeccapurple;
45
+ }
46
+ ```
47
+
48
+ Both parts of `Switch` emit `data-checked` / `data-unchecked` / `data-disabled`. The negative form
49
+ is emitted deliberately: `:not([data-checked])` would also match any third state added later, so
50
+ matching the state you mean keeps future states additive.
51
+
52
+ ## Controlled and uncontrolled
53
+
54
+ Pass `checked` with `onCheckedChange` and the parent owns the value — the switch will not move on
55
+ its own. Pass `defaultChecked` and the component owns it. `onCheckedChange` fires in both modes.
56
+
57
+ The mode is decided **once, at mount**, and never re-evaluated. A `checked` of `undefined` on the
58
+ first render therefore makes the component uncontrolled for the rest of its life, and every value
59
+ passed afterwards is ignored. When the value arrives asynchronously, coalesce at the call site:
60
+
61
+ ```tsx
62
+ <Switch.Root checked={enabled ?? false} onCheckedChange={setEnabled} />
63
+ ```
64
+
65
+ Mixing the modes, or changing the default after mount, logs a development-only warning.
66
+
67
+ ## Composition
68
+
69
+ Every part takes a `render` prop to change the element, and spreads unrecognised props onto it:
70
+
71
+ ```tsx
72
+ <Switch.Root render={<Tooltip.Trigger />} />
73
+ ```
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.
78
+
79
+ ## Engine
80
+
81
+ The primitives the components are built from are exported from the root, for building your own:
82
+
83
+ ```ts
84
+ import {
85
+ useRender,
86
+ useControlled,
87
+ mergeProps,
88
+ getStateAttributes,
89
+ booleanAttribute,
90
+ } from '@arun-dev/headless';
91
+ ```
92
+
93
+ | Export | Purpose |
94
+ | -------------------- | ----------------------------------------------------------------------------- |
95
+ | `useRender` | Resolves what a part renders — merges props, projects state, applies `render` |
96
+ | `useControlled` | One value, controlled or uncontrolled, decided at mount |
97
+ | `mergeProps` | Merges prop objects: handlers chain, `className` concatenates, refs merge |
98
+ | `getStateAttributes` | Projects a state object onto `data-*` attributes via a declared mapping |
99
+ | `booleanAttribute` | The common mapping — one attribute when true, another when false |
@@ -0,0 +1,136 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
7
+ // src/core/mergeProps.ts
8
+ function isEventHandler(key) {
9
+ return key.length > 2 && key.startsWith("on") && key[2] === key[2]?.toUpperCase();
10
+ }
11
+ function mergeRefs(...refs) {
12
+ return (node) => {
13
+ const cleanups = refs.map((ref) => {
14
+ if (typeof ref === "function") {
15
+ const result = ref(node);
16
+ return typeof result === "function" ? result : () => ref(null);
17
+ }
18
+ if (ref && typeof ref === "object") {
19
+ ref.current = node;
20
+ return () => {
21
+ ref.current = null;
22
+ };
23
+ }
24
+ return void 0;
25
+ });
26
+ return () => {
27
+ cleanups.forEach((cleanup) => {
28
+ if (typeof cleanup === "function") cleanup();
29
+ });
30
+ };
31
+ };
32
+ }
33
+ function mergeProps(...objects) {
34
+ const merged = {};
35
+ for (const props of objects) {
36
+ if (!props) continue;
37
+ for (const key of Object.keys(props)) {
38
+ const value = props[key];
39
+ if (value === void 0) continue;
40
+ if (isEventHandler(key) && typeof value === "function") {
41
+ const existing = merged[key];
42
+ merged[key] = typeof existing === "function" ? (...args) => {
43
+ existing(...args);
44
+ value(...args);
45
+ } : value;
46
+ } else if (key === "className") {
47
+ merged.className = merged.className ? `${String(merged.className)} ${String(value)}` : value;
48
+ } else if (key === "style") {
49
+ merged.style = { ...merged.style, ...value };
50
+ } else if (key === "ref") {
51
+ merged.ref = merged.ref ? mergeRefs(merged.ref, value) : value;
52
+ } else {
53
+ merged[key] = value;
54
+ }
55
+ }
56
+ }
57
+ return merged;
58
+ }
59
+
60
+ // src/core/stateAttributes.ts
61
+ function getStateAttributes(state, mapping) {
62
+ if (!mapping) return {};
63
+ const attributes = {};
64
+ for (const key of Object.keys(state)) {
65
+ const toAttributes = mapping[key];
66
+ if (!toAttributes) continue;
67
+ const result = toAttributes(state[key]);
68
+ if (result) Object.assign(attributes, result);
69
+ }
70
+ return attributes;
71
+ }
72
+ function booleanAttribute(whenTrue, whenFalse) {
73
+ return (value) => {
74
+ if (value) return { [whenTrue]: "" };
75
+ return whenFalse ? { [whenFalse]: "" } : null;
76
+ };
77
+ }
78
+
79
+ // src/core/useRender.ts
80
+ import { cloneElement, createElement, isValidElement } from "react";
81
+ function useRender({
82
+ render,
83
+ defaultTagName,
84
+ props,
85
+ state,
86
+ stateAttributes
87
+ }) {
88
+ const attributes = state ? getStateAttributes(state, stateAttributes) : {};
89
+ const merged = mergeProps(attributes, ...props);
90
+ if (isValidElement(render)) {
91
+ return cloneElement(render, mergeProps(merged, render.props));
92
+ }
93
+ return createElement(defaultTagName, merged);
94
+ }
95
+
96
+ // src/core/useControlled.ts
97
+ import { useCallback, useRef, useState } from "react";
98
+ function useControlled({
99
+ controlled,
100
+ default: defaultValue,
101
+ name = "Component",
102
+ state = "value"
103
+ }) {
104
+ const { current: isControlled } = useRef(controlled !== void 0);
105
+ const [uncontrolled, setUncontrolled] = useState(defaultValue);
106
+ const defaultRef = useRef(defaultValue);
107
+ const value = isControlled ? controlled : uncontrolled;
108
+ if (process.env.NODE_ENV !== "production") {
109
+ if (isControlled !== (controlled !== void 0)) {
110
+ console.error(
111
+ `${name}: cannot switch between controlled and uncontrolled \`${state}\`. Decide which one this component is for the whole of its life.`
112
+ );
113
+ }
114
+ if (!isControlled && defaultRef.current !== defaultValue) {
115
+ console.error(
116
+ `${name}: cannot change the default \`${state}\` after mount. It is only read once, so later changes are silently ignored.`
117
+ );
118
+ }
119
+ }
120
+ const setValue = useCallback(
121
+ (next) => {
122
+ if (!isControlled) setUncontrolled(next);
123
+ },
124
+ [isControlled]
125
+ );
126
+ return [value, setValue];
127
+ }
128
+
129
+ export {
130
+ __export,
131
+ mergeProps,
132
+ getStateAttributes,
133
+ booleanAttribute,
134
+ useRender,
135
+ useControlled
136
+ };
package/dist/index.js CHANGED
@@ -1,126 +1,10 @@
1
- // src/core/mergeProps.ts
2
- function isEventHandler(key) {
3
- return key.length > 2 && key.startsWith("on") && key[2] === key[2]?.toUpperCase();
4
- }
5
- function mergeRefs(...refs) {
6
- return (node) => {
7
- const cleanups = refs.map((ref) => {
8
- if (typeof ref === "function") {
9
- const result = ref(node);
10
- return typeof result === "function" ? result : () => ref(null);
11
- }
12
- if (ref && typeof ref === "object") {
13
- ref.current = node;
14
- return () => {
15
- ref.current = null;
16
- };
17
- }
18
- return void 0;
19
- });
20
- return () => {
21
- cleanups.forEach((cleanup) => {
22
- if (typeof cleanup === "function") cleanup();
23
- });
24
- };
25
- };
26
- }
27
- function mergeProps(...objects) {
28
- const merged = {};
29
- for (const props of objects) {
30
- if (!props) continue;
31
- for (const key of Object.keys(props)) {
32
- const value = props[key];
33
- if (value === void 0) continue;
34
- if (isEventHandler(key) && typeof value === "function") {
35
- const existing = merged[key];
36
- merged[key] = typeof existing === "function" ? (...args) => {
37
- existing(...args);
38
- value(...args);
39
- } : value;
40
- } else if (key === "className") {
41
- merged.className = merged.className ? `${String(merged.className)} ${String(value)}` : value;
42
- } else if (key === "style") {
43
- merged.style = { ...merged.style, ...value };
44
- } else if (key === "ref") {
45
- merged.ref = merged.ref ? mergeRefs(merged.ref, value) : value;
46
- } else {
47
- merged[key] = value;
48
- }
49
- }
50
- }
51
- return merged;
52
- }
53
-
54
- // src/core/useRender.ts
55
- import { cloneElement, createElement, isValidElement } from "react";
56
-
57
- // src/core/stateAttributes.ts
58
- function getStateAttributes(state, mapping) {
59
- if (!mapping) return {};
60
- const attributes = {};
61
- for (const key of Object.keys(state)) {
62
- const toAttributes = mapping[key];
63
- if (!toAttributes) continue;
64
- const result = toAttributes(state[key]);
65
- if (result) Object.assign(attributes, result);
66
- }
67
- return attributes;
68
- }
69
- function booleanAttribute(whenTrue, whenFalse) {
70
- return (value) => {
71
- if (value) return { [whenTrue]: "" };
72
- return whenFalse ? { [whenFalse]: "" } : null;
73
- };
74
- }
75
-
76
- // src/core/useRender.ts
77
- function useRender({
78
- render,
79
- defaultTagName,
80
- props,
81
- state,
82
- stateAttributes
83
- }) {
84
- const attributes = state ? getStateAttributes(state, stateAttributes) : {};
85
- const merged = mergeProps(attributes, ...props);
86
- if (isValidElement(render)) {
87
- return cloneElement(render, mergeProps(merged, render.props));
88
- }
89
- return createElement(defaultTagName, merged);
90
- }
91
-
92
- // src/core/useControlled.ts
93
- import { useCallback, useRef, useState } from "react";
94
- function useControlled({
95
- controlled,
96
- default: defaultValue,
97
- name = "Component",
98
- state = "value"
99
- }) {
100
- const { current: isControlled } = useRef(controlled !== void 0);
101
- const [uncontrolled, setUncontrolled] = useState(defaultValue);
102
- const defaultRef = useRef(defaultValue);
103
- const value = isControlled ? controlled : uncontrolled;
104
- if (process.env.NODE_ENV !== "production") {
105
- if (isControlled !== (controlled !== void 0)) {
106
- console.error(
107
- `${name}: cannot switch between controlled and uncontrolled \`${state}\`. Decide which one this component is for the whole of its life.`
108
- );
109
- }
110
- if (!isControlled && defaultRef.current !== defaultValue) {
111
- console.error(
112
- `${name}: cannot change the default \`${state}\` after mount. It is only read once, so later changes are silently ignored.`
113
- );
114
- }
115
- }
116
- const setValue = useCallback(
117
- (next) => {
118
- if (!isControlled) setUncontrolled(next);
119
- },
120
- [isControlled]
121
- );
122
- return [value, setValue];
123
- }
1
+ import {
2
+ booleanAttribute,
3
+ getStateAttributes,
4
+ mergeProps,
5
+ useControlled,
6
+ useRender
7
+ } from "./chunk-DAO4JK6U.js";
124
8
  export {
125
9
  booleanAttribute,
126
10
  getStateAttributes,
@@ -0,0 +1,246 @@
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/switch/index.ts
21
+ var switch_exports = {};
22
+ __export(switch_exports, {
23
+ Switch: () => index_parts_exports
24
+ });
25
+ module.exports = __toCommonJS(switch_exports);
26
+
27
+ // src/switch/index.parts.ts
28
+ var index_parts_exports = {};
29
+ __export(index_parts_exports, {
30
+ Root: () => SwitchRoot,
31
+ Thumb: () => SwitchThumb
32
+ });
33
+
34
+ // src/switch/root/SwitchRoot.tsx
35
+ var import_react4 = require("react");
36
+
37
+ // src/core/useControlled.ts
38
+ var import_react = require("react");
39
+ function useControlled({
40
+ controlled,
41
+ default: defaultValue,
42
+ name = "Component",
43
+ state = "value"
44
+ }) {
45
+ const { current: isControlled } = (0, import_react.useRef)(controlled !== void 0);
46
+ const [uncontrolled, setUncontrolled] = (0, import_react.useState)(defaultValue);
47
+ const defaultRef = (0, import_react.useRef)(defaultValue);
48
+ const value = isControlled ? controlled : uncontrolled;
49
+ if (process.env.NODE_ENV !== "production") {
50
+ if (isControlled !== (controlled !== void 0)) {
51
+ console.error(
52
+ `${name}: cannot switch between controlled and uncontrolled \`${state}\`. Decide which one this component is for the whole of its life.`
53
+ );
54
+ }
55
+ if (!isControlled && defaultRef.current !== defaultValue) {
56
+ console.error(
57
+ `${name}: cannot change the default \`${state}\` after mount. It is only read once, so later changes are silently ignored.`
58
+ );
59
+ }
60
+ }
61
+ const setValue = (0, import_react.useCallback)(
62
+ (next) => {
63
+ if (!isControlled) setUncontrolled(next);
64
+ },
65
+ [isControlled]
66
+ );
67
+ return [value, setValue];
68
+ }
69
+
70
+ // src/core/useRender.ts
71
+ var import_react2 = require("react");
72
+
73
+ // src/core/mergeProps.ts
74
+ function isEventHandler(key) {
75
+ return key.length > 2 && key.startsWith("on") && key[2] === key[2]?.toUpperCase();
76
+ }
77
+ function mergeRefs(...refs) {
78
+ return (node) => {
79
+ const cleanups = refs.map((ref) => {
80
+ if (typeof ref === "function") {
81
+ const result = ref(node);
82
+ return typeof result === "function" ? result : () => ref(null);
83
+ }
84
+ if (ref && typeof ref === "object") {
85
+ ref.current = node;
86
+ return () => {
87
+ ref.current = null;
88
+ };
89
+ }
90
+ return void 0;
91
+ });
92
+ return () => {
93
+ cleanups.forEach((cleanup) => {
94
+ if (typeof cleanup === "function") cleanup();
95
+ });
96
+ };
97
+ };
98
+ }
99
+ function mergeProps(...objects) {
100
+ const merged = {};
101
+ for (const props of objects) {
102
+ if (!props) continue;
103
+ for (const key of Object.keys(props)) {
104
+ const value = props[key];
105
+ if (value === void 0) continue;
106
+ if (isEventHandler(key) && typeof value === "function") {
107
+ const existing = merged[key];
108
+ merged[key] = typeof existing === "function" ? (...args) => {
109
+ existing(...args);
110
+ value(...args);
111
+ } : value;
112
+ } else if (key === "className") {
113
+ merged.className = merged.className ? `${String(merged.className)} ${String(value)}` : value;
114
+ } else if (key === "style") {
115
+ merged.style = { ...merged.style, ...value };
116
+ } else if (key === "ref") {
117
+ merged.ref = merged.ref ? mergeRefs(merged.ref, value) : value;
118
+ } else {
119
+ merged[key] = value;
120
+ }
121
+ }
122
+ }
123
+ return merged;
124
+ }
125
+
126
+ // src/core/stateAttributes.ts
127
+ function getStateAttributes(state, mapping) {
128
+ if (!mapping) return {};
129
+ const attributes = {};
130
+ for (const key of Object.keys(state)) {
131
+ const toAttributes = mapping[key];
132
+ if (!toAttributes) continue;
133
+ const result = toAttributes(state[key]);
134
+ if (result) Object.assign(attributes, result);
135
+ }
136
+ return attributes;
137
+ }
138
+ function booleanAttribute(whenTrue, whenFalse) {
139
+ return (value) => {
140
+ if (value) return { [whenTrue]: "" };
141
+ return whenFalse ? { [whenFalse]: "" } : null;
142
+ };
143
+ }
144
+
145
+ // src/core/useRender.ts
146
+ function useRender({
147
+ render,
148
+ defaultTagName,
149
+ props,
150
+ state,
151
+ stateAttributes
152
+ }) {
153
+ const attributes = state ? getStateAttributes(state, stateAttributes) : {};
154
+ const merged = mergeProps(attributes, ...props);
155
+ if ((0, import_react2.isValidElement)(render)) {
156
+ return (0, import_react2.cloneElement)(render, mergeProps(merged, render.props));
157
+ }
158
+ return (0, import_react2.createElement)(defaultTagName, merged);
159
+ }
160
+
161
+ // src/switch/SwitchRootContext.ts
162
+ var import_react3 = require("react");
163
+ var SwitchRootContext = (0, import_react3.createContext)(null);
164
+ function useSwitchRootContext() {
165
+ const context = (0, import_react3.useContext)(SwitchRootContext);
166
+ if (context === null) {
167
+ throw new Error("<Switch.Thumb> must be rendered inside <Switch.Root>.");
168
+ }
169
+ return context;
170
+ }
171
+
172
+ // src/switch/stateAttributes.ts
173
+ var switchStateAttributes = {
174
+ checked: booleanAttribute("data-checked", "data-unchecked"),
175
+ disabled: booleanAttribute("data-disabled")
176
+ };
177
+
178
+ // src/switch/root/SwitchRoot.tsx
179
+ var import_jsx_runtime = require("react/jsx-runtime");
180
+ function SwitchRoot({
181
+ checked: checkedProp,
182
+ defaultChecked,
183
+ onCheckedChange,
184
+ disabled = false,
185
+ name,
186
+ value = "on",
187
+ className,
188
+ children,
189
+ render,
190
+ ...rest
191
+ }) {
192
+ const [checked, setChecked] = useControlled({
193
+ controlled: checkedProp,
194
+ default: defaultChecked ?? false,
195
+ name: "Switch.Root",
196
+ state: "checked"
197
+ });
198
+ const state = (0, import_react4.useMemo)(() => ({ checked, disabled }), [checked, disabled]);
199
+ const element = useRender({
200
+ render,
201
+ defaultTagName: "button",
202
+ state,
203
+ stateAttributes: switchStateAttributes,
204
+ props: [
205
+ {
206
+ type: "button",
207
+ role: "switch",
208
+ "aria-checked": checked,
209
+ disabled,
210
+ className,
211
+ children,
212
+ onClick() {
213
+ const next = !checked;
214
+ setChecked(next);
215
+ onCheckedChange?.(next);
216
+ }
217
+ },
218
+ rest
219
+ ]
220
+ });
221
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(SwitchRootContext.Provider, { value: state, children: [
222
+ element,
223
+ name !== void 0 && checked ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "hidden", name, value }) : null
224
+ ] });
225
+ }
226
+
227
+ // src/switch/thumb/SwitchThumb.tsx
228
+ function SwitchThumb({
229
+ className,
230
+ children,
231
+ render,
232
+ ...rest
233
+ }) {
234
+ const state = useSwitchRootContext();
235
+ return useRender({
236
+ render,
237
+ defaultTagName: "span",
238
+ state,
239
+ stateAttributes: switchStateAttributes,
240
+ props: [{ "aria-hidden": true, className, children }, rest]
241
+ });
242
+ }
243
+ // Annotate the CommonJS export names for ESM import in node:
244
+ 0 && (module.exports = {
245
+ Switch
246
+ });
@@ -0,0 +1,82 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ReactElement, Ref } from 'react';
3
+
4
+ interface SwitchRootProps {
5
+ /**
6
+ * Controlled state. Provide `onCheckedChange` alongside it.
7
+ *
8
+ * Never `undefined` once mounted. The mode is latched at mount, so an `undefined`
9
+ * first render makes the switch uncontrolled for good and every value passed later
10
+ * is ignored. Coalesce at the call site — `checked={x ?? false}`.
11
+ */
12
+ checked?: boolean;
13
+ /** Initial state when uncontrolled. Read once, at mount. */
14
+ defaultChecked?: boolean;
15
+ onCheckedChange?: (checked: boolean) => void;
16
+ disabled?: boolean;
17
+ /**
18
+ * Submits with the enclosing form when checked, mirroring a native checkbox:
19
+ * an unchecked control contributes nothing.
20
+ */
21
+ name?: string;
22
+ /** Value submitted when checked. Defaults to `"on"`, as a native checkbox does. */
23
+ value?: string;
24
+ className?: string;
25
+ children?: ReactNode;
26
+ /**
27
+ * Element to render instead of the default `<button>`. Props, className, event
28
+ * handlers and ref are merged onto it.
29
+ */
30
+ render?: ReactElement;
31
+ ref?: Ref<HTMLElement>;
32
+ }
33
+ /**
34
+ * A switch — an immediate on/off control, distinct from a checkbox in that it takes
35
+ * effect at once rather than on submit.
36
+ *
37
+ * Renders a native `<button>`, which supplies focusability, Space and Enter
38
+ * activation, and the disabled semantics for free. Per the WAI-ARIA switch pattern
39
+ * it carries `role="switch"` and `aria-checked`.
40
+ *
41
+ * It has no accessible name of its own — wrap it in a `<label>`, or pass `aria-label`
42
+ * or `aria-labelledby`. That is the consumer's decision, not something a headless
43
+ * component should guess.
44
+ */
45
+ declare function SwitchRoot({ checked: checkedProp, defaultChecked, onCheckedChange, disabled, name, value, className, children, render, ...rest }: SwitchRootProps & Record<string, unknown>): react.JSX.Element;
46
+
47
+ interface SwitchThumbProps {
48
+ className?: string;
49
+ children?: ReactNode;
50
+ /** Element to render instead of the default `<span>`. */
51
+ render?: ReactElement;
52
+ ref?: Ref<HTMLElement>;
53
+ }
54
+ /**
55
+ * The moving part of the switch.
56
+ *
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:
60
+ *
61
+ * .switch-thumb[data-checked] { transform: translateX(100%); }
62
+ *
63
+ * Purely presentational — hidden from assistive technology, since the Root already
64
+ * announces the state.
65
+ */
66
+ declare function SwitchThumb({ className, children, render, ...rest }: SwitchThumbProps & Record<string, unknown>): ReactElement<unknown, string | react.JSXElementConstructor<any>>;
67
+
68
+ declare namespace index_parts {
69
+ export { SwitchRoot as Root, SwitchThumb as Thumb };
70
+ }
71
+
72
+ /**
73
+ * A type alias rather than an interface: only aliases get an implicit index
74
+ * signature, which is what lets this satisfy the `Record<string, unknown>` the
75
+ * state-attribute mapper is generic over.
76
+ */
77
+ type SwitchState = {
78
+ checked: boolean;
79
+ disabled: boolean;
80
+ };
81
+
82
+ export { index_parts as Switch, type SwitchRootProps, type SwitchState, type SwitchThumbProps };
@@ -0,0 +1,82 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ReactElement, Ref } from 'react';
3
+
4
+ interface SwitchRootProps {
5
+ /**
6
+ * Controlled state. Provide `onCheckedChange` alongside it.
7
+ *
8
+ * Never `undefined` once mounted. The mode is latched at mount, so an `undefined`
9
+ * first render makes the switch uncontrolled for good and every value passed later
10
+ * is ignored. Coalesce at the call site — `checked={x ?? false}`.
11
+ */
12
+ checked?: boolean;
13
+ /** Initial state when uncontrolled. Read once, at mount. */
14
+ defaultChecked?: boolean;
15
+ onCheckedChange?: (checked: boolean) => void;
16
+ disabled?: boolean;
17
+ /**
18
+ * Submits with the enclosing form when checked, mirroring a native checkbox:
19
+ * an unchecked control contributes nothing.
20
+ */
21
+ name?: string;
22
+ /** Value submitted when checked. Defaults to `"on"`, as a native checkbox does. */
23
+ value?: string;
24
+ className?: string;
25
+ children?: ReactNode;
26
+ /**
27
+ * Element to render instead of the default `<button>`. Props, className, event
28
+ * handlers and ref are merged onto it.
29
+ */
30
+ render?: ReactElement;
31
+ ref?: Ref<HTMLElement>;
32
+ }
33
+ /**
34
+ * A switch — an immediate on/off control, distinct from a checkbox in that it takes
35
+ * effect at once rather than on submit.
36
+ *
37
+ * Renders a native `<button>`, which supplies focusability, Space and Enter
38
+ * activation, and the disabled semantics for free. Per the WAI-ARIA switch pattern
39
+ * it carries `role="switch"` and `aria-checked`.
40
+ *
41
+ * It has no accessible name of its own — wrap it in a `<label>`, or pass `aria-label`
42
+ * or `aria-labelledby`. That is the consumer's decision, not something a headless
43
+ * component should guess.
44
+ */
45
+ declare function SwitchRoot({ checked: checkedProp, defaultChecked, onCheckedChange, disabled, name, value, className, children, render, ...rest }: SwitchRootProps & Record<string, unknown>): react.JSX.Element;
46
+
47
+ interface SwitchThumbProps {
48
+ className?: string;
49
+ children?: ReactNode;
50
+ /** Element to render instead of the default `<span>`. */
51
+ render?: ReactElement;
52
+ ref?: Ref<HTMLElement>;
53
+ }
54
+ /**
55
+ * The moving part of the switch.
56
+ *
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:
60
+ *
61
+ * .switch-thumb[data-checked] { transform: translateX(100%); }
62
+ *
63
+ * Purely presentational — hidden from assistive technology, since the Root already
64
+ * announces the state.
65
+ */
66
+ declare function SwitchThumb({ className, children, render, ...rest }: SwitchThumbProps & Record<string, unknown>): ReactElement<unknown, string | react.JSXElementConstructor<any>>;
67
+
68
+ declare namespace index_parts {
69
+ export { SwitchRoot as Root, SwitchThumb as Thumb };
70
+ }
71
+
72
+ /**
73
+ * A type alias rather than an interface: only aliases get an implicit index
74
+ * signature, which is what lets this satisfy the `Record<string, unknown>` the
75
+ * state-attribute mapper is generic over.
76
+ */
77
+ type SwitchState = {
78
+ checked: boolean;
79
+ disabled: boolean;
80
+ };
81
+
82
+ export { index_parts as Switch, type SwitchRootProps, type SwitchState, type SwitchThumbProps };
@@ -0,0 +1,102 @@
1
+ import {
2
+ __export,
3
+ booleanAttribute,
4
+ useControlled,
5
+ useRender
6
+ } from "../chunk-DAO4JK6U.js";
7
+
8
+ // src/switch/index.parts.ts
9
+ var index_parts_exports = {};
10
+ __export(index_parts_exports, {
11
+ Root: () => SwitchRoot,
12
+ Thumb: () => SwitchThumb
13
+ });
14
+
15
+ // src/switch/root/SwitchRoot.tsx
16
+ import { useMemo } from "react";
17
+
18
+ // src/switch/SwitchRootContext.ts
19
+ import { createContext, useContext } from "react";
20
+ var SwitchRootContext = createContext(null);
21
+ function useSwitchRootContext() {
22
+ const context = useContext(SwitchRootContext);
23
+ if (context === null) {
24
+ throw new Error("<Switch.Thumb> must be rendered inside <Switch.Root>.");
25
+ }
26
+ return context;
27
+ }
28
+
29
+ // src/switch/stateAttributes.ts
30
+ var switchStateAttributes = {
31
+ checked: booleanAttribute("data-checked", "data-unchecked"),
32
+ disabled: booleanAttribute("data-disabled")
33
+ };
34
+
35
+ // src/switch/root/SwitchRoot.tsx
36
+ import { jsx, jsxs } from "react/jsx-runtime";
37
+ function SwitchRoot({
38
+ checked: checkedProp,
39
+ defaultChecked,
40
+ onCheckedChange,
41
+ disabled = false,
42
+ name,
43
+ value = "on",
44
+ className,
45
+ children,
46
+ render,
47
+ ...rest
48
+ }) {
49
+ const [checked, setChecked] = useControlled({
50
+ controlled: checkedProp,
51
+ default: defaultChecked ?? false,
52
+ name: "Switch.Root",
53
+ state: "checked"
54
+ });
55
+ const state = useMemo(() => ({ checked, disabled }), [checked, disabled]);
56
+ const element = useRender({
57
+ render,
58
+ defaultTagName: "button",
59
+ state,
60
+ stateAttributes: switchStateAttributes,
61
+ props: [
62
+ {
63
+ type: "button",
64
+ role: "switch",
65
+ "aria-checked": checked,
66
+ disabled,
67
+ className,
68
+ children,
69
+ onClick() {
70
+ const next = !checked;
71
+ setChecked(next);
72
+ onCheckedChange?.(next);
73
+ }
74
+ },
75
+ rest
76
+ ]
77
+ });
78
+ return /* @__PURE__ */ jsxs(SwitchRootContext.Provider, { value: state, children: [
79
+ element,
80
+ name !== void 0 && checked ? /* @__PURE__ */ jsx("input", { type: "hidden", name, value }) : null
81
+ ] });
82
+ }
83
+
84
+ // src/switch/thumb/SwitchThumb.tsx
85
+ function SwitchThumb({
86
+ className,
87
+ children,
88
+ render,
89
+ ...rest
90
+ }) {
91
+ const state = useSwitchRootContext();
92
+ return useRender({
93
+ render,
94
+ defaultTagName: "span",
95
+ state,
96
+ stateAttributes: switchStateAttributes,
97
+ props: [{ "aria-hidden": true, className, children }, rest]
98
+ });
99
+ }
100
+ export {
101
+ index_parts_exports as Switch
102
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arun-dev/headless",
3
- "version": "0.1.0",
4
- "description": "Unstyled React behaviour primitives — the render engine and state plumbing @arun-dev/ui is built on. Ships no CSS and no class names.",
3
+ "version": "0.2.1",
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",
7
7
  "repository": {
@@ -33,6 +33,16 @@
33
33
  "types": "./dist/index.d.cts",
34
34
  "default": "./dist/index.cjs"
35
35
  }
36
+ },
37
+ "./switch": {
38
+ "import": {
39
+ "types": "./dist/switch/index.d.ts",
40
+ "default": "./dist/switch/index.js"
41
+ },
42
+ "require": {
43
+ "types": "./dist/switch/index.d.cts",
44
+ "default": "./dist/switch/index.cjs"
45
+ }
36
46
  }
37
47
  },
38
48
  "publishConfig": {