@arun-dev/headless 4.1.0 → 4.3.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/dist/dialog/index.cjs +333 -0
- package/dist/dialog/index.d.cts +117 -0
- package/dist/dialog/index.d.ts +117 -0
- package/dist/dialog/index.js +192 -0
- package/dist/radio-group/index.cjs +298 -0
- package/dist/radio-group/index.d.cts +128 -0
- package/dist/radio-group/index.d.ts +128 -0
- package/dist/radio-group/index.js +157 -0
- package/package.json +25 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactElement, Ref, ComponentPropsWithRef } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RadioGroup.Root's own props. Everything else — `id`, `className`, `children`,
|
|
6
|
+
* `aria-*`, `data-*`, event handlers — comes from React's own `<div>` props.
|
|
7
|
+
*
|
|
8
|
+
* `aria-labelledby` in particular is how a group gets its accessible name, pointing at
|
|
9
|
+
* the visible caption. A `<fieldset>` with a `<legend>` does the same through `render`.
|
|
10
|
+
*/
|
|
11
|
+
type RadioGroupRootOwnProps = {
|
|
12
|
+
/**
|
|
13
|
+
* Controlled value — the `value` of the selected item, or `null` for none. Provide
|
|
14
|
+
* `onValueChange` alongside it.
|
|
15
|
+
*
|
|
16
|
+
* Never `undefined` once mounted. The mode is latched at mount, so an `undefined`
|
|
17
|
+
* first render makes the group uncontrolled for good. Use `null` for "nothing
|
|
18
|
+
* selected" — `value={x ?? null}`.
|
|
19
|
+
*/
|
|
20
|
+
value?: string | null;
|
|
21
|
+
/** Initial value when uncontrolled. Read once, at mount. */
|
|
22
|
+
defaultValue?: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Called with the value being moved to, in both controlled and uncontrolled modes.
|
|
25
|
+
* A user can only move to a value; `null` arrives only from `form.reset()` returning
|
|
26
|
+
* a group that mounted with nothing selected.
|
|
27
|
+
*/
|
|
28
|
+
onValueChange?: (value: string | null) => void;
|
|
29
|
+
/**
|
|
30
|
+
* The name every radio in the group shares, and submits under.
|
|
31
|
+
*
|
|
32
|
+
* The platform groups radios by name — that is what makes arrow keys move between
|
|
33
|
+
* them and keeps one checked at a time — so one is generated when this is omitted.
|
|
34
|
+
* A generated name still submits: pass your own when the group is inside a form.
|
|
35
|
+
*/
|
|
36
|
+
name?: string;
|
|
37
|
+
/** Disables every radio in the group. */
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
/** Blocks form submission until a radio is selected, via the platform's validation. */
|
|
40
|
+
required?: boolean;
|
|
41
|
+
/** Associates the radios with a `<form>` by id, when the group is rendered outside it. */
|
|
42
|
+
form?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Component to render instead of the default `<div>` — a `<fieldset>`, say. Props,
|
|
45
|
+
* className, event handlers and ref are merged onto it.
|
|
46
|
+
*/
|
|
47
|
+
render?: ReactElement;
|
|
48
|
+
/** Ref to the rendered element. Merged with any ref on the `render` element. */
|
|
49
|
+
ref?: Ref<HTMLElement>;
|
|
50
|
+
};
|
|
51
|
+
type RadioGroupRootProps = RadioGroupRootOwnProps & Omit<ComponentPropsWithRef<'div'>, keyof RadioGroupRootOwnProps>;
|
|
52
|
+
/**
|
|
53
|
+
* A set of radios, of which at most one is selected.
|
|
54
|
+
*
|
|
55
|
+
* The radios are native `<input type="radio">` elements — the native element passes all
|
|
56
|
+
* three of decision 7's tests, so nothing here rebuilds what the platform already does.
|
|
57
|
+
* Arrow keys move the selection, Tab enters the group at the checked radio, `required`
|
|
58
|
+
* blocks submission and the form submits the checked value, all without JavaScript.
|
|
59
|
+
*
|
|
60
|
+
* What the group adds is the one thing the platform leaves to the page: a single value
|
|
61
|
+
* in React. It owns that value, and every radio derives `checked` from it.
|
|
62
|
+
*
|
|
63
|
+
* Inside a form, `form.reset()` returns the group to the value it mounted with. The
|
|
64
|
+
* platform resets the inputs by itself, but React's value would not follow — the same
|
|
65
|
+
* seam `useFormReset` closes for Checkbox and Switch, closed here for a group.
|
|
66
|
+
*/
|
|
67
|
+
declare function RadioGroupRoot({ value: valueProp, defaultValue, onValueChange, name: nameProp, disabled, required, form, className, children, render, ...rest }: RadioGroupRootProps): react.JSX.Element;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* RadioGroup.Item's own props. Everything else — `id`, `className`, `aria-*`, `data-*`,
|
|
71
|
+
* event handlers — comes from React's own `<input>` props.
|
|
72
|
+
*
|
|
73
|
+
* `name`, `checked`, `defaultChecked`, `required` and `form` belong to the group and are
|
|
74
|
+
* not accepted here: a radio's checkedness is the group's value, not its own. `type` is
|
|
75
|
+
* always `radio`, and `children` is left out because an `<input>` cannot hold any.
|
|
76
|
+
*/
|
|
77
|
+
type RadioGroupItemOwnProps = {
|
|
78
|
+
/** The group's value when this radio is selected, and what the form submits for it. */
|
|
79
|
+
value: string;
|
|
80
|
+
/** Disables this radio alone. A disabled group disables it regardless. */
|
|
81
|
+
disabled?: boolean;
|
|
82
|
+
/** Element to render instead of the default `<input type="radio">`. */
|
|
83
|
+
render?: ReactElement;
|
|
84
|
+
/** Ref to the rendered element. Merged with any ref on the `render` element. */
|
|
85
|
+
ref?: Ref<HTMLElement>;
|
|
86
|
+
};
|
|
87
|
+
type RadioGroupItemProps = RadioGroupItemOwnProps & Omit<ComponentPropsWithRef<'input'>, keyof RadioGroupItemOwnProps | 'type' | 'name' | 'checked' | 'defaultChecked' | 'required' | 'form' | 'children'>;
|
|
88
|
+
/**
|
|
89
|
+
* One radio — a native `<input type="radio">`.
|
|
90
|
+
*
|
|
91
|
+
* Void, so it has no parts: there is no indicator to hold, and the dot is drawn by CSS
|
|
92
|
+
* off `:checked` or the `data-*` attributes. Decision 11 — one element, one component.
|
|
93
|
+
*
|
|
94
|
+
* It has no accessible name of its own — wrap it in a `<label>`, pair it with one by
|
|
95
|
+
* `id`, or pass `aria-label`. A wrapping label names it natively, but
|
|
96
|
+
* jsx-a11y/label-has-associated-control cannot see through this component to the input,
|
|
97
|
+
* so under that rule pair it by `id` too.
|
|
98
|
+
*/
|
|
99
|
+
declare function RadioGroupItem({ value, disabled: disabledProp, className, render, ...rest }: RadioGroupItemProps): ReactElement<unknown, string | react.JSXElementConstructor<any>>;
|
|
100
|
+
|
|
101
|
+
declare namespace index_parts {
|
|
102
|
+
export { RadioGroupItem as Item, RadioGroupRoot as Root };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The state RadioGroup.Root shares with its items.
|
|
107
|
+
*
|
|
108
|
+
* `value` is `null` when nothing is selected — a real state for a radio group, and the
|
|
109
|
+
* one a native group starts in. `null` rather than `undefined`, because `undefined` is
|
|
110
|
+
* what tells `useControlled` a group is uncontrolled.
|
|
111
|
+
*/
|
|
112
|
+
type RadioGroupState = {
|
|
113
|
+
value: string | null;
|
|
114
|
+
disabled: boolean;
|
|
115
|
+
required: boolean;
|
|
116
|
+
/** Always set — the consumer's, or one generated so the radios still form a group. */
|
|
117
|
+
name: string;
|
|
118
|
+
form: string | undefined;
|
|
119
|
+
/** The group's single setter. Every change goes through it (decision 10, rule 5). */
|
|
120
|
+
select: (value: string) => void;
|
|
121
|
+
};
|
|
122
|
+
/** What one radio projects as `data-*` attributes. */
|
|
123
|
+
type RadioGroupItemState = {
|
|
124
|
+
checked: boolean;
|
|
125
|
+
disabled: boolean;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export { index_parts as RadioGroup, type RadioGroupItemProps, type RadioGroupItemState, type RadioGroupRootProps, type RadioGroupState };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useControlled
|
|
3
|
+
} from "../chunk-UEIB3I3T.js";
|
|
4
|
+
import {
|
|
5
|
+
__export,
|
|
6
|
+
useRender
|
|
7
|
+
} from "../chunk-OQTWWZ4B.js";
|
|
8
|
+
|
|
9
|
+
// src/radio-group/index.parts.ts
|
|
10
|
+
var index_parts_exports = {};
|
|
11
|
+
__export(index_parts_exports, {
|
|
12
|
+
Item: () => RadioGroupItem,
|
|
13
|
+
Root: () => RadioGroupRoot
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// src/radio-group/RadioGroupRoot.tsx
|
|
17
|
+
import { useCallback, useEffect, useId, useMemo, useRef } from "react";
|
|
18
|
+
|
|
19
|
+
// src/radio-group/RadioGroupRootContext.ts
|
|
20
|
+
import { createContext, useContext } from "react";
|
|
21
|
+
var RadioGroupRootContext = createContext(null);
|
|
22
|
+
function useRadioGroupRootContext() {
|
|
23
|
+
const context = useContext(RadioGroupRootContext);
|
|
24
|
+
if (context === null) {
|
|
25
|
+
throw new Error("<RadioGroup.Item> must be rendered inside <RadioGroup.Root>.");
|
|
26
|
+
}
|
|
27
|
+
return context;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/radio-group/radioGroupDataAttributes.ts
|
|
31
|
+
function radioGroupItemDataAttributes({ checked, disabled }) {
|
|
32
|
+
return {
|
|
33
|
+
"data-checked": checked ? "" : void 0,
|
|
34
|
+
"data-unchecked": checked ? void 0 : "",
|
|
35
|
+
"data-disabled": disabled ? "" : void 0
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function radioGroupDataAttributes({ disabled }) {
|
|
39
|
+
return {
|
|
40
|
+
"data-disabled": disabled ? "" : void 0
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/radio-group/RadioGroupRoot.tsx
|
|
45
|
+
import { jsx } from "react/jsx-runtime";
|
|
46
|
+
function radiosIn(root, name) {
|
|
47
|
+
if (!root) return [];
|
|
48
|
+
return [...root.querySelectorAll('input[type="radio"]')].filter(
|
|
49
|
+
(input) => input.name === name
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
function RadioGroupRoot({
|
|
53
|
+
value: valueProp,
|
|
54
|
+
defaultValue,
|
|
55
|
+
onValueChange,
|
|
56
|
+
name: nameProp,
|
|
57
|
+
disabled = false,
|
|
58
|
+
required = false,
|
|
59
|
+
form,
|
|
60
|
+
className,
|
|
61
|
+
children,
|
|
62
|
+
render,
|
|
63
|
+
...rest
|
|
64
|
+
}) {
|
|
65
|
+
const [value, setValue] = useControlled({
|
|
66
|
+
controlled: valueProp,
|
|
67
|
+
default: defaultValue ?? null,
|
|
68
|
+
name: "RadioGroup.Root",
|
|
69
|
+
state: "value"
|
|
70
|
+
});
|
|
71
|
+
const generatedName = useId();
|
|
72
|
+
const name = nameProp ?? generatedName;
|
|
73
|
+
const commitValue = useCallback(
|
|
74
|
+
(next) => {
|
|
75
|
+
setValue(next);
|
|
76
|
+
onValueChange?.(next);
|
|
77
|
+
},
|
|
78
|
+
[setValue, onValueChange]
|
|
79
|
+
);
|
|
80
|
+
const elementRef = useRef(null);
|
|
81
|
+
const { current: initialValue } = useRef(value);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const owner = radiosIn(elementRef.current, name)[0]?.form;
|
|
84
|
+
if (!owner) return;
|
|
85
|
+
function onReset(event) {
|
|
86
|
+
queueMicrotask(() => {
|
|
87
|
+
if (event.defaultPrevented) return;
|
|
88
|
+
for (const input of radiosIn(elementRef.current, name)) {
|
|
89
|
+
input.checked = input.value === value;
|
|
90
|
+
}
|
|
91
|
+
if (value === initialValue) return;
|
|
92
|
+
commitValue(initialValue);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
owner.addEventListener("reset", onReset);
|
|
96
|
+
return () => owner.removeEventListener("reset", onReset);
|
|
97
|
+
}, [name, value, commitValue, initialValue]);
|
|
98
|
+
const state = useMemo(
|
|
99
|
+
() => ({ value, disabled, required, name, form, select: commitValue }),
|
|
100
|
+
[value, disabled, required, name, form, commitValue]
|
|
101
|
+
);
|
|
102
|
+
const element = useRender({
|
|
103
|
+
render,
|
|
104
|
+
defaultTagName: "div",
|
|
105
|
+
props: {
|
|
106
|
+
role: "radiogroup",
|
|
107
|
+
"aria-disabled": disabled || void 0,
|
|
108
|
+
"aria-required": required || void 0,
|
|
109
|
+
...radioGroupDataAttributes(state),
|
|
110
|
+
className,
|
|
111
|
+
children,
|
|
112
|
+
ref: elementRef
|
|
113
|
+
},
|
|
114
|
+
consumerProps: rest
|
|
115
|
+
});
|
|
116
|
+
return /* @__PURE__ */ jsx(RadioGroupRootContext.Provider, { value: state, children: element });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/radio-group/RadioGroupItem.tsx
|
|
120
|
+
import { useMemo as useMemo2 } from "react";
|
|
121
|
+
function RadioGroupItem({
|
|
122
|
+
value,
|
|
123
|
+
disabled: disabledProp = false,
|
|
124
|
+
className,
|
|
125
|
+
render,
|
|
126
|
+
...rest
|
|
127
|
+
}) {
|
|
128
|
+
const group = useRadioGroupRootContext();
|
|
129
|
+
const disabled = group.disabled || disabledProp;
|
|
130
|
+
const checked = group.value === value;
|
|
131
|
+
const state = useMemo2(() => ({ checked, disabled }), [checked, disabled]);
|
|
132
|
+
return useRender({
|
|
133
|
+
render,
|
|
134
|
+
defaultTagName: "input",
|
|
135
|
+
props: {
|
|
136
|
+
type: "radio",
|
|
137
|
+
name: group.name,
|
|
138
|
+
value,
|
|
139
|
+
// Controlled from the group's value, so React restores it after any change the
|
|
140
|
+
// group declines — the platform never gets the last word on what is checked.
|
|
141
|
+
checked,
|
|
142
|
+
form: group.form,
|
|
143
|
+
required: group.required || void 0,
|
|
144
|
+
disabled: disabled || void 0,
|
|
145
|
+
...radioGroupItemDataAttributes(state),
|
|
146
|
+
className,
|
|
147
|
+
onChange() {
|
|
148
|
+
if (disabled) return;
|
|
149
|
+
group.select(value);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
consumerProps: rest
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
export {
|
|
156
|
+
index_parts_exports as RadioGroup
|
|
157
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arun-dev/headless",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.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",
|
|
@@ -54,6 +54,26 @@
|
|
|
54
54
|
"default": "./dist/checkbox/index.cjs"
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
|
+
"./dialog": {
|
|
58
|
+
"import": {
|
|
59
|
+
"types": "./dist/dialog/index.d.ts",
|
|
60
|
+
"default": "./dist/dialog/index.js"
|
|
61
|
+
},
|
|
62
|
+
"require": {
|
|
63
|
+
"types": "./dist/dialog/index.d.cts",
|
|
64
|
+
"default": "./dist/dialog/index.cjs"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"./radio-group": {
|
|
68
|
+
"import": {
|
|
69
|
+
"types": "./dist/radio-group/index.d.ts",
|
|
70
|
+
"default": "./dist/radio-group/index.js"
|
|
71
|
+
},
|
|
72
|
+
"require": {
|
|
73
|
+
"types": "./dist/radio-group/index.d.cts",
|
|
74
|
+
"default": "./dist/radio-group/index.cjs"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
57
77
|
"./switch": {
|
|
58
78
|
"import": {
|
|
59
79
|
"types": "./dist/switch/index.d.ts",
|
|
@@ -83,8 +103,10 @@
|
|
|
83
103
|
"@testing-library/react": "16.3.0",
|
|
84
104
|
"@types/react": "19.2.18",
|
|
85
105
|
"@types/react-dom": "19.2.5",
|
|
106
|
+
"@vitest/browser-playwright": "4.1.11",
|
|
86
107
|
"eslint": "9.39.4",
|
|
87
108
|
"jsdom": "27.0.0",
|
|
109
|
+
"playwright": "1.63.0",
|
|
88
110
|
"react": "19.2.8",
|
|
89
111
|
"react-dom": "19.2.8",
|
|
90
112
|
"tsup": "8.5.1",
|
|
@@ -99,6 +121,7 @@
|
|
|
99
121
|
"lint": "eslint .",
|
|
100
122
|
"lint:fix": "eslint . --fix",
|
|
101
123
|
"test": "pnpm run test:unit",
|
|
102
|
-
"test:unit": "vitest run --passWithNoTests --config vitest.unit.config.ts"
|
|
124
|
+
"test:unit": "vitest run --passWithNoTests --config vitest.unit.config.ts",
|
|
125
|
+
"test:browser": "vitest run --config vitest.browser.config.ts"
|
|
103
126
|
}
|
|
104
127
|
}
|