@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
@@ -4,8 +4,10 @@ interface UseRadioRootOptions {
4
4
  }
5
5
  export declare function useRadioRoot(props: IRadioProps, ref?: React.Ref<unknown>, { useInputRefForAria }?: UseRadioRootOptions): {
6
6
  combinedProps: {
7
+ isInvalid: boolean | undefined;
8
+ isReadOnly: boolean;
9
+ isDisabled: boolean;
7
10
  value: string;
8
- isDisabled?: boolean | undefined;
9
11
  isHovered?: boolean;
10
12
  isFocused?: boolean;
11
13
  isPressed?: boolean;
@@ -68,7 +70,7 @@ export declare function useRadioRoot(props: IRadioProps, ref?: React.Ref<unknown
68
70
  accessibilityShowsLargeContentViewer?: boolean | undefined;
69
71
  accessibilityLargeContentTitle?: string | undefined;
70
72
  accessibilityRespondsToUserInteraction?: boolean | undefined;
71
- id?: string | undefined;
73
+ id?: string | undefined | undefined;
72
74
  onLayout?: ((event: import("react-native").LayoutChangeEvent) => void) | undefined | undefined;
73
75
  testID?: string | undefined | undefined;
74
76
  nativeID?: string | undefined | undefined;
@@ -116,22 +118,6 @@ export declare function useRadioRoot(props: IRadioProps, ref?: React.Ref<unknown
116
118
  onPointerDownCapture?: ((event: import("react-native").PointerEvent) => void) | undefined | undefined;
117
119
  onPointerUp?: ((event: import("react-native").PointerEvent) => void) | undefined | undefined;
118
120
  onPointerUpCapture?: ((event: import("react-native").PointerEvent) => void) | undefined | undefined;
119
- hasFeedbackText?: boolean | undefined;
120
- hasHelpText?: boolean | undefined;
121
- setHasFeedbackText?: import("react").Dispatch<import("react").SetStateAction<boolean>> | undefined;
122
- setHasHelpText?: import("react").Dispatch<import("react").SetStateAction<boolean>> | undefined;
123
- name?: string | undefined;
124
- labelId?: string | undefined;
125
- feedbackId?: string | undefined;
126
- helpTextId?: string | undefined;
127
- htmlProps?: {
128
- [x: string]: unknown;
129
- } | undefined;
130
- inputRef?: React.RefObject<import("@cdx-ui/utils").Focusable | null> | undefined;
131
- focusInput?: (() => void) | undefined;
132
- isLabelFocused?: boolean | undefined;
133
- setIsLabelFocused?: React.Dispatch<React.SetStateAction<boolean>> | undefined;
134
- isRequired?: boolean | undefined;
135
121
  };
136
122
  isInvalid: boolean;
137
123
  isReadOnly: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"useRadioRoot.d.ts","sourceRoot":"","sources":["../../../src/radio/useRadioRoot.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,UAAU,mBAAmB;IAC3B,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,wBAAgB,YAAY,CAC1B,KAAK,EAAE,WAAW,EAClB,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,EACxB,EAAE,kBAA0B,EAAE,GAAE,mBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsDzD"}
1
+ {"version":3,"file":"useRadioRoot.d.ts","sourceRoot":"","sources":["../../../src/radio/useRadioRoot.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,UAAU,mBAAmB;IAC3B,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,wBAAgB,YAAY,CAC1B,KAAK,EAAE,WAAW,EAClB,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,EACxB,EAAE,kBAA0B,EAAE,GAAE,mBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA6DzD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cdx-ui/primitives",
3
- "version": "0.0.1-beta.141",
3
+ "version": "0.0.1-beta.142",
4
4
  "license": "MIT",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -61,8 +61,8 @@
61
61
  "@react-stately/checkbox": "3.7.4",
62
62
  "@react-stately/radio": "3.12.0",
63
63
  "@react-stately/toggle": "3.9.4",
64
- "@cdx-ui/formatters": "0.0.1-beta.132",
65
- "@cdx-ui/utils": "0.0.1-beta.141"
64
+ "@cdx-ui/formatters": "0.0.1-beta.133",
65
+ "@cdx-ui/utils": "0.0.1-beta.142"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/react": "19.1.17",
@@ -1,6 +1,7 @@
1
1
  import { forwardRef } from 'react';
2
2
  import { Pressable, type PressableProps } from 'react-native';
3
3
  import { VisuallyHidden } from '@react-aria/visually-hidden';
4
+ import { composeEventHandlers } from '@cdx-ui/utils';
4
5
  import { useFocusRing } from '@react-native-aria/focus';
5
6
  import { Slot } from '../slot';
6
7
  import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
@@ -12,6 +13,14 @@ export interface CheckboxRootProps extends ICheckboxProps {
12
13
  asChild?: boolean;
13
14
  }
14
15
 
16
+ /**
17
+ * Web Checkbox root.
18
+ *
19
+ * On web, `@react-native-aria/checkbox` re-exports `@react-aria/checkbox`'s
20
+ * `useCheckbox`, whose `inputProps` target a native `<input type="checkbox">`
21
+ * (selection via `onChange`). Toggle must be applied explicitly on the Pressable —
22
+ * `inputProps.onPress` does **not** call `toggleState.toggle()`.
23
+ */
15
24
  export const CheckboxRoot = forwardRef<React.ComponentRef<typeof Pressable>, CheckboxRootProps>(
16
25
  ({ asChild, children, ...props }, ref) => {
17
26
  const {
@@ -37,18 +46,55 @@ export const CheckboxRoot = forwardRef<React.ComponentRef<typeof Pressable>, Che
37
46
  isHovered,
38
47
  mergedRef,
39
48
  inputRef,
49
+ toggle,
40
50
  } = useCheckboxRoot(props, ref, { useInputRefForAria: true });
41
51
 
42
52
  const { focusProps, isFocusVisible } = useFocusRing();
43
53
 
54
+ // Strip native-input / internal props — they must not land on Pressable.
55
+ const {
56
+ onPress: _onPress,
57
+ onChange: _onChange,
58
+ role: _role,
59
+ tabIndex: _tabIndex,
60
+ disabled: _disabled,
61
+ checked: _checked,
62
+ value: _value,
63
+ type: _type,
64
+ name: _name,
65
+ form: _form,
66
+ required: _required,
67
+ ...inputAriaProps
68
+ } = groupItemInputProps;
69
+
70
+ const resolvedChecked = !!(isChecked || isCheckedProp);
71
+ const resolvedIndeterminate = !!(isIndeterminate || isIndeterminateProp);
72
+ const resolvedDisabled = !!(isDisabled || isDisabledProp);
73
+ const resolvedReadOnly = !!(isReadOnly || isReadOnlyProp);
74
+ const ariaChecked = resolvedIndeterminate ? 'mixed' : resolvedChecked;
75
+
76
+ const handleToggle = () => {
77
+ if (resolvedDisabled || resolvedReadOnly) {
78
+ return;
79
+ }
80
+ toggle();
81
+ };
82
+
83
+ const handleKeyDown = (event: { key: string; preventDefault: () => void }) => {
84
+ if (event.key === ' ' || event.key === 'Spacebar') {
85
+ event.preventDefault();
86
+ handleToggle();
87
+ }
88
+ };
89
+
44
90
  const contextValue: ICheckboxContextValue = {
45
- isChecked: isChecked || isCheckedProp,
46
- isDisabled: isDisabled || isDisabledProp,
91
+ isChecked: resolvedChecked,
92
+ isDisabled: resolvedDisabled,
47
93
  isFocusVisible: isFocusVisible || isFocusVisibleProp,
48
94
  isHovered: isHovered || isHoveredProp,
49
95
  isInvalid: isInvalid || isInvalidProp,
50
- isReadOnly: isReadOnly || isReadOnlyProp,
51
- isIndeterminate: isIndeterminate || isIndeterminateProp,
96
+ isReadOnly: resolvedReadOnly,
97
+ isIndeterminate: resolvedIndeterminate,
52
98
  isPressed,
53
99
  isFocused,
54
100
  };
@@ -57,33 +103,63 @@ export const CheckboxRoot = forwardRef<React.ComponentRef<typeof Pressable>, Che
57
103
 
58
104
  return (
59
105
  <CheckboxProvider value={contextValue}>
60
- <Comp
61
- {...(combinedProps as PressableProps)}
62
- ref={mergedRef}
63
- tabIndex={-1}
64
- role="label"
65
- // eslint-disable-next-line react-native-a11y/has-valid-accessibility-role
66
- accessibilityRole="label"
67
- {...mergeDataAttributes(
68
- props,
69
- dataAttributes({
70
- checked: isChecked || isCheckedProp,
71
- disabled: isDisabled || isDisabledProp,
72
- hover: isHovered || isHoveredProp,
73
- invalid: isInvalid || isInvalidProp,
74
- readonly: isReadOnly || isReadOnlyProp,
75
- active: isPressed,
76
- focus: isFocused,
77
- indeterminate: isIndeterminate || isIndeterminateProp,
78
- focusVisible: isFocusVisible,
79
- }),
80
- )}
81
- >
106
+ <>
82
107
  <VisuallyHidden>
83
- <input {...groupItemInputProps} {...focusProps} ref={inputRef} />
108
+ <input
109
+ ref={inputRef as React.RefObject<HTMLInputElement>}
110
+ type="checkbox"
111
+ value={combinedProps.value}
112
+ checked={resolvedChecked}
113
+ disabled={resolvedDisabled}
114
+ tabIndex={-1}
115
+ aria-hidden
116
+ readOnly
117
+ style={{ pointerEvents: 'none' }}
118
+ />
84
119
  </VisuallyHidden>
85
- {children}
86
- </Comp>
120
+ <Comp
121
+ {...(combinedProps as PressableProps)}
122
+ {...inputAriaProps}
123
+ ref={mergedRef}
124
+ role="checkbox"
125
+ aria-checked={ariaChecked}
126
+ aria-disabled={resolvedDisabled || undefined}
127
+ accessibilityRole="checkbox"
128
+ accessibilityState={{
129
+ checked: resolvedIndeterminate ? 'mixed' : resolvedChecked,
130
+ disabled: resolvedDisabled,
131
+ }}
132
+ tabIndex={resolvedDisabled ? -1 : 0}
133
+ disabled={resolvedDisabled}
134
+ {...focusProps}
135
+ onPress={handleToggle}
136
+ onClick={handleToggle}
137
+ onKeyDown={composeEventHandlers(
138
+ (
139
+ combinedProps as {
140
+ onKeyDown?: (e: { key: string; preventDefault: () => void }) => void;
141
+ }
142
+ ).onKeyDown,
143
+ handleKeyDown,
144
+ )}
145
+ {...mergeDataAttributes(
146
+ props,
147
+ dataAttributes({
148
+ checked: resolvedChecked,
149
+ disabled: resolvedDisabled,
150
+ hover: isHovered || isHoveredProp,
151
+ invalid: isInvalid || isInvalidProp,
152
+ readonly: resolvedReadOnly,
153
+ active: isPressed,
154
+ focus: isFocused,
155
+ indeterminate: resolvedIndeterminate,
156
+ focusVisible: isFocusVisible || isFocusVisibleProp,
157
+ }),
158
+ )}
159
+ >
160
+ {children}
161
+ </Comp>
162
+ </>
87
163
  </CheckboxProvider>
88
164
  );
89
165
  },
@@ -1,4 +1,4 @@
1
- import { useContext, useRef } from 'react';
1
+ import { useCallback, useContext, useRef } from 'react';
2
2
  import { mergeRefs, useFormControlContext } from '@cdx-ui/utils';
3
3
  import { useCheckbox } from '@react-native-aria/checkbox';
4
4
  import { useHover } from '@react-native-aria/interactions';
@@ -16,29 +16,43 @@ export function useCheckboxRoot(
16
16
  { useInputRefForAria = false }: UseCheckboxRootOptions = {},
17
17
  ) {
18
18
  const formControlContext = useFormControlContext();
19
+ const checkboxGroupContext = useContext(CheckboxGroupContext);
20
+
21
+ // Only pull field-state flags from FormControl — never spread the whole context
22
+ // (ids, setters, htmlProps) onto the host element.
23
+ const isInvalid = props.isInvalid ?? formControlContext.isInvalid;
24
+ const isReadOnly =
25
+ props.isReadOnly ?? formControlContext.isReadOnly ?? checkboxGroupContext?.state.isReadOnly;
26
+ const isDisabled =
27
+ props.isDisabled ?? formControlContext.isDisabled ?? checkboxGroupContext?.state.isDisabled;
28
+ const isIndeterminate = props.isIndeterminate;
19
29
 
20
- const { isInvalid, isReadOnly, isIndeterminate, ...combinedProps } = {
21
- ...formControlContext,
30
+ const combinedProps = {
22
31
  ...props,
32
+ isInvalid,
33
+ isReadOnly,
34
+ isDisabled,
35
+ isIndeterminate,
23
36
  };
24
37
 
25
- const checkboxGroupContext = useContext(CheckboxGroupContext);
26
-
27
38
  const state = useToggleState({
28
- ...combinedProps,
29
39
  defaultSelected: props.defaultIsChecked,
30
40
  isSelected: props.isChecked,
41
+ onChange: props.onChange,
42
+ isReadOnly,
43
+ isDisabled,
31
44
  });
32
45
 
33
46
  const rootRef = useRef(null);
34
47
  const inputRef = useRef<HTMLInputElement | null>(null);
35
48
  const mergedRootRef = mergeRefs(ref as any, rootRef as any);
36
- const ariaLabel = combinedProps['aria-label'] || combinedProps.value || 'Checkbox';
49
+ const ariaLabel = props['aria-label'] || props.value || 'Checkbox';
37
50
 
38
51
  const groupToggleState = useToggleState(
39
52
  checkboxGroupContext
40
53
  ? {
41
- isReadOnly: isReadOnly || checkboxGroupContext.state.isReadOnly,
54
+ isReadOnly: !!isReadOnly,
55
+ isDisabled: !!isDisabled,
42
56
  isSelected: checkboxGroupContext.state.isSelected(combinedProps.value),
43
57
  onChange(isSelected: boolean) {
44
58
  if (isSelected) {
@@ -52,23 +66,30 @@ export function useCheckboxRoot(
52
66
  : { isSelected: false },
53
67
  );
54
68
 
69
+ const toggleState = checkboxGroupContext ? groupToggleState : state;
70
+
55
71
  const { inputProps: groupItemInputProps } = useCheckbox(
56
72
  {
57
73
  ...combinedProps,
58
74
  'aria-label': ariaLabel,
59
- ...(checkboxGroupContext && {
60
- isReadOnly: isReadOnly || checkboxGroupContext.state.isReadOnly,
61
- isDisabled: combinedProps.isDisabled || checkboxGroupContext.state.isDisabled,
62
- }),
75
+ isReadOnly: !!isReadOnly,
76
+ isDisabled: !!isDisabled,
63
77
  } as any,
64
- checkboxGroupContext ? groupToggleState : state,
78
+ toggleState,
65
79
  (useInputRefForAria ? inputRef : rootRef) as any,
66
80
  );
67
81
 
68
- const { checked: isChecked, disabled: isDisabled } = groupItemInputProps;
82
+ const { checked: isChecked, disabled: isDisabledFromInput } = groupItemInputProps;
69
83
 
70
84
  const { hoverProps, isHovered } = useHover({}, rootRef);
71
85
 
86
+ const toggle = useCallback(() => {
87
+ if (isDisabled || isReadOnly) {
88
+ return;
89
+ }
90
+ toggleState.toggle();
91
+ }, [isDisabled, isReadOnly, toggleState]);
92
+
72
93
  return {
73
94
  combinedProps,
74
95
  isInvalid,
@@ -76,10 +97,11 @@ export function useCheckboxRoot(
76
97
  isIndeterminate,
77
98
  groupItemInputProps,
78
99
  isChecked,
79
- isDisabled,
100
+ isDisabled: isDisabledFromInput,
80
101
  isHovered,
81
102
  hoverProps,
82
103
  mergedRef: mergedRootRef,
83
104
  inputRef,
105
+ toggle,
84
106
  };
85
107
  }
@@ -1,12 +1,13 @@
1
- import { forwardRef } from 'react';
2
- import { View, type ViewProps } from 'react-native';
3
- import { useFormControlContext } from '@cdx-ui/utils';
1
+ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Platform, View, type ViewProps } from 'react-native';
3
+ import { composeEventHandlers, useFormControl } from '@cdx-ui/utils';
4
4
  import { useRadioGroup } from '@react-native-aria/radio';
5
5
  import { useRadioGroupState } from '@react-stately/radio';
6
6
  import { Slot } from '../slot';
7
7
  import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
8
8
  import { RadioGroupContext } from './context';
9
- import type { IRadioGroupProps } from './types';
9
+ import type { IRadioGroupProps, RegisteredRadio } from './types';
10
+ import { useRadioGroupNavigation } from './useRadioGroupNavigation';
10
11
 
11
12
  export interface RadioGroupRootProps extends IRadioGroupProps {
12
13
  asChild?: boolean;
@@ -16,30 +17,86 @@ export interface RadioGroupRootProps extends IRadioGroupProps {
16
17
 
17
18
  export const RadioGroup = forwardRef<View, RadioGroupRootProps>(
18
19
  ({ asChild, children, className, style, ...props }, ref) => {
19
- const formControlContext = useFormControlContext();
20
+ const formControlProps = useFormControl({
21
+ isDisabled: props.isDisabled,
22
+ isRequired: props.isRequired,
23
+ isInvalid: props.isInvalid,
24
+ isReadOnly: props.isReadOnly,
25
+ });
20
26
 
21
- const combinedProps = {
22
- ...formControlContext,
23
- ...props,
24
- };
27
+ const isDisabled = props.isDisabled ?? Boolean(formControlProps.disabled);
28
+ const isInvalid = props.isInvalid ?? Boolean(formControlProps['aria-invalid']);
29
+ const isRequired = props.isRequired ?? Boolean(formControlProps.required);
30
+ const isReadOnly = props.isReadOnly ?? Boolean(formControlProps.readOnly);
31
+ const name = props.name ?? formControlProps.name;
32
+ const ariaLabel = props['aria-label'];
33
+ const ariaLabelledBy = props['aria-labelledby'] ?? formControlProps['aria-labelledby'];
34
+ const ariaDescribedBy = formControlProps['aria-describedby'];
25
35
 
26
36
  const radioGroupState = useRadioGroupState({
27
- ...combinedProps,
28
- validationState: combinedProps.isInvalid ? 'invalid' : 'valid',
37
+ name,
38
+ value: props.value,
39
+ defaultValue: props.defaultValue,
40
+ onChange: props.onChange,
41
+ isDisabled,
42
+ isInvalid,
43
+ isRequired,
44
+ isReadOnly,
45
+ validationState: isInvalid ? 'invalid' : 'valid',
29
46
  });
30
47
 
31
48
  const { radioGroupProps } = useRadioGroup(
32
49
  {
33
- ...combinedProps,
34
- 'aria-label': combinedProps['aria-label'],
50
+ 'aria-label': ariaLabel,
51
+ 'aria-labelledby': ariaLabelledBy,
52
+ isDisabled,
35
53
  },
36
54
  radioGroupState,
37
55
  );
38
56
 
39
- const isDisabled = combinedProps.isDisabled ?? false;
40
- const isInvalid = combinedProps.isInvalid ?? false;
41
- const isRequired = combinedProps.isRequired ?? false;
42
- const isReadOnly = combinedProps.isReadOnly ?? false;
57
+ const [focusedValue, setFocusedValue] = useState<string | undefined>(undefined);
58
+ // Identity of getRadios depends on this so roving tabIndex recomputes after register/unregister.
59
+ const [registrationEpoch, setRegistrationEpoch] = useState(0);
60
+ const radiosRef = useRef<Map<string, RegisteredRadio>>(new Map());
61
+
62
+ useEffect(() => {
63
+ setFocusedValue((prev) =>
64
+ prev !== undefined && prev !== radioGroupState.selectedValue ? undefined : prev,
65
+ );
66
+ }, [radioGroupState.selectedValue]);
67
+
68
+ const registerRadio = useCallback((value: string, entry: RegisteredRadio) => {
69
+ radiosRef.current.set(value, entry);
70
+ setRegistrationEpoch((epoch) => epoch + 1);
71
+ }, []);
72
+
73
+ const unregisterRadio = useCallback((value: string) => {
74
+ radiosRef.current.delete(value);
75
+ setRegistrationEpoch((epoch) => epoch + 1);
76
+ }, []);
77
+
78
+ const getRadios = useCallback(
79
+ () => Array.from(radiosRef.current.entries()),
80
+ [registrationEpoch],
81
+ );
82
+
83
+ const { handleKeyDown } = useRadioGroupNavigation({
84
+ getRadios,
85
+ focusedValue,
86
+ setFocusedValue,
87
+ selectedValue: radioGroupState.selectedValue,
88
+ selectValue: (value: string) => radioGroupState.setSelectedValue(value),
89
+ });
90
+
91
+ const webProps =
92
+ Platform.OS === 'web'
93
+ ? {
94
+ onKeyDown: composeEventHandlers(
95
+ (props as { onKeyDown?: (e: KeyboardEvent) => void }).onKeyDown,
96
+ handleKeyDown,
97
+ ),
98
+ }
99
+ : {};
43
100
 
44
101
  const Comp = asChild ? Slot : View;
45
102
 
@@ -55,18 +112,25 @@ export const RadioGroup = forwardRef<View, RadioGroupRootProps>(
55
112
  selectValue: (value: string) => radioGroupState.setSelectedValue(value),
56
113
  },
57
114
  radioGroupState,
58
- name: combinedProps.name,
115
+ name,
116
+ focusedValue,
117
+ setFocusedValue,
118
+ registerRadio,
119
+ unregisterRadio,
120
+ getRadios,
59
121
  }}
60
122
  >
61
123
  <Comp
62
124
  {...radioGroupProps}
63
- aria-label={combinedProps['aria-label']}
64
- aria-labelledby={combinedProps['aria-labelledby']}
125
+ aria-label={ariaLabel}
126
+ aria-labelledby={ariaLabelledBy}
127
+ aria-describedby={ariaDescribedBy}
65
128
  aria-required={isRequired || undefined}
66
129
  aria-readonly={isReadOnly || undefined}
67
130
  className={className}
68
131
  style={style}
69
132
  ref={ref}
133
+ {...webProps}
70
134
  {...mergeDataAttributes(
71
135
  props,
72
136
  dataAttributes({