@proyecto-viviana/solidaria 0.0.1 → 0.0.2

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.
Files changed (56) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/index.js.map +1 -1
  3. package/package.json +4 -3
  4. package/src/button/createButton.ts +135 -0
  5. package/src/button/createToggleButton.ts +101 -0
  6. package/src/button/index.ts +4 -0
  7. package/src/button/types.ts +67 -0
  8. package/src/checkbox/createCheckbox.ts +135 -0
  9. package/src/checkbox/createCheckboxGroup.ts +137 -0
  10. package/src/checkbox/createCheckboxGroupItem.ts +117 -0
  11. package/src/checkbox/createCheckboxGroupState.ts +193 -0
  12. package/src/checkbox/index.ts +13 -0
  13. package/src/index.ts +128 -0
  14. package/src/interactions/FocusableProvider.tsx +44 -0
  15. package/src/interactions/PressEvent.ts +112 -0
  16. package/src/interactions/createFocus.ts +157 -0
  17. package/src/interactions/createFocusRing.ts +142 -0
  18. package/src/interactions/createFocusWithin.ts +141 -0
  19. package/src/interactions/createFocusable.ts +168 -0
  20. package/src/interactions/createHover.ts +214 -0
  21. package/src/interactions/createKeyboard.ts +82 -0
  22. package/src/interactions/createPress.ts +758 -0
  23. package/src/interactions/index.ts +45 -0
  24. package/src/label/createField.ts +145 -0
  25. package/src/label/createLabel.ts +116 -0
  26. package/src/label/createLabels.ts +50 -0
  27. package/src/label/index.ts +19 -0
  28. package/src/link/createLink.ts +176 -0
  29. package/src/link/index.ts +1 -0
  30. package/src/progress/createProgressBar.ts +128 -0
  31. package/src/progress/index.ts +5 -0
  32. package/src/radio/createRadio.ts +286 -0
  33. package/src/radio/createRadioGroup.ts +189 -0
  34. package/src/radio/createRadioGroupState.ts +201 -0
  35. package/src/radio/index.ts +23 -0
  36. package/src/separator/createSeparator.ts +82 -0
  37. package/src/separator/index.ts +6 -0
  38. package/src/ssr/index.ts +36 -0
  39. package/src/switch/createSwitch.ts +70 -0
  40. package/src/switch/index.ts +1 -0
  41. package/src/textfield/createTextField.ts +198 -0
  42. package/src/textfield/index.ts +5 -0
  43. package/src/toggle/createToggle.ts +222 -0
  44. package/src/toggle/createToggleState.ts +94 -0
  45. package/src/toggle/index.ts +7 -0
  46. package/src/utils/dom.ts +244 -0
  47. package/src/utils/events.ts +119 -0
  48. package/src/utils/filterDOMProps.ts +116 -0
  49. package/src/utils/focus.ts +151 -0
  50. package/src/utils/geometry.ts +115 -0
  51. package/src/utils/globalListeners.ts +142 -0
  52. package/src/utils/index.ts +66 -0
  53. package/src/utils/mergeProps.ts +49 -0
  54. package/src/utils/platform.ts +52 -0
  55. package/src/utils/reactivity.ts +36 -0
  56. package/src/utils/textSelection.ts +114 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Radio hook for Solidaria
3
+ *
4
+ * Provides the behavior and accessibility implementation for an individual
5
+ * radio button in a radio group.
6
+ *
7
+ * This is a 1:1 port of @react-aria/radio's useRadio hook.
8
+ */
9
+
10
+ import { JSX, Accessor, createEffect } from 'solid-js';
11
+ import { createPress } from '../interactions/createPress';
12
+ import { createFocusable } from '../interactions/createFocusable';
13
+ import { mergeProps } from '../utils/mergeProps';
14
+ import { filterDOMProps } from '../utils/filterDOMProps';
15
+ import { type MaybeAccessor, access } from '../utils/reactivity';
16
+ import { type RadioGroupState, radioGroupSyncVersion } from '@proyecto-viviana/solid-stately';
17
+ import { radioGroupData } from './createRadioGroup';
18
+ import { type PressEvent } from '../interactions/PressEvent';
19
+
20
+ // ============================================
21
+ // TYPES
22
+ // ============================================
23
+
24
+ export interface AriaRadioProps {
25
+ /** The value of the radio button, used when submitting an HTML form. */
26
+ value: string;
27
+ /** Whether the radio button is disabled. */
28
+ isDisabled?: boolean;
29
+ /** The label for the radio button. */
30
+ children?: JSX.Element;
31
+ /** Defines a string value that labels the current element. */
32
+ 'aria-label'?: string;
33
+ /** Identifies the element (or elements) that labels the current element. */
34
+ 'aria-labelledby'?: string;
35
+ /** Identifies the element (or elements) that describes the object. */
36
+ 'aria-describedby'?: string;
37
+ /** Handler that is called when the press is released over the target. */
38
+ onPress?: (e: PressEvent) => void;
39
+ /** Handler that is called when a press interaction starts. */
40
+ onPressStart?: (e: PressEvent) => void;
41
+ /** Handler that is called when a press interaction ends, either over the target or when the pointer leaves the target. */
42
+ onPressEnd?: (e: PressEvent) => void;
43
+ /** Handler that is called when the press state changes. */
44
+ onPressChange?: (isPressed: boolean) => void;
45
+ /** Handler that is called when a press is released over the target, regardless of whether it started on the target or not. */
46
+ onPressUp?: (e: PressEvent) => void;
47
+ /** Handler that is called when the element is clicked. */
48
+ onClick?: (e: MouseEvent) => void;
49
+ /** Handler that is called when the element receives focus. */
50
+ onFocus?: (e: FocusEvent) => void;
51
+ /** Handler that is called when the element loses focus. */
52
+ onBlur?: (e: FocusEvent) => void;
53
+ /** Handler that is called when the element's focus status changes. */
54
+ onFocusChange?: (isFocused: boolean) => void;
55
+ /** Handler that is called when a key is pressed. */
56
+ onKeyDown?: (e: KeyboardEvent) => void;
57
+ /** Handler that is called when a key is released. */
58
+ onKeyUp?: (e: KeyboardEvent) => void;
59
+ /** Whether to autofocus the element. */
60
+ autoFocus?: boolean;
61
+ }
62
+
63
+ export interface RadioAria {
64
+ /** Props for the label wrapper element. */
65
+ labelProps: JSX.LabelHTMLAttributes<HTMLLabelElement>;
66
+ /** Props for the input element. */
67
+ inputProps: JSX.InputHTMLAttributes<HTMLInputElement>;
68
+ /** Whether the radio is disabled. */
69
+ isDisabled: boolean;
70
+ /** Whether the radio is currently selected. */
71
+ isSelected: Accessor<boolean>;
72
+ /** Whether the radio is in a pressed state. */
73
+ isPressed: Accessor<boolean>;
74
+ }
75
+
76
+ // ============================================
77
+ // IMPLEMENTATION
78
+ // ============================================
79
+
80
+ /**
81
+ * Provides the behavior and accessibility implementation for an individual
82
+ * radio button in a radio group.
83
+ */
84
+ export function createRadio(
85
+ props: MaybeAccessor<AriaRadioProps>,
86
+ state: RadioGroupState,
87
+ ref: () => HTMLInputElement | null
88
+ ): RadioAria {
89
+ const getProps = () => access(props);
90
+
91
+ const isDisabled = () => getProps().isDisabled || state.isDisabled;
92
+ const value = () => getProps().value;
93
+ const isSelected: Accessor<boolean> = () => {
94
+ const selected = state.selectedValue();
95
+ const v = value();
96
+ return selected === v;
97
+ };
98
+
99
+ // Warn if no accessible label
100
+ createEffect(() => {
101
+ const p = getProps();
102
+ const hasChildren = p.children != null;
103
+ const hasAriaLabel = p['aria-label'] != null || p['aria-labelledby'] != null;
104
+ if (!hasChildren && !hasAriaLabel && process.env.NODE_ENV !== 'production') {
105
+ console.warn('If you do not provide children, you must specify an aria-label for accessibility');
106
+ }
107
+ });
108
+
109
+ // SolidJS-specific: Sync DOM checked state whenever selection changes
110
+ // This handles:
111
+ // 1. Initial render with controlled value
112
+ // 2. Controlled mode where parent doesn't update value after click
113
+ // 3. Native radio group behavior (clicking one unchecks others)
114
+ //
115
+ // Unlike React's VDOM reconciliation that re-applies all props on every render,
116
+ // SolidJS only updates when signals change. Native radio behavior can change
117
+ // the DOM checked state without our knowledge, so we need to actively sync.
118
+ //
119
+ // We track `syncVersion` to ensure this effect runs on EVERY selection attempt,
120
+ // even in controlled mode where isSelected() may not change.
121
+ createEffect(() => {
122
+ const inputEl = ref();
123
+ if (!inputEl) return;
124
+
125
+ // Track syncVersion to trigger on any selection attempt
126
+ // This is crucial for controlled mode where isSelected() may not change
127
+ // syncVersion is stored in an internal WeakMap to maintain API parity with React-Aria
128
+ const syncVersion = radioGroupSyncVersion.get(state);
129
+ syncVersion?.();
130
+
131
+ // Read the reactive state to establish tracking and get current value
132
+ const shouldBeChecked = isSelected();
133
+
134
+ // Sync the DOM checked state immediately
135
+ if (inputEl.checked !== shouldBeChecked) {
136
+ inputEl.checked = shouldBeChecked;
137
+ }
138
+ });
139
+
140
+ // Handle input change
141
+ // SolidJS-specific: Unlike React, the input's `checked` state can get out of sync
142
+ // with our reactive state. This happens because:
143
+ // 1. A readonly `<input type="radio" />` is always "checkable" in the browser
144
+ // 2. Even controlled inputs (`<input checked={isChecked} />`) will change their
145
+ // internal `checked` state when clicked, regardless of what the signal says
146
+ //
147
+ // To prevent this, we force the input's `checked` DOM property to match our state
148
+ // after processing the change. This is the pattern used by Kobalte and other
149
+ // SolidJS component libraries.
150
+ const onChange: JSX.EventHandler<HTMLInputElement, Event> = (e) => {
151
+ e.stopPropagation();
152
+
153
+ const target = e.target as HTMLInputElement;
154
+
155
+ // Guard against disabled state - JSDOM's fireEvent may bypass disabled check
156
+ if (isDisabled()) {
157
+ target.checked = isSelected();
158
+ return;
159
+ }
160
+
161
+ state.setSelectedValue(value());
162
+
163
+ // Focus the input when clicked
164
+ // In real browsers this happens automatically, but JSDOM/fireEvent doesn't trigger it
165
+ target.focus();
166
+
167
+ // Force the DOM checked state to match our reactive state
168
+ // This handles controlled mode where the parent might not update the value
169
+ target.checked = isSelected();
170
+ };
171
+
172
+ // Handle press state for keyboard interactions and cases where labelProps is not used.
173
+ const { pressProps, isPressed } = createPress({
174
+ get onPressStart() { return getProps().onPressStart; },
175
+ get onPressEnd() { return getProps().onPressEnd; },
176
+ get onPressChange() { return getProps().onPressChange; },
177
+ get onPress() { return getProps().onPress; },
178
+ get onPressUp() { return getProps().onPressUp; },
179
+ get isDisabled() { return isDisabled(); },
180
+ });
181
+
182
+ // Handle press state on the label.
183
+ const { pressProps: labelPressProps, isPressed: isLabelPressed } = createPress({
184
+ get onPressStart() { return getProps().onPressStart; },
185
+ get onPressEnd() { return getProps().onPressEnd; },
186
+ get onPressChange() { return getProps().onPressChange; },
187
+ get onPressUp() { return getProps().onPressUp; },
188
+ onPress(e: PressEvent) {
189
+ getProps().onPress?.(e);
190
+ state.setSelectedValue(value());
191
+ ref()?.focus();
192
+ },
193
+ get isDisabled() { return isDisabled(); },
194
+ });
195
+
196
+ // Handle focusable
197
+ const { focusableProps } = createFocusable({
198
+ get isDisabled() { return isDisabled(); },
199
+ get autoFocus() { return getProps().autoFocus; },
200
+ onFocus(e: FocusEvent) {
201
+ getProps().onFocus?.(e);
202
+ state.setLastFocusedValue(value());
203
+ },
204
+ get onBlur() { return getProps().onBlur; },
205
+ get onFocusChange() { return getProps().onFocusChange; },
206
+ get onKeyDown() { return getProps().onKeyDown; },
207
+ get onKeyUp() { return getProps().onKeyUp; },
208
+ }, ref as unknown as (el: HTMLElement) => void);
209
+
210
+ // Combine press and focusable props for input
211
+ const interactions = mergeProps(pressProps, focusableProps);
212
+
213
+ // Filter DOM props
214
+ const domProps = () => filterDOMProps(getProps() as unknown as Record<string, unknown>, { labelable: true });
215
+
216
+ // Calculate tabIndex based on selection and focus state
217
+ const getTabIndex = (): number | undefined => {
218
+ if (isDisabled()) {
219
+ return undefined;
220
+ }
221
+
222
+ const selected = state.selectedValue();
223
+ const lastFocused = state.lastFocusedValue();
224
+ const currentValue = value();
225
+
226
+ if (selected != null) {
227
+ // If there's a selection, only the selected radio is focusable
228
+ if (selected === currentValue) {
229
+ return 0;
230
+ }
231
+ return -1;
232
+ } else {
233
+ // If no selection, the last focused or first radio is focusable
234
+ if (lastFocused === currentValue || lastFocused == null) {
235
+ return 0;
236
+ }
237
+ return -1;
238
+ }
239
+ };
240
+
241
+ // Get group data
242
+ const getGroupData = () => radioGroupData.get(state);
243
+
244
+ // Combined pressed state
245
+ const combinedIsPressed: Accessor<boolean> = () => isPressed() || isLabelPressed();
246
+
247
+ return {
248
+ labelProps: mergeProps(labelPressProps, {
249
+ onClick: (e: MouseEvent) => e.preventDefault(),
250
+ onMouseDown: (e: MouseEvent) => e.preventDefault(),
251
+ }),
252
+ get inputProps() {
253
+ const p = getProps();
254
+ const groupData = getGroupData();
255
+
256
+ // Build aria-describedby
257
+ const describedByIds: string[] = [];
258
+ if (p['aria-describedby']) {
259
+ describedByIds.push(p['aria-describedby']);
260
+ }
261
+ if (state.isInvalid && groupData?.errorMessageId) {
262
+ describedByIds.push(groupData.errorMessageId);
263
+ }
264
+ if (groupData?.descriptionId) {
265
+ describedByIds.push(groupData.descriptionId);
266
+ }
267
+ const ariaDescribedBy = describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
268
+
269
+ return mergeProps(domProps(), interactions, {
270
+ type: 'radio' as const,
271
+ name: groupData?.name,
272
+ form: groupData?.form,
273
+ tabIndex: getTabIndex(),
274
+ disabled: isDisabled(),
275
+ required: state.isRequired && groupData?.validationBehavior === 'native',
276
+ checked: isSelected(),
277
+ value: value(),
278
+ onChange,
279
+ 'aria-describedby': ariaDescribedBy,
280
+ }) as JSX.InputHTMLAttributes<HTMLInputElement>;
281
+ },
282
+ isDisabled: isDisabled(),
283
+ isSelected,
284
+ isPressed: combinedIsPressed,
285
+ };
286
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Radio group hook for Solidaria
3
+ *
4
+ * Provides the behavior and accessibility implementation for a radio group component.
5
+ * Radio groups allow users to select a single item from a list of mutually exclusive options.
6
+ *
7
+ * This is a 1:1 port of @react-aria/radio's useRadioGroup hook.
8
+ */
9
+
10
+ import { JSX } from 'solid-js';
11
+ import { createField } from '../label/createField';
12
+ import { createFocusWithin } from '../interactions/createFocusWithin';
13
+ import { mergeProps } from '../utils/mergeProps';
14
+ import { filterDOMProps } from '../utils/filterDOMProps';
15
+ import { createId } from '../ssr';
16
+ import { type MaybeAccessor, access } from '../utils/reactivity';
17
+ import { type RadioGroupState } from '@proyecto-viviana/solid-stately';
18
+
19
+ // ============================================
20
+ // TYPES
21
+ // ============================================
22
+
23
+ export interface AriaRadioGroupProps {
24
+ /** The content to display as the label. */
25
+ label?: JSX.Element;
26
+ /** A description for the radio group. Provides additional context. */
27
+ description?: JSX.Element;
28
+ /** An error message for the radio group. */
29
+ errorMessage?: JSX.Element | ((validation: { isInvalid: boolean; validationErrors: string[] }) => JSX.Element);
30
+ /** Whether the radio group is disabled. */
31
+ isDisabled?: boolean;
32
+ /** Whether the radio group is read only. */
33
+ isReadOnly?: boolean;
34
+ /** Whether the radio group is required. */
35
+ isRequired?: boolean;
36
+ /** Whether the radio group is invalid. */
37
+ isInvalid?: boolean;
38
+ /** The axis the Radio Button(s) should align with. Defaults to 'vertical'. */
39
+ orientation?: 'horizontal' | 'vertical';
40
+ /** The name of the radio group, used when submitting an HTML form. */
41
+ name?: string;
42
+ /** The form to associate the radio group with. */
43
+ form?: string;
44
+ /** Validation behavior for the radio group. */
45
+ validationBehavior?: 'aria' | 'native';
46
+ /** Handler that is called when the radio group receives focus. */
47
+ onFocus?: (e: FocusEvent) => void;
48
+ /** Handler that is called when the radio group loses focus. */
49
+ onBlur?: (e: FocusEvent) => void;
50
+ /** Handler that is called when the radio group's focus status changes. */
51
+ onFocusChange?: (isFocused: boolean) => void;
52
+ /** Defines a string value that labels the current element. */
53
+ 'aria-label'?: string;
54
+ /** Identifies the element (or elements) that labels the current element. */
55
+ 'aria-labelledby'?: string;
56
+ /** Identifies the element (or elements) that describes the object. */
57
+ 'aria-describedby'?: string;
58
+ /** Identifies the element (or elements) that provide an error message for the object. */
59
+ 'aria-errormessage'?: string;
60
+ /** The element's unique identifier. */
61
+ id?: string;
62
+ }
63
+
64
+ export interface RadioGroupAria {
65
+ /** Props for the radio group wrapper element. */
66
+ radioGroupProps: JSX.HTMLAttributes<HTMLDivElement>;
67
+ /** Props for the radio group's visible label (if any). */
68
+ labelProps: JSX.HTMLAttributes<HTMLElement>;
69
+ /** Props for the radio group description element, if any. */
70
+ descriptionProps: JSX.HTMLAttributes<HTMLElement>;
71
+ /** Props for the radio group error message element, if any. */
72
+ errorMessageProps: JSX.HTMLAttributes<HTMLElement>;
73
+ /** Whether the radio group is invalid. */
74
+ isInvalid: boolean;
75
+ /** Validation errors, if any. */
76
+ validationErrors: string[];
77
+ /** Validation details, if any. */
78
+ validationDetails: Record<string, boolean>;
79
+ }
80
+
81
+ // WeakMap to share data between radio group and radio items
82
+ interface RadioGroupData {
83
+ name: string;
84
+ form: string | undefined;
85
+ descriptionId: string | undefined;
86
+ errorMessageId: string | undefined;
87
+ validationBehavior: 'aria' | 'native';
88
+ }
89
+
90
+ export const radioGroupData: WeakMap<RadioGroupState, RadioGroupData> = new WeakMap();
91
+
92
+ // ============================================
93
+ // IMPLEMENTATION
94
+ // ============================================
95
+
96
+ /**
97
+ * Provides the behavior and accessibility implementation for a radio group component.
98
+ * Radio groups allow users to select a single item from a list of mutually exclusive options.
99
+ */
100
+ export function createRadioGroup(
101
+ props: MaybeAccessor<AriaRadioGroupProps>,
102
+ state: RadioGroupState
103
+ ): RadioGroupAria {
104
+ const getProps = () => access(props);
105
+
106
+ const orientation = () => getProps().orientation ?? 'vertical';
107
+ const isReadOnly = () => getProps().isReadOnly ?? false;
108
+ const isRequired = () => getProps().isRequired ?? false;
109
+ const isDisabled = () => getProps().isDisabled ?? false;
110
+ const validationBehavior = () => getProps().validationBehavior ?? 'aria';
111
+
112
+ // Use field for label, description, error message
113
+ const { labelProps, fieldProps, descriptionProps, errorMessageProps } = createField({
114
+ get label() { return getProps().label; },
115
+ get description() { return getProps().description; },
116
+ get errorMessage() { return getProps().errorMessage; },
117
+ get isInvalid() { return state.isInvalid; },
118
+ // Radio group is not an HTML input element so it
119
+ // shouldn't be labeled by a <label> element.
120
+ labelElementType: 'span',
121
+ });
122
+
123
+ // Handle focus within - reset focusable radio when group loses focus and no selection
124
+ const { focusWithinProps } = createFocusWithin({
125
+ onBlurWithin(e: FocusEvent) {
126
+ getProps().onBlur?.(e);
127
+ if (!state.selectedValue()) {
128
+ state.setLastFocusedValue(null);
129
+ }
130
+ },
131
+ onFocusWithin: (e: FocusEvent) => getProps().onFocus?.(e),
132
+ onFocusWithinChange: (isFocused: boolean) => getProps().onFocusChange?.(isFocused),
133
+ });
134
+
135
+ // Filter DOM props
136
+ const domProps = () => filterDOMProps(getProps() as unknown as Record<string, unknown>, { labelable: true });
137
+
138
+ // Generate group name
139
+ const groupName = getProps().name ?? createId();
140
+
141
+ // Store data for radio items to access
142
+ radioGroupData.set(state, {
143
+ name: groupName,
144
+ form: getProps().form,
145
+ descriptionId: descriptionProps.id,
146
+ errorMessageId: errorMessageProps.id,
147
+ validationBehavior: validationBehavior(),
148
+ });
149
+
150
+ // Keyboard navigation handler for arrow keys
151
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
152
+ const onKeyDown = (_e: KeyboardEvent) => {
153
+ // For now, we rely on native radio behavior
154
+ // The complex tree walker implementation can be added later if needed
155
+ // Most browsers handle arrow key navigation natively for radio groups
156
+ };
157
+
158
+ return {
159
+ get radioGroupProps() {
160
+ return mergeProps(
161
+ domProps(),
162
+ focusWithinProps as unknown as Record<string, unknown>,
163
+ {
164
+ role: 'radiogroup',
165
+ onKeyDown,
166
+ 'aria-invalid': state.isInvalid || undefined,
167
+ 'aria-errormessage': getProps()['aria-errormessage'],
168
+ 'aria-readonly': isReadOnly() || undefined,
169
+ 'aria-required': isRequired() || undefined,
170
+ 'aria-disabled': isDisabled() || undefined,
171
+ 'aria-orientation': orientation(),
172
+ ...fieldProps,
173
+ }
174
+ ) as JSX.HTMLAttributes<HTMLDivElement>;
175
+ },
176
+ labelProps: labelProps as JSX.HTMLAttributes<HTMLElement>,
177
+ descriptionProps,
178
+ errorMessageProps,
179
+ get isInvalid() {
180
+ return state.isInvalid;
181
+ },
182
+ get validationErrors() {
183
+ return []; // Simplified - full validation can be added later
184
+ },
185
+ get validationDetails() {
186
+ return {}; // Simplified - full validation can be added later
187
+ },
188
+ };
189
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Radio group state for Solidaria
3
+ *
4
+ * Provides state management for a radio group component.
5
+ * Provides a name for the group, and manages selection and focus state.
6
+ *
7
+ * This is a 1:1 port of @react-stately/radio's useRadioGroupState.
8
+ */
9
+
10
+ import { createSignal, Accessor, untrack } from 'solid-js';
11
+ import { type MaybeAccessor, access } from '../utils/reactivity';
12
+ import { createId } from '../ssr';
13
+
14
+ // ============================================
15
+ // TYPES
16
+ // ============================================
17
+
18
+ export interface RadioGroupProps {
19
+ /** The current selected value (controlled). */
20
+ value?: string | null;
21
+ /** The default selected value (uncontrolled). */
22
+ defaultValue?: string | null;
23
+ /** Handler that is called when the value changes. */
24
+ onChange?: (value: string) => void;
25
+ /** Whether the radio group is disabled. */
26
+ isDisabled?: boolean;
27
+ /** Whether the radio group is read only. */
28
+ isReadOnly?: boolean;
29
+ /** Whether the radio group is required. */
30
+ isRequired?: boolean;
31
+ /** Whether the radio group is invalid. */
32
+ isInvalid?: boolean;
33
+ /** The name of the radio group, used when submitting an HTML form. */
34
+ name?: string;
35
+ /** The form to associate the radio group with. */
36
+ form?: string;
37
+ /** The label for the radio group. */
38
+ label?: string;
39
+ /** Orientation of the radio group. */
40
+ orientation?: 'horizontal' | 'vertical';
41
+ /** Handler that is called when the radio group receives focus. */
42
+ onFocus?: (e: FocusEvent) => void;
43
+ /** Handler that is called when the radio group loses focus. */
44
+ onBlur?: (e: FocusEvent) => void;
45
+ /** Handler that is called when the radio group's focus status changes. */
46
+ onFocusChange?: (isFocused: boolean) => void;
47
+ }
48
+
49
+ export interface RadioGroupState {
50
+ /** The name for the group, used for native form submission. */
51
+ readonly name: string;
52
+
53
+ /** Whether the radio group is disabled. */
54
+ readonly isDisabled: boolean;
55
+
56
+ /** Whether the radio group is read only. */
57
+ readonly isReadOnly: boolean;
58
+
59
+ /** Whether the radio group is required. */
60
+ readonly isRequired: boolean;
61
+
62
+ /** Whether the radio group is invalid. */
63
+ readonly isInvalid: boolean;
64
+
65
+ /** The currently selected value. */
66
+ readonly selectedValue: Accessor<string | null>;
67
+
68
+ /** The default selected value. */
69
+ readonly defaultSelectedValue: string | null;
70
+
71
+ /** Sets the selected value. */
72
+ setSelectedValue(value: string | null): void;
73
+
74
+ /** The value of the last focused radio. */
75
+ readonly lastFocusedValue: Accessor<string | null>;
76
+
77
+ /** Sets the last focused value. */
78
+ setLastFocusedValue(value: string | null): void;
79
+ }
80
+
81
+ // ============================================
82
+ // INTERNAL: SolidJS-specific sync mechanism
83
+ // ============================================
84
+
85
+ /**
86
+ * Internal WeakMap to store sync version accessors for each radio group state.
87
+ * This is used by createRadio to trigger DOM sync when native radio behavior
88
+ * causes the DOM checked state to desync from our reactive state.
89
+ *
90
+ * This is kept separate from RadioGroupState to maintain API parity with React-Aria.
91
+ * @internal
92
+ */
93
+ export const radioGroupSyncVersion: WeakMap<RadioGroupState, Accessor<number>> = new WeakMap();
94
+
95
+ // ============================================
96
+ // IMPLEMENTATION
97
+ // ============================================
98
+
99
+ /**
100
+ * Provides state management for a radio group component.
101
+ * Provides a name for the group, and manages selection and focus state.
102
+ */
103
+ export function createRadioGroupState(
104
+ props: MaybeAccessor<RadioGroupProps> = {}
105
+ ): RadioGroupState {
106
+ const getProps = () => access(props);
107
+
108
+ // Get initial props using untrack to avoid setting up dependencies
109
+ // This ensures we capture the initial defaultValue without reactivity issues
110
+ const initialProps = untrack(() => getProps());
111
+
112
+ // Generate name - preserved for backward compatibility
113
+ // React Aria now generates the name instead of stately
114
+ const name = initialProps.name || `radio-group-${createId()}`;
115
+
116
+ // Create internal signal for uncontrolled mode
117
+ // Initialize with defaultValue only (not value, which is for controlled mode)
118
+ const [internalValue, setInternalValue] = createSignal<string | null>(
119
+ initialProps.defaultValue ?? null
120
+ );
121
+ const [lastFocusedValue, setLastFocusedValueInternal] = createSignal<string | null>(null);
122
+
123
+ // SolidJS-specific: Version counter for triggering DOM sync across all radios
124
+ // This handles the case where native radio behavior causes DOM state to desync
125
+ // from our reactive state (e.g., clicking a radio unchecks siblings in the DOM)
126
+ const [syncVersion, setSyncVersion] = createSignal(0);
127
+
128
+ // Determine if controlled - must be reactive to handle dynamic props
129
+ const isControlled = () => getProps().value !== undefined;
130
+
131
+ // Get current value - reactive for both controlled and uncontrolled modes
132
+ const selectedValue: Accessor<string | null> = () => {
133
+ const p = getProps();
134
+ // In controlled mode, always read from props.value reactively
135
+ // In uncontrolled mode, read from internal signal
136
+ if (p.value !== undefined) {
137
+ return p.value ?? null;
138
+ }
139
+ return internalValue();
140
+ };
141
+
142
+ // Check if invalid
143
+ const isInvalid = () => {
144
+ return getProps().isInvalid ?? false;
145
+ };
146
+
147
+ // Set value
148
+ function setSelectedValue(value: string | null): void {
149
+ const p = getProps();
150
+ if (p.isReadOnly || p.isDisabled) {
151
+ return;
152
+ }
153
+
154
+ // Always increment syncVersion to trigger DOM sync across all radios
155
+ // This is crucial for SolidJS because:
156
+ // 1. Native radio behavior unchecks siblings when one is checked
157
+ // 2. In controlled mode, isSelected() may not change even though DOM changed
158
+ // 3. We need ALL radios to re-sync their checked state after any click
159
+ setSyncVersion((v) => v + 1);
160
+
161
+ if (!isControlled()) {
162
+ setInternalValue(value);
163
+ }
164
+
165
+ if (value != null) {
166
+ p.onChange?.(value);
167
+ }
168
+ }
169
+
170
+ // Set last focused value
171
+ function setLastFocusedValue(value: string | null): void {
172
+ setLastFocusedValueInternal(value);
173
+ }
174
+
175
+ const state: RadioGroupState = {
176
+ name,
177
+ selectedValue,
178
+ defaultSelectedValue: initialProps.defaultValue ?? null,
179
+ setSelectedValue,
180
+ lastFocusedValue,
181
+ setLastFocusedValue,
182
+ get isDisabled() {
183
+ return getProps().isDisabled ?? false;
184
+ },
185
+ get isReadOnly() {
186
+ return getProps().isReadOnly ?? false;
187
+ },
188
+ get isRequired() {
189
+ return getProps().isRequired ?? false;
190
+ },
191
+ get isInvalid() {
192
+ return isInvalid();
193
+ },
194
+ };
195
+
196
+ // Store syncVersion in internal WeakMap (not part of public API)
197
+ // This maintains API parity with React-Aria while supporting SolidJS's reactivity needs
198
+ radioGroupSyncVersion.set(state, syncVersion);
199
+
200
+ return state;
201
+ }
@@ -0,0 +1,23 @@
1
+ // Re-export state from solid-stately
2
+ export {
3
+ createRadioGroupState,
4
+ radioGroupSyncVersion,
5
+ type RadioGroupProps,
6
+ type RadioGroupState,
7
+ } from '@proyecto-viviana/solid-stately';
8
+
9
+ // ARIA hooks (solidaria-specific)
10
+ // Radio Group
11
+ export {
12
+ createRadioGroup,
13
+ radioGroupData,
14
+ type AriaRadioGroupProps,
15
+ type RadioGroupAria,
16
+ } from './createRadioGroup';
17
+
18
+ // Radio
19
+ export {
20
+ createRadio,
21
+ type AriaRadioProps,
22
+ type RadioAria,
23
+ } from './createRadio';