@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
package/src/portal/portal.tsx
CHANGED
|
@@ -130,7 +130,21 @@ export function PortalProvider({ children }: Readonly<{ children: React.ReactNod
|
|
|
130
130
|
|
|
131
131
|
// --------------------------------------------------
|
|
132
132
|
|
|
133
|
-
export
|
|
133
|
+
export interface PortalHostProps {
|
|
134
|
+
/** Host name that `Portal` targets through `hostName`. @default the shared default host */
|
|
135
|
+
name?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Web only. Element that receives the portal content instead of `document.body`.
|
|
138
|
+
*
|
|
139
|
+
* Use it to keep overlays inside an embedded surface, such as a device preview. Fixed
|
|
140
|
+
* overlays stay inside the container only when an ancestor of it creates a containing
|
|
141
|
+
* block for fixed descendants, for example through a `transform`. While `null`, such as
|
|
142
|
+
* before a callback ref attaches, the host renders nothing. Ignored on native.
|
|
143
|
+
*/
|
|
144
|
+
container?: Element | null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function PortalHost({ name = DEFAULT_PORTAL_HOST, container }: PortalHostProps) {
|
|
134
148
|
const registry = React.useContext(PortalRegistryContext) ?? fallbackPortalRegistry;
|
|
135
149
|
const hostId = React.useRef(Symbol()).current;
|
|
136
150
|
|
|
@@ -167,7 +181,8 @@ export function PortalHost({ name = DEFAULT_PORTAL_HOST }: { name?: string }) {
|
|
|
167
181
|
);
|
|
168
182
|
|
|
169
183
|
if (Platform.OS === 'web' && createPortalFn && typeof document !== 'undefined') {
|
|
170
|
-
|
|
184
|
+
if (container === null) return null;
|
|
185
|
+
return createPortalFn(content, container ?? document.body);
|
|
171
186
|
}
|
|
172
187
|
|
|
173
188
|
return content;
|
|
@@ -5,17 +5,22 @@ export type ProgressDataState = 'complete' | 'indeterminate' | 'loading';
|
|
|
5
5
|
|
|
6
6
|
export interface InterfaceProgressProps extends Omit<ViewProps, 'children'> {
|
|
7
7
|
/**
|
|
8
|
-
* Current value in the range [0, max]
|
|
9
|
-
*
|
|
8
|
+
* Current value in the range `[0, max]`, or `null` for indeterminate progress.
|
|
9
|
+
* Values below `0` clamp to `0`; values above `max` clamp to `max`.
|
|
10
|
+
* Non-finite values (such as `NaN`) are treated as `0`.
|
|
11
|
+
* @default 0
|
|
10
12
|
*/
|
|
11
13
|
readonly value?: number | null;
|
|
12
14
|
/**
|
|
13
|
-
* Maximum value
|
|
15
|
+
* Maximum value. The minimum is always `0`.
|
|
16
|
+
* A `max` of `0` or less is treated as complete.
|
|
14
17
|
* @default 100
|
|
15
18
|
*/
|
|
16
19
|
readonly max?: number;
|
|
17
20
|
/**
|
|
18
|
-
*
|
|
21
|
+
* Screen-reader text for determinate progress (`accessibilityValue.text`).
|
|
22
|
+
* Defaults to a rounded percentage of `max`, such as `"45%"`.
|
|
23
|
+
* No effect when `value` is `null`, or when `accessibilityValue` is set.
|
|
19
24
|
*/
|
|
20
25
|
readonly getValueLabel?: (value: number, max: number) => string;
|
|
21
26
|
readonly children?: ReactNode;
|
package/src/radio/types.ts
CHANGED
|
@@ -3,8 +3,10 @@ import type { PressableProps } from 'react-native';
|
|
|
3
3
|
export interface IRadioProps extends PressableProps {
|
|
4
4
|
/** Required — identifies this item's value within a Radio.Group. */
|
|
5
5
|
value: string;
|
|
6
|
+
/** Disables this item without disabling the rest of the group. */
|
|
6
7
|
isDisabled?: boolean;
|
|
7
8
|
isInvalid?: boolean;
|
|
9
|
+
/** Focusable, but the selection cannot be changed. */
|
|
8
10
|
isReadOnly?: boolean;
|
|
9
11
|
isHovered?: boolean;
|
|
10
12
|
isFocused?: boolean;
|
|
@@ -2,7 +2,7 @@ import React, { forwardRef, useCallback, useRef, type ComponentRef } from 'react
|
|
|
2
2
|
import { Platform, View } from 'react-native';
|
|
3
3
|
import { FadeIn, FadeOut } from 'react-native-reanimated';
|
|
4
4
|
import { composeEventHandlers, mergeRefs } from '@cdx-ui/utils';
|
|
5
|
-
import { OverlayContainer, useAnchoredOverlay, useDismissOverlay } from '../overlay';
|
|
5
|
+
import { OverlayContainer, useAnchoredOverlay, useDismissOverlay, useEscapeKey } from '../overlay';
|
|
6
6
|
import { Slot } from '../slot';
|
|
7
7
|
import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
|
|
8
8
|
import { useSelectContext } from './context';
|
|
@@ -76,6 +76,7 @@ export const SelectContent = forwardRef<ComponentRef<typeof View>, SelectContent
|
|
|
76
76
|
const mergedRef = mergeRefs(ref, focusOnMount as unknown as React.Ref<View>, floatingRef);
|
|
77
77
|
|
|
78
78
|
useDismissOverlay(overlayOpen, contentRef, [triggerRef], dismiss);
|
|
79
|
+
useEscapeKey(overlayOpen, dismiss);
|
|
79
80
|
|
|
80
81
|
const { handleKeyDown } = useListboxNavigation({
|
|
81
82
|
items,
|
package/src/switch/types.ts
CHANGED
|
@@ -7,11 +7,11 @@ export interface IInterfaceSwitchProps {
|
|
|
7
7
|
*/
|
|
8
8
|
onValueChange?: (value: boolean) => void;
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Alias for `value`. `value` takes precedence when both are set.
|
|
11
11
|
*/
|
|
12
12
|
isChecked?: boolean;
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Controlled on/off state. Takes precedence over `isChecked`.
|
|
15
15
|
*/
|
|
16
16
|
value?: boolean;
|
|
17
17
|
/**
|
|
@@ -19,11 +19,11 @@ export interface IInterfaceSwitchProps {
|
|
|
19
19
|
*/
|
|
20
20
|
isDisabled?: boolean;
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* Alias for `isDisabled`. Either prop disables the switch.
|
|
23
23
|
*/
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
26
|
+
* Initial state when uncontrolled. No effect when `value` or `isChecked` is set.
|
|
27
27
|
*/
|
|
28
28
|
defaultValue?: boolean;
|
|
29
29
|
/**
|
package/src/tabs/types.ts
CHANGED
|
@@ -13,7 +13,8 @@ export interface ITabsProps {
|
|
|
13
13
|
onValueChange?: (value: string) => void;
|
|
14
14
|
/**
|
|
15
15
|
* `automatic` (default) selects a tab as soon as it receives focus via arrow keys.
|
|
16
|
-
* `manual` only moves focus
|
|
16
|
+
* `manual` only moves focus — press Enter or Space to select. Web only; native
|
|
17
|
+
* selection follows press. Use `manual` when rendering the next panel is expensive.
|
|
17
18
|
*/
|
|
18
19
|
activationMode?: TabsActivationMode;
|
|
19
20
|
/**
|
|
@@ -21,6 +22,7 @@ export interface ITabsProps {
|
|
|
21
22
|
* `vertical` uses ArrowUp/ArrowDown, per the ARIA APG tabs pattern.
|
|
22
23
|
*/
|
|
23
24
|
orientation?: TabsOrientation;
|
|
25
|
+
/** Disables every tab. Disable a single tab with `disabled` on that `Tabs.Tab`. */
|
|
24
26
|
disabled?: boolean;
|
|
25
27
|
children?: ReactNode;
|
|
26
28
|
}
|
|
@@ -63,7 +65,10 @@ export interface ITabProps extends PressableProps {
|
|
|
63
65
|
export interface ITabPanelProps extends ViewProps {
|
|
64
66
|
/** Required — correlates this panel with its `Tabs.Tab` counterpart. */
|
|
65
67
|
value: string;
|
|
66
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Unmount when inactive instead of keeping the panel mounted and hidden.
|
|
70
|
+
* The default keeps it mounted so the tab's `aria-controls` target stays in the tree.
|
|
71
|
+
*/
|
|
67
72
|
unmountWhenInactive?: boolean;
|
|
68
73
|
children?: ReactNode;
|
|
69
74
|
}
|
|
@@ -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';
|