@cdx-ui/primitives 0.1.4 → 0.1.6

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 (64) hide show
  1. package/lib/commonjs/index.js +12 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/toggle-button/ToggleButtonGroup.js +185 -0
  4. package/lib/commonjs/toggle-button/ToggleButtonGroup.js.map +1 -0
  5. package/lib/commonjs/toggle-button/ToggleButtonRoot.js +131 -0
  6. package/lib/commonjs/toggle-button/ToggleButtonRoot.js.map +1 -0
  7. package/lib/commonjs/toggle-button/ToggleButtonRoot.web.js +180 -0
  8. package/lib/commonjs/toggle-button/ToggleButtonRoot.web.js.map +1 -0
  9. package/lib/commonjs/toggle-button/context.js +27 -0
  10. package/lib/commonjs/toggle-button/context.js.map +1 -0
  11. package/lib/commonjs/toggle-button/index.js +45 -0
  12. package/lib/commonjs/toggle-button/index.js.map +1 -0
  13. package/lib/commonjs/toggle-button/toggleState.js +23 -0
  14. package/lib/commonjs/toggle-button/toggleState.js.map +1 -0
  15. package/lib/commonjs/toggle-button/types.js +6 -0
  16. package/lib/commonjs/toggle-button/types.js.map +1 -0
  17. package/lib/commonjs/toggle-button/useToggleButtonGroupNavigation.js +80 -0
  18. package/lib/commonjs/toggle-button/useToggleButtonGroupNavigation.js.map +1 -0
  19. package/lib/module/index.js +1 -0
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/toggle-button/ToggleButtonGroup.js +181 -0
  22. package/lib/module/toggle-button/ToggleButtonGroup.js.map +1 -0
  23. package/lib/module/toggle-button/ToggleButtonRoot.js +127 -0
  24. package/lib/module/toggle-button/ToggleButtonRoot.js.map +1 -0
  25. package/lib/module/toggle-button/ToggleButtonRoot.web.js +176 -0
  26. package/lib/module/toggle-button/ToggleButtonRoot.web.js.map +1 -0
  27. package/lib/module/toggle-button/context.js +18 -0
  28. package/lib/module/toggle-button/context.js.map +1 -0
  29. package/lib/module/toggle-button/index.js +6 -0
  30. package/lib/module/toggle-button/index.js.map +1 -0
  31. package/lib/module/toggle-button/toggleState.js +19 -0
  32. package/lib/module/toggle-button/toggleState.js.map +1 -0
  33. package/lib/module/toggle-button/types.js +4 -0
  34. package/lib/module/toggle-button/types.js.map +1 -0
  35. package/lib/module/toggle-button/useToggleButtonGroupNavigation.js +75 -0
  36. package/lib/module/toggle-button/useToggleButtonGroupNavigation.js.map +1 -0
  37. package/lib/typescript/index.d.ts +1 -0
  38. package/lib/typescript/index.d.ts.map +1 -1
  39. package/lib/typescript/toggle-button/ToggleButtonGroup.d.ts +5 -0
  40. package/lib/typescript/toggle-button/ToggleButtonGroup.d.ts.map +1 -0
  41. package/lib/typescript/toggle-button/ToggleButtonRoot.d.ts +5 -0
  42. package/lib/typescript/toggle-button/ToggleButtonRoot.d.ts.map +1 -0
  43. package/lib/typescript/toggle-button/ToggleButtonRoot.web.d.ts +5 -0
  44. package/lib/typescript/toggle-button/ToggleButtonRoot.web.d.ts.map +1 -0
  45. package/lib/typescript/toggle-button/context.d.ts +27 -0
  46. package/lib/typescript/toggle-button/context.d.ts.map +1 -0
  47. package/lib/typescript/toggle-button/index.d.ts +5 -0
  48. package/lib/typescript/toggle-button/index.d.ts.map +1 -0
  49. package/lib/typescript/toggle-button/toggleState.d.ts +14 -0
  50. package/lib/typescript/toggle-button/toggleState.d.ts.map +1 -0
  51. package/lib/typescript/toggle-button/types.d.ts +87 -0
  52. package/lib/typescript/toggle-button/types.d.ts.map +1 -0
  53. package/lib/typescript/toggle-button/useToggleButtonGroupNavigation.d.ts +19 -0
  54. package/lib/typescript/toggle-button/useToggleButtonGroupNavigation.d.ts.map +1 -0
  55. package/package.json +4 -4
  56. package/src/index.ts +1 -0
  57. package/src/toggle-button/ToggleButtonGroup.tsx +260 -0
  58. package/src/toggle-button/ToggleButtonRoot.tsx +151 -0
  59. package/src/toggle-button/ToggleButtonRoot.web.tsx +225 -0
  60. package/src/toggle-button/context.tsx +30 -0
  61. package/src/toggle-button/index.ts +16 -0
  62. package/src/toggle-button/toggleState.ts +19 -0
  63. package/src/toggle-button/types.ts +94 -0
  64. package/src/toggle-button/useToggleButtonGroupNavigation.ts +112 -0
@@ -0,0 +1,260 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ComponentRef,
9
+ } from 'react';
10
+ import { Platform, View, type ViewProps } from 'react-native';
11
+ import { composeEventHandlers, useControllableState } from '@cdx-ui/utils';
12
+ import { Slot } from '../slot';
13
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
14
+ import { ToggleButtonGroupContextProvider } from './context';
15
+ import type {
16
+ IToggleButtonGroupProps,
17
+ RegisteredToggleButton,
18
+ ToggleButtonGroupType,
19
+ } from './types';
20
+ import { useToggleButtonGroupNavigation } from './useToggleButtonGroupNavigation';
21
+
22
+ type WidenedToggleButtonGroupProps = ViewProps & {
23
+ asChild?: boolean;
24
+ type?: ToggleButtonGroupType;
25
+ value?: string | string[];
26
+ defaultValue?: string | string[];
27
+ onValueChange?: (value: string | string[]) => void;
28
+ isDisabled?: boolean;
29
+ orientation?: 'horizontal' | 'vertical';
30
+ 'aria-label'?: string;
31
+ 'aria-labelledby'?: string;
32
+ };
33
+
34
+ export type ToggleButtonGroupProps = IToggleButtonGroupProps;
35
+
36
+ // Map insertion order drifts when an item re-registers (e.g. its disabled state changes)
37
+ // or mounts conditionally, so navigation order is derived from document position instead.
38
+ function getItemsInRenderedOrder(
39
+ items: Map<string, RegisteredToggleButton>,
40
+ ): [string, RegisteredToggleButton][] {
41
+ const entries = Array.from(items.entries());
42
+ if (Platform.OS !== 'web' || typeof Node === 'undefined') {
43
+ return entries;
44
+ }
45
+
46
+ return entries.sort(([, first], [, second]) => {
47
+ const firstNode = first.node as Node | null;
48
+ const secondNode = second.node as Node | null;
49
+ if (!firstNode?.compareDocumentPosition || !secondNode) {
50
+ return 0;
51
+ }
52
+
53
+ const position = firstNode.compareDocumentPosition(secondNode);
54
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
55
+ return -1;
56
+ }
57
+ if (position & Node.DOCUMENT_POSITION_PRECEDING) {
58
+ return 1;
59
+ }
60
+ return 0;
61
+ });
62
+ }
63
+
64
+ function getAnchorValue(
65
+ type: ToggleButtonGroupType,
66
+ state: string | string[] | undefined,
67
+ ): string | undefined {
68
+ if (type === 'single') {
69
+ return typeof state === 'string' ? state : undefined;
70
+ }
71
+ if (Array.isArray(state) && state.length > 0) {
72
+ return state[0];
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ export const ToggleButtonGroup = forwardRef<ComponentRef<typeof View>, ToggleButtonGroupProps>(
78
+ (props, ref) => {
79
+ const {
80
+ asChild = false,
81
+ children,
82
+ className,
83
+ style,
84
+ type: typeProp = 'single',
85
+ value: valueProp,
86
+ defaultValue,
87
+ onValueChange,
88
+ isDisabled = false,
89
+ orientation = 'horizontal',
90
+ 'aria-label': ariaLabel,
91
+ 'aria-labelledby': ariaLabelledBy,
92
+ ...rest
93
+ } = props as WidenedToggleButtonGroupProps & {
94
+ className?: string;
95
+ style?: ViewProps['style'];
96
+ };
97
+
98
+ const type: ToggleButtonGroupType = typeProp;
99
+
100
+ const defaultProp =
101
+ defaultValue !== undefined ? defaultValue : type === 'multiple' ? [] : undefined;
102
+
103
+ const [state, setState] = useControllableState<string | string[] | undefined>({
104
+ prop: valueProp,
105
+ defaultProp,
106
+ onChange: (next) => {
107
+ if (next !== undefined) {
108
+ onValueChange?.(next);
109
+ }
110
+ },
111
+ });
112
+
113
+ const [focusedValue, setFocusedValue] = useState<string | undefined>(undefined);
114
+ const [registrationEpoch, setRegistrationEpoch] = useState(0);
115
+ const itemsRef = useRef<Map<string, RegisteredToggleButton>>(new Map());
116
+
117
+ // Single-select: if the parent changes `value`, drop a stale roving tab stop so
118
+ // arrow keys follow the selected item. Multiple-select keeps focus on the last
119
+ // pressed/focused item even when it is no longer selected.
120
+ useEffect(() => {
121
+ if (type !== 'single') {
122
+ return;
123
+ }
124
+ const selectedId = typeof state === 'string' ? state : undefined;
125
+ setFocusedValue((prev) => (prev !== undefined && prev !== selectedId ? undefined : prev));
126
+ }, [type, state]);
127
+
128
+ const getItems = useCallback(
129
+ () => getItemsInRenderedOrder(itemsRef.current),
130
+ [registrationEpoch],
131
+ );
132
+
133
+ const registerItem = useCallback((itemValue: string, entry: RegisteredToggleButton) => {
134
+ itemsRef.current.set(itemValue, entry);
135
+ setRegistrationEpoch((epoch) => epoch + 1);
136
+ }, []);
137
+
138
+ const unregisterItem = useCallback((itemValue: string) => {
139
+ itemsRef.current.delete(itemValue);
140
+ setRegistrationEpoch((epoch) => epoch + 1);
141
+ }, []);
142
+
143
+ const isSelected = useCallback(
144
+ (buttonValue: string) => {
145
+ if (type === 'single') {
146
+ return state === buttonValue;
147
+ }
148
+ return Array.isArray(state) && state.includes(buttonValue);
149
+ },
150
+ [type, state],
151
+ );
152
+
153
+ const toggleValue = useCallback(
154
+ (buttonValue: string) => {
155
+ if (isDisabled) {
156
+ return;
157
+ }
158
+ if (type === 'single') {
159
+ if (state === buttonValue) {
160
+ return;
161
+ }
162
+ setState(buttonValue);
163
+ return;
164
+ }
165
+ const arr = Array.isArray(state) ? [...state] : [];
166
+ const idx = arr.indexOf(buttonValue);
167
+ if (idx >= 0) {
168
+ arr.splice(idx, 1);
169
+ setState(arr);
170
+ } else {
171
+ arr.push(buttonValue);
172
+ setState(arr);
173
+ }
174
+ },
175
+ [isDisabled, type, state, setState],
176
+ );
177
+
178
+ const anchorValue = getAnchorValue(type, state);
179
+
180
+ const { handleKeyDown } = useToggleButtonGroupNavigation({
181
+ type,
182
+ orientation,
183
+ getItems,
184
+ focusedValue,
185
+ setFocusedValue,
186
+ anchorValue,
187
+ selectOnFocus: type === 'single' ? toggleValue : undefined,
188
+ });
189
+
190
+ const webProps =
191
+ Platform.OS === 'web'
192
+ ? {
193
+ onKeyDown: composeEventHandlers(
194
+ (rest as { onKeyDown?: (e: KeyboardEvent) => void }).onKeyDown,
195
+ handleKeyDown,
196
+ ),
197
+ }
198
+ : {};
199
+
200
+ const contextValue = useMemo(
201
+ () => ({
202
+ type,
203
+ orientation,
204
+ value: state,
205
+ toggleValue,
206
+ isSelected,
207
+ isGroupDisabled: isDisabled,
208
+ focusedValue,
209
+ setFocusedValue,
210
+ registerItem,
211
+ unregisterItem,
212
+ getItems,
213
+ }),
214
+ [
215
+ type,
216
+ orientation,
217
+ state,
218
+ toggleValue,
219
+ isSelected,
220
+ isDisabled,
221
+ focusedValue,
222
+ registerItem,
223
+ unregisterItem,
224
+ getItems,
225
+ ],
226
+ );
227
+
228
+ // `AccessibilityRole` has no `group` member, so multiple-select relies on the `role` prop,
229
+ // which RN resolves natively and ranks above `accessibilityRole`.
230
+ const role = rest.role ?? (type === 'single' ? 'radiogroup' : 'group');
231
+ const Comp = asChild ? Slot : View;
232
+
233
+ return (
234
+ <ToggleButtonGroupContextProvider value={contextValue}>
235
+ <Comp
236
+ ref={ref}
237
+ {...rest}
238
+ role={role}
239
+ {...(type === 'single' ? { accessibilityRole: 'radiogroup' as const } : {})}
240
+ {...(ariaLabel ? { 'aria-label': ariaLabel, accessibilityLabel: ariaLabel } : {})}
241
+ {...(ariaLabelledBy ? { 'aria-labelledby': ariaLabelledBy } : {})}
242
+ className={className}
243
+ style={style}
244
+ {...mergeDataAttributes(
245
+ rest,
246
+ dataAttributes({
247
+ orientation,
248
+ disabled: isDisabled,
249
+ }),
250
+ )}
251
+ {...webProps}
252
+ >
253
+ {children}
254
+ </Comp>
255
+ </ToggleButtonGroupContextProvider>
256
+ );
257
+ },
258
+ );
259
+
260
+ ToggleButtonGroup.displayName = 'ToggleButtonGroup';
@@ -0,0 +1,151 @@
1
+ import { forwardRef, useMemo, type ComponentRef } from 'react';
2
+ import { Pressable, type PressableProps } from 'react-native';
3
+ import { composeEventHandlers, useControllableState } from '@cdx-ui/utils';
4
+ import { useFocus, useFocusRing } from '@react-native-aria/focus';
5
+ import { useHover, usePress } from '@react-native-aria/interactions';
6
+ import { Slot } from '../slot';
7
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
8
+ import { ToggleButtonProvider, useOptionalToggleButtonGroupContext } from './context';
9
+ import { getToggleState } from './toggleState';
10
+ import type { IToggleButtonProps } from './types';
11
+
12
+ export interface ToggleButtonRootProps extends IToggleButtonProps {}
13
+
14
+ export const ToggleButtonRoot = forwardRef<ComponentRef<typeof Pressable>, ToggleButtonRootProps>(
15
+ (
16
+ {
17
+ asChild = false,
18
+ value,
19
+ isSelected: controlledSelected,
20
+ defaultSelected,
21
+ onSelectedChange,
22
+ isDisabled: isDisabledProp = false,
23
+ children,
24
+ onPress,
25
+ onFocus,
26
+ onBlur,
27
+ style,
28
+ ...rest
29
+ },
30
+ ref,
31
+ ) => {
32
+ const group = useOptionalToggleButtonGroupContext();
33
+
34
+ const [standaloneSelected = false, setStandaloneSelected] = useControllableState<boolean>({
35
+ prop: controlledSelected,
36
+ defaultProp: defaultSelected ?? false,
37
+ onChange: (next) => {
38
+ onSelectedChange?.(next);
39
+ },
40
+ });
41
+
42
+ const isSelected = group ? group.isSelected(value) : standaloneSelected;
43
+ const isDisabled = (group?.isGroupDisabled ?? false) || isDisabledProp;
44
+
45
+ const { focusProps, isFocused } = useFocus();
46
+ const { isFocusVisible, focusProps: focusRingProps } = useFocusRing() as {
47
+ isFocusVisible: boolean;
48
+ focusProps: typeof focusProps;
49
+ };
50
+ const { pressProps, isPressed } = usePress({ isDisabled });
51
+ const { hoverProps, isHovered } = useHover();
52
+
53
+ const selectionType: 'single' | 'multiple' | 'standalone' = group ? group.type : 'standalone';
54
+
55
+ const role = selectionType === 'single' ? ('radio' as const) : ('button' as const);
56
+
57
+ const accessibilityState = useMemo(
58
+ () => ({
59
+ disabled: isDisabled,
60
+ ...(selectionType === 'single'
61
+ ? { checked: isSelected, selected: isSelected }
62
+ : { selected: isSelected }),
63
+ }),
64
+ [isDisabled, isSelected, selectionType],
65
+ );
66
+
67
+ const ariaProps =
68
+ selectionType === 'single' ? { 'aria-checked': isSelected } : { 'aria-pressed': isSelected };
69
+
70
+ const toggleButtonContext = useMemo(
71
+ () => ({
72
+ value,
73
+ isSelected,
74
+ isDisabled,
75
+ selectionType,
76
+ hover: isHovered,
77
+ active: isPressed,
78
+ focus: isFocused,
79
+ focusVisible: isFocusVisible,
80
+ }),
81
+ [
82
+ value,
83
+ isSelected,
84
+ isDisabled,
85
+ selectionType,
86
+ isHovered,
87
+ isPressed,
88
+ isFocused,
89
+ isFocusVisible,
90
+ ],
91
+ );
92
+
93
+ const composedOnPress = composeEventHandlers(onPress, () => {
94
+ if (isDisabled) {
95
+ return;
96
+ }
97
+ if (group) {
98
+ group.toggleValue(value);
99
+ } else {
100
+ setStandaloneSelected(!standaloneSelected);
101
+ }
102
+ });
103
+
104
+ const Comp = asChild ? Slot : Pressable;
105
+
106
+ return (
107
+ <ToggleButtonProvider value={toggleButtonContext}>
108
+ <Comp
109
+ ref={ref}
110
+ accessibilityRole={role}
111
+ role={role}
112
+ accessibilityState={accessibilityState}
113
+ {...ariaProps}
114
+ {...(isDisabled ? { 'aria-disabled': true as const } : {})}
115
+ disabled={isDisabled}
116
+ {...(rest as PressableProps)}
117
+ {...mergeDataAttributes(
118
+ rest,
119
+ dataAttributes({
120
+ selected: isSelected,
121
+ disabled: isDisabled,
122
+ active: isPressed,
123
+ hover: isHovered,
124
+ focus: isFocused,
125
+ focusVisible: isFocusVisible,
126
+ toggleState: getToggleState(isSelected, isPressed, isHovered),
127
+ }),
128
+ )}
129
+ style={style}
130
+ onPress={isDisabled ? undefined : composedOnPress}
131
+ onPressIn={composeEventHandlers(rest.onPressIn, pressProps.onPressIn)}
132
+ onPressOut={composeEventHandlers(rest.onPressOut, pressProps.onPressOut)}
133
+ onHoverIn={composeEventHandlers(rest.onHoverIn, hoverProps.onHoverIn)}
134
+ onHoverOut={composeEventHandlers(rest.onHoverOut, hoverProps.onHoverOut)}
135
+ onFocus={composeEventHandlers(
136
+ composeEventHandlers(onFocus, focusProps.onFocus),
137
+ focusRingProps.onFocus,
138
+ )}
139
+ onBlur={composeEventHandlers(
140
+ composeEventHandlers(onBlur, focusProps.onBlur),
141
+ focusRingProps.onBlur,
142
+ )}
143
+ >
144
+ {children}
145
+ </Comp>
146
+ </ToggleButtonProvider>
147
+ );
148
+ },
149
+ );
150
+
151
+ ToggleButtonRoot.displayName = 'ToggleButtonRoot';
@@ -0,0 +1,225 @@
1
+ import { forwardRef, useEffect, useMemo, useRef, type ComponentRef } from 'react';
2
+ import { Pressable, type PressableProps } from 'react-native';
3
+ import { composeEventHandlers, mergeRefs, useControllableState } from '@cdx-ui/utils';
4
+ import { useFocus, useFocusRing } from '@react-native-aria/focus';
5
+ import { useHover, usePress } from '@react-native-aria/interactions';
6
+ import { Slot } from '../slot';
7
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
8
+ import { ToggleButtonProvider, useOptionalToggleButtonGroupContext } from './context';
9
+ import { getToggleState } from './toggleState';
10
+ import type { IToggleButtonProps } from './types';
11
+ import { getRovingFocusValue } from './useToggleButtonGroupNavigation';
12
+
13
+ export interface ToggleButtonRootProps extends IToggleButtonProps {}
14
+
15
+ export const ToggleButtonRoot = forwardRef<ComponentRef<typeof Pressable>, ToggleButtonRootProps>(
16
+ (
17
+ {
18
+ asChild = false,
19
+ value,
20
+ isSelected: controlledSelected,
21
+ defaultSelected,
22
+ onSelectedChange,
23
+ isDisabled: isDisabledProp = false,
24
+ children,
25
+ onPress,
26
+ onFocus,
27
+ onBlur,
28
+ style,
29
+ ...rest
30
+ },
31
+ ref,
32
+ ) => {
33
+ const group = useOptionalToggleButtonGroupContext();
34
+
35
+ const [standaloneSelected = false, setStandaloneSelected] = useControllableState<boolean>({
36
+ prop: controlledSelected,
37
+ defaultProp: defaultSelected ?? false,
38
+ onChange: (next) => {
39
+ onSelectedChange?.(next);
40
+ },
41
+ });
42
+
43
+ const isSelected = group ? group.isSelected(value) : standaloneSelected;
44
+ const isDisabled = (group?.isGroupDisabled ?? false) || isDisabledProp;
45
+
46
+ const { focusProps, isFocused } = useFocus();
47
+ const { isFocusVisible, focusProps: focusRingProps } = useFocusRing() as {
48
+ isFocusVisible: boolean;
49
+ focusProps: typeof focusProps;
50
+ };
51
+ const { pressProps, isPressed } = usePress({ isDisabled });
52
+ const { hoverProps, isHovered } = useHover();
53
+
54
+ const selectionType: 'single' | 'multiple' | 'standalone' = group ? group.type : 'standalone';
55
+
56
+ const role = selectionType === 'single' ? ('radio' as const) : ('button' as const);
57
+
58
+ const accessibilityState = useMemo(
59
+ () => ({
60
+ disabled: isDisabled,
61
+ ...(selectionType === 'single'
62
+ ? { checked: isSelected, selected: isSelected }
63
+ : { selected: isSelected }),
64
+ }),
65
+ [isDisabled, isSelected, selectionType],
66
+ );
67
+
68
+ const ariaProps =
69
+ selectionType === 'single' ? { 'aria-checked': isSelected } : { 'aria-pressed': isSelected };
70
+
71
+ const nodeRef = useRef<ComponentRef<typeof Pressable> | null>(null);
72
+ const setRefs = mergeRefs(ref, nodeRef);
73
+
74
+ const registerItem = group?.registerItem;
75
+ const unregisterItem = group?.unregisterItem;
76
+ const getItems = group?.getItems;
77
+ const focusedValue = group?.focusedValue;
78
+ const anchorValue =
79
+ group?.type === 'single'
80
+ ? typeof group.value === 'string'
81
+ ? group.value
82
+ : undefined
83
+ : Array.isArray(group?.value) && group.value.length > 0
84
+ ? group.value[0]
85
+ : undefined;
86
+
87
+ useEffect(() => {
88
+ if (!registerItem || !unregisterItem) {
89
+ return;
90
+ }
91
+
92
+ registerItem(value, {
93
+ disabled: !!isDisabled,
94
+ node: nodeRef.current as { focus?: () => void } | null,
95
+ });
96
+
97
+ return () => unregisterItem(value);
98
+ }, [value, isDisabled, registerItem, unregisterItem]);
99
+
100
+ const isRovingTabStop = useMemo(() => {
101
+ if (!group || isDisabled) {
102
+ return !group;
103
+ }
104
+
105
+ const enabledItems = getItems?.().filter(([, entry]) => !entry.disabled) ?? [];
106
+ const rovingValue = getRovingFocusValue(focusedValue, anchorValue, enabledItems);
107
+
108
+ return value === rovingValue;
109
+ }, [anchorValue, focusedValue, getItems, group, isDisabled, value]);
110
+
111
+ const toggleButtonContext = useMemo(
112
+ () => ({
113
+ value,
114
+ isSelected,
115
+ isDisabled,
116
+ selectionType,
117
+ hover: isHovered,
118
+ active: isPressed,
119
+ focus: isFocused,
120
+ focusVisible: isFocusVisible,
121
+ }),
122
+ [
123
+ value,
124
+ isSelected,
125
+ isDisabled,
126
+ selectionType,
127
+ isHovered,
128
+ isPressed,
129
+ isFocused,
130
+ isFocusVisible,
131
+ ],
132
+ );
133
+
134
+ const markRovingFocus = () => {
135
+ if (!isDisabled) {
136
+ group?.setFocusedValue?.(value);
137
+ }
138
+ };
139
+
140
+ const toggle = () => {
141
+ if (isDisabled) {
142
+ return;
143
+ }
144
+ if (group) {
145
+ markRovingFocus();
146
+ group.toggleValue(value);
147
+ } else {
148
+ setStandaloneSelected(!standaloneSelected);
149
+ }
150
+ };
151
+
152
+ const composedOnPress = composeEventHandlers(onPress, toggle);
153
+
154
+ // RNW's press responder already turns Space into a press for role="button", but ignores
155
+ // it for role="radio", so only single-select items handle Space here.
156
+ const handleKeyDown = (event: {
157
+ key: string;
158
+ repeat?: boolean;
159
+ preventDefault: () => void;
160
+ }) => {
161
+ if (selectionType !== 'single') {
162
+ return;
163
+ }
164
+ if (event.key === ' ' || event.key === 'Spacebar') {
165
+ event.preventDefault();
166
+ if (!event.repeat) {
167
+ toggle();
168
+ }
169
+ }
170
+ };
171
+
172
+ const Comp = asChild ? Slot : Pressable;
173
+
174
+ return (
175
+ <ToggleButtonProvider value={toggleButtonContext}>
176
+ <Comp
177
+ ref={setRefs}
178
+ accessibilityRole={role}
179
+ role={role}
180
+ accessibilityState={accessibilityState}
181
+ {...ariaProps}
182
+ {...(isDisabled ? { 'aria-disabled': true as const } : {})}
183
+ disabled={isDisabled}
184
+ tabIndex={group ? (isDisabled ? -1 : isRovingTabStop ? 0 : -1) : undefined}
185
+ {...(rest as PressableProps)}
186
+ {...mergeDataAttributes(
187
+ rest,
188
+ dataAttributes({
189
+ selected: isSelected,
190
+ disabled: isDisabled,
191
+ active: isPressed,
192
+ hover: isHovered,
193
+ focus: isFocused,
194
+ focusVisible: isFocusVisible,
195
+ toggleState: getToggleState(isSelected, isPressed, isHovered),
196
+ }),
197
+ )}
198
+ style={style}
199
+ onPress={isDisabled ? undefined : composedOnPress}
200
+ onKeyDown={composeEventHandlers(
201
+ (rest as { onKeyDown?: (e: { key: string; preventDefault: () => void }) => void })
202
+ .onKeyDown,
203
+ handleKeyDown,
204
+ )}
205
+ onPressIn={composeEventHandlers(rest.onPressIn, pressProps.onPressIn)}
206
+ onPressOut={composeEventHandlers(rest.onPressOut, pressProps.onPressOut)}
207
+ onHoverIn={composeEventHandlers(rest.onHoverIn, hoverProps.onHoverIn)}
208
+ onHoverOut={composeEventHandlers(rest.onHoverOut, hoverProps.onHoverOut)}
209
+ onFocus={composeEventHandlers(
210
+ composeEventHandlers(onFocus, markRovingFocus),
211
+ composeEventHandlers(focusProps.onFocus, focusRingProps.onFocus),
212
+ )}
213
+ onBlur={composeEventHandlers(
214
+ composeEventHandlers(onBlur, focusProps.onBlur),
215
+ focusRingProps.onBlur,
216
+ )}
217
+ >
218
+ {children}
219
+ </Comp>
220
+ </ToggleButtonProvider>
221
+ );
222
+ },
223
+ );
224
+
225
+ ToggleButtonRoot.displayName = 'ToggleButtonRoot';
@@ -0,0 +1,30 @@
1
+ import { createContext as createReactContext, useContext } from 'react';
2
+ import { createContext } from '@cdx-ui/utils';
3
+ import type { IToggleButtonGroupContextValue } from './types';
4
+
5
+ export const [ToggleButtonProvider, useToggleButtonContext] = createContext<{
6
+ value: string;
7
+ isSelected: boolean;
8
+ isDisabled: boolean;
9
+ selectionType: 'single' | 'multiple' | 'standalone';
10
+ hover: boolean;
11
+ active: boolean;
12
+ focus: boolean;
13
+ focusVisible: boolean;
14
+ }>('ToggleButtonContext');
15
+
16
+ const ToggleButtonGroupContext = createReactContext<IToggleButtonGroupContextValue | null>(null);
17
+
18
+ export const ToggleButtonGroupContextProvider = ToggleButtonGroupContext.Provider;
19
+
20
+ export function useToggleButtonGroupContext(): IToggleButtonGroupContextValue {
21
+ const ctx = useContext(ToggleButtonGroupContext);
22
+ if (!ctx) {
23
+ throw new Error('ToggleButton must be used within ToggleButtonGroup');
24
+ }
25
+ return ctx;
26
+ }
27
+
28
+ export function useOptionalToggleButtonGroupContext(): IToggleButtonGroupContextValue | null {
29
+ return useContext(ToggleButtonGroupContext);
30
+ }
@@ -0,0 +1,16 @@
1
+ export { ToggleButtonRoot, type ToggleButtonRootProps } from './ToggleButtonRoot';
2
+ export { ToggleButtonGroup, type ToggleButtonGroupProps } from './ToggleButtonGroup';
3
+ export {
4
+ ToggleButtonProvider,
5
+ useToggleButtonContext,
6
+ useToggleButtonGroupContext,
7
+ useOptionalToggleButtonGroupContext,
8
+ } from './context';
9
+ export type {
10
+ IToggleButtonProps,
11
+ IToggleButtonGroupProps,
12
+ IToggleButtonGroupSingleProps,
13
+ IToggleButtonGroupMultipleProps,
14
+ IToggleButtonGroupContextValue,
15
+ ToggleButtonGroupType,
16
+ } from './types';
@@ -0,0 +1,19 @@
1
+ // Keyed `selected:active:hover`. Press outranks hover so each combination maps to exactly one
2
+ // value, letting the styled layer use single-condition selectors that also work on native.
3
+ const TOGGLE_STATES = {
4
+ 'false:false:false': 'idle',
5
+ 'false:false:true': 'hover',
6
+ 'false:true:false': 'active',
7
+ 'false:true:true': 'active',
8
+ 'true:false:false': 'selected',
9
+ 'true:false:true': 'selected-hover',
10
+ 'true:true:false': 'selected-active',
11
+ 'true:true:true': 'selected-active',
12
+ } as const;
13
+
14
+ export type ToggleState = (typeof TOGGLE_STATES)[keyof typeof TOGGLE_STATES];
15
+
16
+ export function getToggleState(selected: boolean, active: boolean, hover: boolean): ToggleState {
17
+ const key = [selected, active, hover].map((flag) => (flag ? 'true' : 'false')).join(':');
18
+ return TOGGLE_STATES[key as keyof typeof TOGGLE_STATES];
19
+ }