@cdx-ui/primitives 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/dialog/DialogContent.js +13 -5
- package/lib/commonjs/dialog/DialogContent.js.map +1 -1
- package/lib/commonjs/dialog/DialogPopup.js +7 -1
- package/lib/commonjs/dialog/DialogPopup.js.map +1 -1
- package/lib/commonjs/dialog/DialogRoot.js +3 -1
- package/lib/commonjs/dialog/DialogRoot.js.map +1 -1
- package/lib/commonjs/index.js +12 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/portal/portal.js +4 -2
- package/lib/commonjs/portal/portal.js.map +1 -1
- package/lib/commonjs/select/SelectContent.js +1 -0
- package/lib/commonjs/select/SelectContent.js.map +1 -1
- package/lib/commonjs/toggle-button/ToggleButtonGroup.js +185 -0
- package/lib/commonjs/toggle-button/ToggleButtonGroup.js.map +1 -0
- package/lib/commonjs/toggle-button/ToggleButtonRoot.js +131 -0
- package/lib/commonjs/toggle-button/ToggleButtonRoot.js.map +1 -0
- package/lib/commonjs/toggle-button/ToggleButtonRoot.web.js +180 -0
- package/lib/commonjs/toggle-button/ToggleButtonRoot.web.js.map +1 -0
- package/lib/commonjs/toggle-button/context.js +27 -0
- package/lib/commonjs/toggle-button/context.js.map +1 -0
- package/lib/commonjs/toggle-button/index.js +45 -0
- package/lib/commonjs/toggle-button/index.js.map +1 -0
- package/lib/commonjs/toggle-button/toggleState.js +23 -0
- package/lib/commonjs/toggle-button/toggleState.js.map +1 -0
- package/lib/commonjs/toggle-button/types.js +6 -0
- package/lib/commonjs/toggle-button/types.js.map +1 -0
- package/lib/commonjs/toggle-button/useToggleButtonGroupNavigation.js +80 -0
- package/lib/commonjs/toggle-button/useToggleButtonGroupNavigation.js.map +1 -0
- package/lib/module/dialog/DialogContent.js +13 -5
- package/lib/module/dialog/DialogContent.js.map +1 -1
- package/lib/module/dialog/DialogPopup.js +7 -1
- package/lib/module/dialog/DialogPopup.js.map +1 -1
- package/lib/module/dialog/DialogRoot.js +4 -2
- package/lib/module/dialog/DialogRoot.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/portal/portal.js +4 -2
- package/lib/module/portal/portal.js.map +1 -1
- package/lib/module/select/SelectContent.js +2 -1
- package/lib/module/select/SelectContent.js.map +1 -1
- package/lib/module/toggle-button/ToggleButtonGroup.js +181 -0
- package/lib/module/toggle-button/ToggleButtonGroup.js.map +1 -0
- package/lib/module/toggle-button/ToggleButtonRoot.js +127 -0
- package/lib/module/toggle-button/ToggleButtonRoot.js.map +1 -0
- package/lib/module/toggle-button/ToggleButtonRoot.web.js +176 -0
- package/lib/module/toggle-button/ToggleButtonRoot.web.js.map +1 -0
- package/lib/module/toggle-button/context.js +18 -0
- package/lib/module/toggle-button/context.js.map +1 -0
- package/lib/module/toggle-button/index.js +6 -0
- package/lib/module/toggle-button/index.js.map +1 -0
- package/lib/module/toggle-button/toggleState.js +19 -0
- package/lib/module/toggle-button/toggleState.js.map +1 -0
- package/lib/module/toggle-button/types.js +4 -0
- package/lib/module/toggle-button/types.js.map +1 -0
- package/lib/module/toggle-button/useToggleButtonGroupNavigation.js +75 -0
- package/lib/module/toggle-button/useToggleButtonGroupNavigation.js.map +1 -0
- package/lib/typescript/breadcrumbs/types.d.ts +13 -0
- package/lib/typescript/breadcrumbs/types.d.ts.map +1 -1
- package/lib/typescript/checkbox/types.d.ts +4 -0
- package/lib/typescript/checkbox/types.d.ts.map +1 -1
- package/lib/typescript/dialog/DialogContent.d.ts +1 -1
- package/lib/typescript/dialog/DialogContent.d.ts.map +1 -1
- package/lib/typescript/dialog/DialogPopup.d.ts.map +1 -1
- package/lib/typescript/dialog/DialogRoot.d.ts.map +1 -1
- package/lib/typescript/dialog/types.d.ts +12 -0
- package/lib/typescript/dialog/types.d.ts.map +1 -1
- package/lib/typescript/field/types.d.ts +1 -1
- package/lib/typescript/field/types.d.ts.map +1 -1
- package/lib/typescript/form/types.d.ts +6 -0
- package/lib/typescript/form/types.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +1 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/input/types.d.ts +1 -0
- package/lib/typescript/input/types.d.ts.map +1 -1
- package/lib/typescript/link/types.d.ts +2 -2
- package/lib/typescript/list-item/types.d.ts +3 -2
- package/lib/typescript/list-item/types.d.ts.map +1 -1
- package/lib/typescript/portal/portal.d.ts +13 -2
- package/lib/typescript/portal/portal.d.ts.map +1 -1
- package/lib/typescript/progress-bar/types.d.ts +9 -4
- package/lib/typescript/progress-bar/types.d.ts.map +1 -1
- package/lib/typescript/radio/types.d.ts +2 -0
- package/lib/typescript/radio/types.d.ts.map +1 -1
- package/lib/typescript/select/SelectContent.d.ts.map +1 -1
- package/lib/typescript/switch/types.d.ts +4 -4
- package/lib/typescript/tabs/types.d.ts +7 -2
- package/lib/typescript/tabs/types.d.ts.map +1 -1
- package/lib/typescript/toggle-button/ToggleButtonGroup.d.ts +5 -0
- package/lib/typescript/toggle-button/ToggleButtonGroup.d.ts.map +1 -0
- package/lib/typescript/toggle-button/ToggleButtonRoot.d.ts +5 -0
- package/lib/typescript/toggle-button/ToggleButtonRoot.d.ts.map +1 -0
- package/lib/typescript/toggle-button/ToggleButtonRoot.web.d.ts +5 -0
- package/lib/typescript/toggle-button/ToggleButtonRoot.web.d.ts.map +1 -0
- package/lib/typescript/toggle-button/context.d.ts +27 -0
- package/lib/typescript/toggle-button/context.d.ts.map +1 -0
- package/lib/typescript/toggle-button/index.d.ts +5 -0
- package/lib/typescript/toggle-button/index.d.ts.map +1 -0
- package/lib/typescript/toggle-button/toggleState.d.ts +14 -0
- package/lib/typescript/toggle-button/toggleState.d.ts.map +1 -0
- package/lib/typescript/toggle-button/types.d.ts +87 -0
- package/lib/typescript/toggle-button/types.d.ts.map +1 -0
- package/lib/typescript/toggle-button/useToggleButtonGroupNavigation.d.ts +19 -0
- package/lib/typescript/toggle-button/useToggleButtonGroupNavigation.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/breadcrumbs/types.ts +13 -0
- package/src/checkbox/types.ts +4 -0
- package/src/dialog/DialogContent.tsx +13 -5
- package/src/dialog/DialogPopup.tsx +7 -1
- package/src/dialog/DialogRoot.tsx +3 -1
- package/src/dialog/types.ts +12 -0
- package/src/field/types.tsx +1 -1
- package/src/form/types.tsx +6 -0
- package/src/index.ts +1 -0
- package/src/input/types.ts +1 -0
- package/src/link/types.ts +2 -2
- package/src/list-item/types.ts +3 -2
- package/src/portal/portal.tsx +17 -2
- package/src/progress-bar/types.ts +9 -4
- package/src/radio/types.ts +2 -0
- package/src/select/SelectContent.tsx +2 -1
- package/src/switch/types.ts +4 -4
- package/src/tabs/types.ts +7 -2
- package/src/toggle-button/ToggleButtonGroup.tsx +260 -0
- package/src/toggle-button/ToggleButtonRoot.tsx +151 -0
- package/src/toggle-button/ToggleButtonRoot.web.tsx +225 -0
- package/src/toggle-button/context.tsx +30 -0
- package/src/toggle-button/index.ts +16 -0
- package/src/toggle-button/toggleState.ts +19 -0
- package/src/toggle-button/types.ts +94 -0
- package/src/toggle-button/useToggleButtonGroupNavigation.ts +112 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { PressableProps, ViewProps } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export type ToggleButtonGroupType = 'single' | 'multiple';
|
|
4
|
+
|
|
5
|
+
export type ToggleButtonGroupValue = string | string[] | undefined;
|
|
6
|
+
|
|
7
|
+
interface IToggleButtonGroupBaseProps extends ViewProps {
|
|
8
|
+
/** Merge group props onto a single child instead of rendering the default View. */
|
|
9
|
+
asChild?: boolean;
|
|
10
|
+
/** Disables every item in the group. */
|
|
11
|
+
isDisabled?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Layout direction and arrow-key axis.
|
|
14
|
+
* @default 'horizontal'
|
|
15
|
+
*/
|
|
16
|
+
orientation?: 'horizontal' | 'vertical';
|
|
17
|
+
/** Accessible name for the group (required when no visible label). */
|
|
18
|
+
'aria-label'?: string;
|
|
19
|
+
'aria-labelledby'?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IToggleButtonGroupSingleProps extends IToggleButtonGroupBaseProps {
|
|
23
|
+
/**
|
|
24
|
+
* Single-select (radio semantics). `value` is the selected item's `value`.
|
|
25
|
+
* @default 'single'
|
|
26
|
+
*/
|
|
27
|
+
type?: 'single';
|
|
28
|
+
/** Currently selected item `value` (controlled). */
|
|
29
|
+
value?: string;
|
|
30
|
+
/** Initially selected item `value` (uncontrolled). */
|
|
31
|
+
defaultValue?: string;
|
|
32
|
+
onValueChange?: (value: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface IToggleButtonGroupMultipleProps extends IToggleButtonGroupBaseProps {
|
|
36
|
+
/**
|
|
37
|
+
* Multi-select (pressed-button semantics). `value` is an array of selected item `value`s.
|
|
38
|
+
*/
|
|
39
|
+
type: 'multiple';
|
|
40
|
+
/** Currently selected item `value`s (controlled). */
|
|
41
|
+
value?: string[];
|
|
42
|
+
/** Initially selected item `value`s (uncontrolled). */
|
|
43
|
+
defaultValue?: string[];
|
|
44
|
+
onValueChange?: (value: string[]) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Discriminated union: `type` narrows `value` / `defaultValue` / `onValueChange`.
|
|
49
|
+
*/
|
|
50
|
+
export type IToggleButtonGroupProps =
|
|
51
|
+
| IToggleButtonGroupSingleProps
|
|
52
|
+
| IToggleButtonGroupMultipleProps;
|
|
53
|
+
|
|
54
|
+
export interface RegisteredToggleButton {
|
|
55
|
+
disabled: boolean;
|
|
56
|
+
node: { focus?: () => void } | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface IToggleButtonGroupContextValue {
|
|
60
|
+
type: ToggleButtonGroupType;
|
|
61
|
+
orientation: 'horizontal' | 'vertical';
|
|
62
|
+
value: ToggleButtonGroupValue;
|
|
63
|
+
toggleValue: (buttonValue: string) => void;
|
|
64
|
+
isSelected: (buttonValue: string) => boolean;
|
|
65
|
+
isGroupDisabled: boolean;
|
|
66
|
+
focusedValue?: string | undefined;
|
|
67
|
+
setFocusedValue?: (value: string | undefined) => void;
|
|
68
|
+
registerItem?: (value: string, entry: RegisteredToggleButton) => void;
|
|
69
|
+
unregisterItem?: (value: string) => void;
|
|
70
|
+
getItems?: () => [string, RegisteredToggleButton][];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IToggleButtonProps extends PressableProps {
|
|
74
|
+
/** Render the consumer's child in place of the default `Pressable` host. */
|
|
75
|
+
asChild?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Identifies this button within a `ToggleButtonGroup`. Must be unique within the group.
|
|
78
|
+
* Standalone buttons still require a value.
|
|
79
|
+
*/
|
|
80
|
+
value: string;
|
|
81
|
+
/**
|
|
82
|
+
* Controlled selected state (standalone only; ignored inside `ToggleButtonGroup`).
|
|
83
|
+
*/
|
|
84
|
+
isSelected?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Uncontrolled initial selected state (standalone only).
|
|
87
|
+
* @default false
|
|
88
|
+
*/
|
|
89
|
+
defaultSelected?: boolean;
|
|
90
|
+
/** Called when standalone selected state changes. */
|
|
91
|
+
onSelectedChange?: (isSelected: boolean) => void;
|
|
92
|
+
/** @default false */
|
|
93
|
+
isDisabled?: boolean;
|
|
94
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react';
|
|
2
|
+
import type { RegisteredToggleButton, ToggleButtonGroupType } from './types';
|
|
3
|
+
|
|
4
|
+
function isEnabledItemValue(
|
|
5
|
+
value: string | null | undefined,
|
|
6
|
+
enabledItems: [string, RegisteredToggleButton][],
|
|
7
|
+
): value is string {
|
|
8
|
+
return value != null && enabledItems.some(([itemValue]) => itemValue === value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Resolves which item value should hold the roving tab stop / keyboard focus anchor. */
|
|
12
|
+
export function getRovingFocusValue(
|
|
13
|
+
focusedValue: string | undefined,
|
|
14
|
+
anchorValue: string | null | undefined,
|
|
15
|
+
enabledItems: [string, RegisteredToggleButton][],
|
|
16
|
+
): string | undefined {
|
|
17
|
+
const defaultFocusValue = isEnabledItemValue(anchorValue, enabledItems)
|
|
18
|
+
? anchorValue
|
|
19
|
+
: enabledItems[0]?.[0];
|
|
20
|
+
|
|
21
|
+
const resolvedFocusedValue = isEnabledItemValue(focusedValue, enabledItems)
|
|
22
|
+
? focusedValue
|
|
23
|
+
: undefined;
|
|
24
|
+
|
|
25
|
+
return resolvedFocusedValue ?? defaultFocusValue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UseToggleButtonGroupNavigationOptions {
|
|
29
|
+
type: ToggleButtonGroupType;
|
|
30
|
+
orientation: 'horizontal' | 'vertical';
|
|
31
|
+
getItems: () => [string, RegisteredToggleButton][];
|
|
32
|
+
focusedValue: string | undefined;
|
|
33
|
+
setFocusedValue: (value: string | undefined) => void;
|
|
34
|
+
anchorValue: string | null | undefined;
|
|
35
|
+
selectOnFocus?: (value: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useToggleButtonGroupNavigation({
|
|
39
|
+
type,
|
|
40
|
+
orientation,
|
|
41
|
+
getItems,
|
|
42
|
+
focusedValue,
|
|
43
|
+
setFocusedValue,
|
|
44
|
+
anchorValue,
|
|
45
|
+
selectOnFocus,
|
|
46
|
+
}: UseToggleButtonGroupNavigationOptions) {
|
|
47
|
+
const selectOnFocusRef = useRef(selectOnFocus);
|
|
48
|
+
selectOnFocusRef.current = selectOnFocus;
|
|
49
|
+
|
|
50
|
+
const getEnabledItems = useCallback(
|
|
51
|
+
() => getItems().filter(([, entry]) => !entry.disabled),
|
|
52
|
+
[getItems],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const moveFocus = useCallback(
|
|
56
|
+
([targetValue, targetEntry]: [string, RegisteredToggleButton], shouldSelect: boolean) => {
|
|
57
|
+
setFocusedValue(targetValue);
|
|
58
|
+
targetEntry.node?.focus?.();
|
|
59
|
+
if (shouldSelect) {
|
|
60
|
+
selectOnFocusRef.current?.(targetValue);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
[setFocusedValue],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const handleKeyDown = useCallback(
|
|
67
|
+
(e: { key: string; preventDefault: () => void }) => {
|
|
68
|
+
const enabledItems = getEnabledItems();
|
|
69
|
+
if (enabledItems.length === 0) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const currentValue = getRovingFocusValue(focusedValue, anchorValue, enabledItems);
|
|
74
|
+
const idx = enabledItems.findIndex(([value]) => value === currentValue);
|
|
75
|
+
const shouldSelectOnFocus = type === 'single';
|
|
76
|
+
|
|
77
|
+
const nextKeys = orientation === 'vertical' ? ['ArrowDown'] : ['ArrowRight', 'ArrowDown'];
|
|
78
|
+
const prevKeys = orientation === 'vertical' ? ['ArrowUp'] : ['ArrowLeft', 'ArrowUp'];
|
|
79
|
+
|
|
80
|
+
if (nextKeys.includes(e.key)) {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
moveFocus(enabledItems[idx < enabledItems.length - 1 ? idx + 1 : 0], shouldSelectOnFocus);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (prevKeys.includes(e.key)) {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
moveFocus(enabledItems[idx > 0 ? idx - 1 : enabledItems.length - 1], shouldSelectOnFocus);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
switch (e.key) {
|
|
93
|
+
case 'Home': {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
moveFocus(enabledItems[0], shouldSelectOnFocus);
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case 'End': {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
const last = enabledItems.at(-1);
|
|
101
|
+
if (last) {
|
|
102
|
+
moveFocus(last, shouldSelectOnFocus);
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
[anchorValue, focusedValue, getEnabledItems, moveFocus, orientation, type],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return { handleKeyDown };
|
|
112
|
+
}
|