@cdx-ui/primitives 0.0.1-beta.141 → 0.0.1-beta.142

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 (50) hide show
  1. package/lib/commonjs/checkbox/CheckboxRoot.web.js +91 -28
  2. package/lib/commonjs/checkbox/CheckboxRoot.web.js.map +1 -1
  3. package/lib/commonjs/checkbox/useCheckboxRoot.js +33 -19
  4. package/lib/commonjs/checkbox/useCheckboxRoot.js.map +1 -1
  5. package/lib/commonjs/radio/RadioGroup.js +65 -16
  6. package/lib/commonjs/radio/RadioGroup.js.map +1 -1
  7. package/lib/commonjs/radio/RadioRoot.web.js +114 -26
  8. package/lib/commonjs/radio/RadioRoot.web.js.map +1 -1
  9. package/lib/commonjs/radio/context.js.map +1 -1
  10. package/lib/commonjs/radio/useRadioGroupNavigation.js +79 -0
  11. package/lib/commonjs/radio/useRadioGroupNavigation.js.map +1 -0
  12. package/lib/commonjs/radio/useRadioRoot.js +14 -13
  13. package/lib/commonjs/radio/useRadioRoot.js.map +1 -1
  14. package/lib/module/checkbox/CheckboxRoot.web.js +92 -29
  15. package/lib/module/checkbox/CheckboxRoot.web.js.map +1 -1
  16. package/lib/module/checkbox/useCheckboxRoot.js +34 -20
  17. package/lib/module/checkbox/useCheckboxRoot.js.map +1 -1
  18. package/lib/module/radio/RadioGroup.js +68 -19
  19. package/lib/module/radio/RadioGroup.js.map +1 -1
  20. package/lib/module/radio/RadioRoot.web.js +117 -29
  21. package/lib/module/radio/RadioRoot.web.js.map +1 -1
  22. package/lib/module/radio/context.js.map +1 -1
  23. package/lib/module/radio/useRadioGroupNavigation.js +74 -0
  24. package/lib/module/radio/useRadioGroupNavigation.js.map +1 -0
  25. package/lib/module/radio/useRadioRoot.js +14 -13
  26. package/lib/module/radio/useRadioRoot.js.map +1 -1
  27. package/lib/typescript/checkbox/CheckboxRoot.web.d.ts +8 -0
  28. package/lib/typescript/checkbox/CheckboxRoot.web.d.ts.map +1 -1
  29. package/lib/typescript/checkbox/useCheckboxRoot.d.ts +7 -18
  30. package/lib/typescript/checkbox/useCheckboxRoot.d.ts.map +1 -1
  31. package/lib/typescript/radio/RadioGroup.d.ts.map +1 -1
  32. package/lib/typescript/radio/RadioRoot.web.d.ts +8 -0
  33. package/lib/typescript/radio/RadioRoot.web.d.ts.map +1 -1
  34. package/lib/typescript/radio/context.d.ts +6 -1
  35. package/lib/typescript/radio/context.d.ts.map +1 -1
  36. package/lib/typescript/radio/types.d.ts +7 -0
  37. package/lib/typescript/radio/types.d.ts.map +1 -1
  38. package/lib/typescript/radio/useRadioGroupNavigation.d.ts +17 -0
  39. package/lib/typescript/radio/useRadioGroupNavigation.d.ts.map +1 -0
  40. package/lib/typescript/radio/useRadioRoot.d.ts +4 -18
  41. package/lib/typescript/radio/useRadioRoot.d.ts.map +1 -1
  42. package/package.json +3 -3
  43. package/src/checkbox/CheckboxRoot.web.tsx +105 -29
  44. package/src/checkbox/useCheckboxRoot.ts +37 -15
  45. package/src/radio/RadioGroup.tsx +84 -20
  46. package/src/radio/RadioRoot.web.tsx +136 -31
  47. package/src/radio/context.tsx +6 -1
  48. package/src/radio/types.ts +6 -0
  49. package/src/radio/useRadioGroupNavigation.ts +104 -0
  50. package/src/radio/useRadioRoot.ts +18 -11
@@ -1,20 +1,27 @@
1
- import { forwardRef } from 'react';
1
+ import { forwardRef, useContext, useEffect, useMemo, useRef } from 'react';
2
2
  import { Pressable } from 'react-native';
3
3
  import { VisuallyHidden } from '@react-aria/visually-hidden';
4
- import { mergeProps } from '@react-aria/utils';
4
+ import { composeEventHandlers, mergeRefs } from '@cdx-ui/utils';
5
5
  import { useFocusRing } from '@react-native-aria/focus';
6
6
  import { Slot } from '../slot';
7
7
  import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
8
- import { RadioProvider } from './context';
8
+ import { RadioGroupContext, RadioProvider } from './context';
9
9
  import type { IRadioProps, IRadioContextValue } from './types';
10
10
  import { useRadioRoot } from './useRadioRoot';
11
-
12
- // TODO: Keyboard focus is not working as expected
11
+ import { getRovingFocusValue } from './useRadioGroupNavigation';
13
12
 
14
13
  export interface RadioRootProps extends IRadioProps {
15
14
  asChild?: boolean;
16
15
  }
17
16
 
17
+ /**
18
+ * Web Radio root.
19
+ *
20
+ * On web, `@react-native-aria/radio` re-exports `@react-aria/radio`'s `useRadio`,
21
+ * whose `inputProps` target a native `<input type="radio">` (selection via
22
+ * `onChange`). Selection must be applied explicitly on the Pressable —
23
+ * `inputProps.onPress` does **not** call `setSelectedValue`.
24
+ */
18
25
  export const RadioRoot = forwardRef<React.ComponentRef<typeof Pressable>, RadioRootProps>(
19
26
  ({ asChild, children, ...props }, ref) => {
20
27
  const {
@@ -27,12 +34,13 @@ export const RadioRoot = forwardRef<React.ComponentRef<typeof Pressable>, RadioR
27
34
  isPressed,
28
35
  } = props;
29
36
 
37
+ const radioGroupContext = useContext(RadioGroupContext);
38
+
30
39
  const {
31
40
  combinedProps,
32
41
  isInvalid,
33
42
  isReadOnly,
34
43
  inputProps,
35
- labelProps,
36
44
  isChecked,
37
45
  isDisabled,
38
46
  isHovered,
@@ -42,13 +50,80 @@ export const RadioRoot = forwardRef<React.ComponentRef<typeof Pressable>, RadioR
42
50
 
43
51
  const { focusProps, isFocusVisible } = useFocusRing();
44
52
 
53
+ const nodeRef = useRef<React.ComponentRef<typeof Pressable> | null>(null);
54
+ const setRefs = mergeRefs(mergedRef, nodeRef);
55
+
56
+ // Strip native-input / internal props — they must not land on Pressable.
57
+ const {
58
+ onPress: _onPress,
59
+ onChange: _onChange,
60
+ role: _role,
61
+ tabIndex: _tabIndex,
62
+ disabled: _disabled,
63
+ checked: _checked,
64
+ value: _value,
65
+ type: _type,
66
+ name: _name,
67
+ form: _form,
68
+ required: _required,
69
+ ...inputAriaProps
70
+ } = inputProps;
71
+
72
+ const resolvedDisabled = !!(isDisabled || isDisabledProp);
73
+ const resolvedReadOnly = !!(isReadOnly || isReadOnlyProp);
74
+
75
+ const selectThis = () => {
76
+ if (resolvedDisabled || resolvedReadOnly) {
77
+ return;
78
+ }
79
+ radioGroupContext?.radioGroupState.setSelectedValue(combinedProps.value);
80
+ };
81
+
82
+ const selectedValue = radioGroupContext?.radioGroupState.selectedValue;
83
+ const focusedValue = radioGroupContext?.focusedValue;
84
+ const registerRadio = radioGroupContext?.registerRadio;
85
+ const unregisterRadio = radioGroupContext?.unregisterRadio;
86
+ const getRadios = radioGroupContext?.getRadios;
87
+ const groupName = radioGroupContext?.name;
88
+
89
+ useEffect(() => {
90
+ if (!registerRadio || !unregisterRadio) {
91
+ return;
92
+ }
93
+
94
+ registerRadio(combinedProps.value, {
95
+ disabled: !!resolvedDisabled,
96
+ node: nodeRef.current as { focus?: () => void } | null,
97
+ });
98
+
99
+ return () => unregisterRadio(combinedProps.value);
100
+ }, [combinedProps.value, resolvedDisabled, registerRadio, unregisterRadio]);
101
+
102
+ const isRovingTabStop = useMemo(() => {
103
+ if (resolvedDisabled) {
104
+ return false;
105
+ }
106
+
107
+ const enabledRadios = getRadios?.().filter(([, entry]) => !entry.disabled) ?? [];
108
+ const rovingValue = getRovingFocusValue(focusedValue, selectedValue, enabledRadios);
109
+
110
+ return combinedProps.value === rovingValue;
111
+ }, [combinedProps.value, focusedValue, getRadios, resolvedDisabled, selectedValue]);
112
+
113
+ const handleKeyDown = (event: { key: string; preventDefault: () => void }) => {
114
+ if (event.key === ' ' || event.key === 'Spacebar') {
115
+ event.preventDefault();
116
+ selectThis();
117
+ }
118
+ };
119
+
45
120
  const contextValue: IRadioContextValue = {
46
121
  isChecked,
47
- isDisabled: isDisabled || isDisabledProp,
122
+ isDisabled: resolvedDisabled,
48
123
  isFocusVisible: isFocusVisible || isFocusVisibleProp,
49
124
  isHovered: isHovered || isHoveredProp,
50
125
  isInvalid: isInvalid || isInvalidProp,
51
- isReadOnly: isReadOnly || isReadOnlyProp,
126
+ isReadOnly: resolvedReadOnly,
52
127
  isPressed,
53
128
  isFocused,
54
129
  };
@@ -57,31 +132,61 @@ export const RadioRoot = forwardRef<React.ComponentRef<typeof Pressable>, RadioR
57
132
 
58
133
  return (
59
134
  <RadioProvider value={contextValue}>
60
- <Comp
61
- {...mergeProps(combinedProps as any, labelProps as any, focusProps as any)}
62
- ref={mergedRef}
63
- role="label"
64
- // eslint-disable-next-line react-native-a11y/has-valid-accessibility-role
65
- accessibilityRole="label"
66
- {...mergeDataAttributes(
67
- props,
68
- dataAttributes({
69
- checked: isChecked,
70
- disabled: isDisabled || isDisabledProp,
71
- hover: isHovered || isHoveredProp,
72
- invalid: isInvalid || isInvalidProp,
73
- readonly: isReadOnly || isReadOnlyProp,
74
- active: isPressed,
75
- focus: isFocused,
76
- focusVisible: isFocusVisible || isFocusVisibleProp,
77
- }),
78
- )}
79
- >
135
+ <>
80
136
  <VisuallyHidden>
81
- <input {...inputProps} ref={inputRef} />
137
+ <input
138
+ ref={inputRef as React.RefObject<HTMLInputElement>}
139
+ type="radio"
140
+ name={groupName}
141
+ value={combinedProps.value}
142
+ checked={!!isChecked}
143
+ disabled={resolvedDisabled}
144
+ tabIndex={-1}
145
+ aria-hidden
146
+ readOnly
147
+ // Decorative form mirror — pointer events must not steal clicks from Pressable.
148
+ style={{ pointerEvents: 'none' }}
149
+ />
82
150
  </VisuallyHidden>
83
- {children}
84
- </Comp>
151
+ <Comp
152
+ {...(combinedProps as object)}
153
+ {...inputAriaProps}
154
+ ref={setRefs}
155
+ role="radio"
156
+ aria-checked={isChecked}
157
+ aria-disabled={resolvedDisabled || undefined}
158
+ accessibilityRole="radio"
159
+ accessibilityState={{ checked: isChecked, disabled: resolvedDisabled }}
160
+ tabIndex={resolvedDisabled ? -1 : isRovingTabStop ? 0 : -1}
161
+ disabled={resolvedDisabled}
162
+ {...focusProps}
163
+ onPress={selectThis}
164
+ onClick={selectThis}
165
+ onKeyDown={composeEventHandlers(
166
+ (
167
+ combinedProps as {
168
+ onKeyDown?: (e: { key: string; preventDefault: () => void }) => void;
169
+ }
170
+ ).onKeyDown,
171
+ handleKeyDown,
172
+ )}
173
+ {...mergeDataAttributes(
174
+ props,
175
+ dataAttributes({
176
+ checked: isChecked,
177
+ disabled: resolvedDisabled,
178
+ hover: isHovered || isHoveredProp,
179
+ invalid: isInvalid || isInvalidProp,
180
+ readonly: resolvedReadOnly,
181
+ active: isPressed,
182
+ focus: isFocused,
183
+ focusVisible: isFocusVisible || isFocusVisibleProp,
184
+ }),
185
+ )}
186
+ >
187
+ {children}
188
+ </Comp>
189
+ </>
85
190
  </RadioProvider>
86
191
  );
87
192
  },
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { createContext } from '@cdx-ui/utils';
3
3
  import type { RadioGroupState } from '@react-stately/radio';
4
- import type { IRadioContextValue } from './types';
4
+ import type { IRadioContextValue, RegisteredRadio } from './types';
5
5
 
6
6
  export const [RadioProvider, useRadioContext] = createContext<IRadioContextValue>('RadioContext');
7
7
 
@@ -18,4 +18,9 @@ export const RadioGroupContext = React.createContext<{
18
18
  state: IRadioGroupState;
19
19
  radioGroupState: RadioGroupState;
20
20
  name?: string;
21
+ focusedValue?: string | undefined;
22
+ setFocusedValue?: (value: string | undefined) => void;
23
+ registerRadio?: (value: string, entry: RegisteredRadio) => void;
24
+ unregisterRadio?: (value: string) => void;
25
+ getRadios?: () => [string, RegisteredRadio][];
21
26
  } | null>(null);
@@ -43,3 +43,9 @@ export interface IRadioContextValue {
43
43
  isHovered?: boolean;
44
44
  isPressed?: boolean;
45
45
  }
46
+
47
+ /** Web roving-focus registration entry for a single radio item. */
48
+ export interface RegisteredRadio {
49
+ disabled: boolean;
50
+ node: { focus?: () => void } | null;
51
+ }
@@ -0,0 +1,104 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import type { RegisteredRadio } from './types';
3
+
4
+ // Web-only — native platforms rely on OS-managed sequential focus (VoiceOver/TalkBack).
5
+
6
+ function isEnabledRadioValue(
7
+ value: string | null | undefined,
8
+ enabledRadios: [string, RegisteredRadio][],
9
+ ): value is string {
10
+ return value != null && enabledRadios.some(([radioValue]) => radioValue === value);
11
+ }
12
+
13
+ /** Resolves which radio value should hold the roving tab stop / keyboard focus anchor. */
14
+ export function getRovingFocusValue(
15
+ focusedValue: string | undefined,
16
+ selectedValue: string | null | undefined,
17
+ enabledRadios: [string, RegisteredRadio][],
18
+ ): string | undefined {
19
+ const defaultFocusValue = isEnabledRadioValue(selectedValue, enabledRadios)
20
+ ? selectedValue
21
+ : enabledRadios[0]?.[0];
22
+
23
+ const resolvedFocusedValue = isEnabledRadioValue(focusedValue, enabledRadios)
24
+ ? focusedValue
25
+ : undefined;
26
+
27
+ return resolvedFocusedValue ?? defaultFocusValue;
28
+ }
29
+
30
+ export interface UseRadioGroupNavigationOptions {
31
+ getRadios: () => [string, RegisteredRadio][];
32
+ focusedValue: string | undefined;
33
+ setFocusedValue: (value: string | undefined) => void;
34
+ selectedValue: string | null | undefined;
35
+ selectValue: (value: string) => void;
36
+ }
37
+
38
+ export function useRadioGroupNavigation({
39
+ getRadios,
40
+ focusedValue,
41
+ setFocusedValue,
42
+ selectedValue,
43
+ selectValue,
44
+ }: UseRadioGroupNavigationOptions) {
45
+ const selectValueRef = useRef(selectValue);
46
+ selectValueRef.current = selectValue;
47
+
48
+ const getEnabledRadios = useCallback(
49
+ () => getRadios().filter(([, entry]) => !entry.disabled),
50
+ [getRadios],
51
+ );
52
+
53
+ const moveFocus = useCallback(
54
+ ([targetValue, targetEntry]: [string, RegisteredRadio]) => {
55
+ setFocusedValue(targetValue);
56
+ targetEntry.node?.focus?.();
57
+ selectValueRef.current(targetValue);
58
+ },
59
+ [setFocusedValue],
60
+ );
61
+
62
+ const handleKeyDown = useCallback(
63
+ (e: { key: string; preventDefault: () => void }) => {
64
+ const enabledRadios = getEnabledRadios();
65
+ if (enabledRadios.length === 0) {
66
+ return;
67
+ }
68
+
69
+ const currentValue = getRovingFocusValue(focusedValue, selectedValue, enabledRadios);
70
+ const idx = enabledRadios.findIndex(([value]) => value === currentValue);
71
+
72
+ switch (e.key) {
73
+ case 'ArrowDown':
74
+ case 'ArrowRight': {
75
+ e.preventDefault();
76
+ moveFocus(enabledRadios[idx < enabledRadios.length - 1 ? idx + 1 : 0]);
77
+ break;
78
+ }
79
+ case 'ArrowUp':
80
+ case 'ArrowLeft': {
81
+ e.preventDefault();
82
+ moveFocus(enabledRadios[idx > 0 ? idx - 1 : enabledRadios.length - 1]);
83
+ break;
84
+ }
85
+ case 'Home': {
86
+ e.preventDefault();
87
+ moveFocus(enabledRadios[0]);
88
+ break;
89
+ }
90
+ case 'End': {
91
+ e.preventDefault();
92
+ const last = enabledRadios.at(-1);
93
+ if (last) {
94
+ moveFocus(last);
95
+ }
96
+ break;
97
+ }
98
+ }
99
+ },
100
+ [focusedValue, selectedValue, getEnabledRadios, moveFocus],
101
+ );
102
+
103
+ return { handleKeyDown };
104
+ }
@@ -15,12 +15,6 @@ export function useRadioRoot(
15
15
  { useInputRefForAria = false }: UseRadioRootOptions = {},
16
16
  ) {
17
17
  const formControlContext = useFormControlContext();
18
-
19
- const { isInvalid, isReadOnly, ...combinedProps } = {
20
- ...formControlContext,
21
- ...props,
22
- };
23
-
24
18
  const radioGroupContext = useContext(RadioGroupContext);
25
19
 
26
20
  if (!radioGroupContext) {
@@ -29,17 +23,30 @@ export function useRadioRoot(
29
23
  );
30
24
  }
31
25
 
26
+ const isInvalid = props.isInvalid ?? formControlContext.isInvalid;
27
+ const isReadOnly =
28
+ props.isReadOnly ?? formControlContext.isReadOnly ?? radioGroupContext.state.isReadOnly;
29
+ const isDisabled =
30
+ props.isDisabled ?? formControlContext.isDisabled ?? radioGroupContext.state.isDisabled;
31
+
32
+ const combinedProps = {
33
+ ...props,
34
+ isInvalid,
35
+ isReadOnly,
36
+ isDisabled,
37
+ };
38
+
32
39
  const rootRef = useRef(null);
33
40
  const inputRef = useRef<HTMLInputElement | null>(null);
34
41
  const mergedRootRef = mergeRefs(ref as any, rootRef as any);
35
- const ariaLabel = combinedProps['aria-label'] || combinedProps.value || 'Radio';
42
+ const ariaLabel = props['aria-label'] || props.value || 'Radio';
36
43
 
37
44
  const radio = useRadio(
38
45
  {
39
46
  ...combinedProps,
40
47
  'aria-label': ariaLabel,
41
- isReadOnly: isReadOnly || radioGroupContext.state.isReadOnly,
42
- isDisabled: combinedProps.isDisabled || radioGroupContext.state.isDisabled,
48
+ isReadOnly,
49
+ isDisabled,
43
50
  } as any,
44
51
  radioGroupContext.radioGroupState,
45
52
  (useInputRefForAria ? inputRef : rootRef) as any,
@@ -49,7 +56,7 @@ export function useRadioRoot(
49
56
  labelProps?: Record<string, unknown>;
50
57
  };
51
58
 
52
- const { checked: isChecked, disabled: isDisabled } = inputProps;
59
+ const { checked: isChecked, disabled: isDisabledFromInput } = inputProps;
53
60
 
54
61
  const { hoverProps, isHovered } = useHover({}, rootRef);
55
62
 
@@ -60,7 +67,7 @@ export function useRadioRoot(
60
67
  inputProps,
61
68
  labelProps: labelProps ?? {},
62
69
  isChecked,
63
- isDisabled,
70
+ isDisabled: isDisabledFromInput,
64
71
  isHovered,
65
72
  hoverProps,
66
73
  mergedRef: mergedRootRef,