@cdx-ui/primitives 0.3.2 → 0.3.4
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/index.js +12 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/insight/InsightRoot.js +159 -0
- package/lib/commonjs/insight/InsightRoot.js.map +1 -0
- package/lib/commonjs/insight/context.js +11 -0
- package/lib/commonjs/insight/context.js.map +1 -0
- package/lib/commonjs/insight/index.js +26 -0
- package/lib/commonjs/insight/index.js.map +1 -0
- package/lib/commonjs/insight/types.js +6 -0
- package/lib/commonjs/insight/types.js.map +1 -0
- package/lib/commonjs/list-item/ListItemRoot.js +2 -18
- package/lib/commonjs/list-item/ListItemRoot.js.map +1 -1
- package/lib/commonjs/select/SelectContent.js +89 -45
- package/lib/commonjs/select/SelectContent.js.map +1 -1
- package/lib/commonjs/select/SelectDismissButton.js +14 -0
- package/lib/commonjs/select/SelectDismissButton.js.map +1 -0
- package/lib/commonjs/select/SelectDismissButton.web.js +28 -0
- package/lib/commonjs/select/SelectDismissButton.web.js.map +1 -0
- package/lib/commonjs/select/SelectEmpty.js +53 -0
- package/lib/commonjs/select/SelectEmpty.js.map +1 -0
- package/lib/commonjs/select/SelectItem.js +30 -9
- package/lib/commonjs/select/SelectItem.js.map +1 -1
- package/lib/commonjs/select/SelectListbox.js +47 -0
- package/lib/commonjs/select/SelectListbox.js.map +1 -0
- package/lib/commonjs/select/SelectRoot.js +110 -21
- package/lib/commonjs/select/SelectRoot.js.map +1 -1
- package/lib/commonjs/select/SelectSearch.js +81 -0
- package/lib/commonjs/select/SelectSearch.js.map +1 -0
- package/lib/commonjs/select/SelectSearchStatus.js +14 -0
- package/lib/commonjs/select/SelectSearchStatus.js.map +1 -0
- package/lib/commonjs/select/SelectSearchStatus.web.js +24 -0
- package/lib/commonjs/select/SelectSearchStatus.web.js.map +1 -0
- package/lib/commonjs/select/SelectSheetContent.js +67 -0
- package/lib/commonjs/select/SelectSheetContent.js.map +1 -0
- package/lib/commonjs/select/context.js +5 -1
- package/lib/commonjs/select/context.js.map +1 -1
- package/lib/commonjs/select/filter.js +16 -0
- package/lib/commonjs/select/filter.js.map +1 -0
- package/lib/commonjs/select/index.js +34 -0
- package/lib/commonjs/select/index.js.map +1 -1
- package/lib/commonjs/select/popupRole.js +27 -0
- package/lib/commonjs/select/popupRole.js.map +1 -0
- package/lib/commonjs/select/useContentFocus.js +10 -21
- package/lib/commonjs/select/useContentFocus.js.map +1 -1
- package/lib/commonjs/select/useListboxNavigation.js +21 -8
- package/lib/commonjs/select/useListboxNavigation.js.map +1 -1
- package/lib/commonjs/select/useQueryHighlight.js +50 -0
- package/lib/commonjs/select/useQueryHighlight.js.map +1 -0
- package/lib/commonjs/select/useSelectTrigger.js +4 -1
- package/lib/commonjs/select/useSelectTrigger.js.map +1 -1
- package/lib/commonjs/select/useSelectTrigger.web.js +4 -1
- package/lib/commonjs/select/useSelectTrigger.web.js.map +1 -1
- package/lib/commonjs/utils/splitContainerProps.js +26 -0
- package/lib/commonjs/utils/splitContainerProps.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/insight/InsightRoot.js +154 -0
- package/lib/module/insight/InsightRoot.js.map +1 -0
- package/lib/module/insight/context.js +5 -0
- package/lib/module/insight/context.js.map +1 -0
- package/lib/module/insight/index.js +5 -0
- package/lib/module/insight/index.js.map +1 -0
- package/lib/module/insight/types.js +4 -0
- package/lib/module/insight/types.js.map +1 -0
- package/lib/module/list-item/ListItemRoot.js +1 -17
- package/lib/module/list-item/ListItemRoot.js.map +1 -1
- package/lib/module/select/SelectContent.js +94 -48
- package/lib/module/select/SelectContent.js.map +1 -1
- package/lib/module/select/SelectDismissButton.js +10 -0
- package/lib/module/select/SelectDismissButton.js.map +1 -0
- package/lib/module/select/SelectDismissButton.web.js +24 -0
- package/lib/module/select/SelectDismissButton.web.js.map +1 -0
- package/lib/module/select/SelectEmpty.js +49 -0
- package/lib/module/select/SelectEmpty.js.map +1 -0
- package/lib/module/select/SelectItem.js +32 -11
- package/lib/module/select/SelectItem.js.map +1 -1
- package/lib/module/select/SelectListbox.js +43 -0
- package/lib/module/select/SelectListbox.js.map +1 -0
- package/lib/module/select/SelectRoot.js +111 -22
- package/lib/module/select/SelectRoot.js.map +1 -1
- package/lib/module/select/SelectSearch.js +77 -0
- package/lib/module/select/SelectSearch.js.map +1 -0
- package/lib/module/select/SelectSearchStatus.js +10 -0
- package/lib/module/select/SelectSearchStatus.js.map +1 -0
- package/lib/module/select/SelectSearchStatus.web.js +20 -0
- package/lib/module/select/SelectSearchStatus.web.js.map +1 -0
- package/lib/module/select/SelectSheetContent.js +63 -0
- package/lib/module/select/SelectSheetContent.js.map +1 -0
- package/lib/module/select/context.js +2 -0
- package/lib/module/select/context.js.map +1 -1
- package/lib/module/select/filter.js +11 -0
- package/lib/module/select/filter.js.map +1 -0
- package/lib/module/select/index.js +5 -1
- package/lib/module/select/index.js.map +1 -1
- package/lib/module/select/popupRole.js +23 -0
- package/lib/module/select/popupRole.js.map +1 -0
- package/lib/module/select/useContentFocus.js +11 -22
- package/lib/module/select/useContentFocus.js.map +1 -1
- package/lib/module/select/useListboxNavigation.js +21 -8
- package/lib/module/select/useListboxNavigation.js.map +1 -1
- package/lib/module/select/useQueryHighlight.js +46 -0
- package/lib/module/select/useQueryHighlight.js.map +1 -0
- package/lib/module/select/useSelectTrigger.js +4 -1
- package/lib/module/select/useSelectTrigger.js.map +1 -1
- package/lib/module/select/useSelectTrigger.web.js +4 -1
- package/lib/module/select/useSelectTrigger.web.js.map +1 -1
- package/lib/module/utils/splitContainerProps.js +22 -0
- package/lib/module/utils/splitContainerProps.js.map +1 -0
- package/lib/typescript/index.d.ts +1 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/insight/InsightRoot.d.ts +6 -0
- package/lib/typescript/insight/InsightRoot.d.ts.map +1 -0
- package/lib/typescript/insight/context.d.ts +6 -0
- package/lib/typescript/insight/context.d.ts.map +1 -0
- package/lib/typescript/insight/index.d.ts +4 -0
- package/lib/typescript/insight/index.d.ts.map +1 -0
- package/lib/typescript/insight/types.d.ts +44 -0
- package/lib/typescript/insight/types.d.ts.map +1 -0
- package/lib/typescript/list-item/ListItemRoot.d.ts.map +1 -1
- package/lib/typescript/select/SelectContent.d.ts.map +1 -1
- package/lib/typescript/select/SelectDismissButton.d.ts +10 -0
- package/lib/typescript/select/SelectDismissButton.d.ts.map +1 -0
- package/lib/typescript/select/SelectDismissButton.web.d.ts +9 -0
- package/lib/typescript/select/SelectDismissButton.web.d.ts.map +1 -0
- package/lib/typescript/select/SelectEmpty.d.ts +12 -0
- package/lib/typescript/select/SelectEmpty.d.ts.map +1 -0
- package/lib/typescript/select/SelectItem.d.ts.map +1 -1
- package/lib/typescript/select/SelectListbox.d.ts +12 -0
- package/lib/typescript/select/SelectListbox.d.ts.map +1 -0
- package/lib/typescript/select/SelectRoot.d.ts +2 -2
- package/lib/typescript/select/SelectRoot.d.ts.map +1 -1
- package/lib/typescript/select/SelectSearch.d.ts +18 -0
- package/lib/typescript/select/SelectSearch.d.ts.map +1 -0
- package/lib/typescript/select/SelectSearchStatus.d.ts +9 -0
- package/lib/typescript/select/SelectSearchStatus.d.ts.map +1 -0
- package/lib/typescript/select/SelectSearchStatus.web.d.ts +8 -0
- package/lib/typescript/select/SelectSearchStatus.web.d.ts.map +1 -0
- package/lib/typescript/select/SelectSheetContent.d.ts +14 -0
- package/lib/typescript/select/SelectSheetContent.d.ts.map +1 -0
- package/lib/typescript/select/context.d.ts +9 -0
- package/lib/typescript/select/context.d.ts.map +1 -1
- package/lib/typescript/select/filter.d.ts +4 -0
- package/lib/typescript/select/filter.d.ts.map +1 -0
- package/lib/typescript/select/index.d.ts +6 -2
- package/lib/typescript/select/index.d.ts.map +1 -1
- package/lib/typescript/select/popupRole.d.ts +18 -0
- package/lib/typescript/select/popupRole.d.ts.map +1 -0
- package/lib/typescript/select/types.d.ts +129 -8
- package/lib/typescript/select/types.d.ts.map +1 -1
- package/lib/typescript/select/useContentFocus.d.ts +4 -7
- package/lib/typescript/select/useContentFocus.d.ts.map +1 -1
- package/lib/typescript/select/useListboxNavigation.d.ts +16 -3
- package/lib/typescript/select/useListboxNavigation.d.ts.map +1 -1
- package/lib/typescript/select/useQueryHighlight.d.ts +21 -0
- package/lib/typescript/select/useQueryHighlight.d.ts.map +1 -0
- package/lib/typescript/select/useSelectTrigger.d.ts.map +1 -1
- package/lib/typescript/select/useSelectTrigger.web.d.ts.map +1 -1
- package/lib/typescript/utils/splitContainerProps.d.ts +9 -0
- package/lib/typescript/utils/splitContainerProps.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/insight/InsightRoot.tsx +162 -0
- package/src/insight/context.tsx +5 -0
- package/src/insight/index.ts +11 -0
- package/src/insight/types.ts +51 -0
- package/src/list-item/ListItemRoot.tsx +1 -14
- package/src/select/SelectContent.tsx +89 -54
- package/src/select/SelectDismissButton.tsx +14 -0
- package/src/select/SelectDismissButton.web.tsx +26 -0
- package/src/select/SelectEmpty.tsx +61 -0
- package/src/select/SelectItem.tsx +42 -10
- package/src/select/SelectListbox.tsx +43 -0
- package/src/select/SelectRoot.tsx +184 -12
- package/src/select/SelectSearch.tsx +98 -0
- package/src/select/SelectSearchStatus.tsx +11 -0
- package/src/select/SelectSearchStatus.web.tsx +18 -0
- package/src/select/SelectSheetContent.tsx +54 -0
- package/src/select/context.tsx +9 -0
- package/src/select/filter.ts +12 -0
- package/src/select/index.ts +14 -1
- package/src/select/popupRole.ts +26 -0
- package/src/select/types.ts +142 -9
- package/src/select/useContentFocus.ts +12 -31
- package/src/select/useListboxNavigation.ts +41 -9
- package/src/select/useQueryHighlight.ts +61 -0
- package/src/select/useSelectTrigger.ts +5 -1
- package/src/select/useSelectTrigger.web.ts +5 -1
- package/src/utils/splitContainerProps.ts +16 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { forwardRef, type ComponentRef } from 'react';
|
|
2
|
+
import { Platform, View } from 'react-native';
|
|
3
|
+
import { Slot } from '../slot';
|
|
4
|
+
import { useSelectContext } from './context';
|
|
5
|
+
import type { ISelectListboxProps } from './types';
|
|
6
|
+
|
|
7
|
+
export interface SelectListboxProps extends ISelectListboxProps {
|
|
8
|
+
asChild?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wraps the options. With `Select.Search` mounted it is the `role="listbox"` inside the dialog
|
|
14
|
+
* popup; without it the popup itself is the listbox and this renders a plain container.
|
|
15
|
+
*/
|
|
16
|
+
export const SelectListbox = forwardRef<ComponentRef<typeof View>, SelectListboxProps>(
|
|
17
|
+
({ asChild, children, ...props }, ref) => {
|
|
18
|
+
const { hasSearch, listboxId, labelId, triggerId, accessibilityLabel, type } =
|
|
19
|
+
useSelectContext();
|
|
20
|
+
|
|
21
|
+
const listboxProps = hasSearch
|
|
22
|
+
? {
|
|
23
|
+
role: 'listbox' as const,
|
|
24
|
+
id: listboxId,
|
|
25
|
+
'aria-labelledby': labelId ?? (accessibilityLabel ? undefined : triggerId),
|
|
26
|
+
'aria-label': labelId ? undefined : accessibilityLabel,
|
|
27
|
+
...(Platform.OS === 'web'
|
|
28
|
+
? { 'aria-multiselectable': type === 'multiple' || undefined }
|
|
29
|
+
: {}),
|
|
30
|
+
}
|
|
31
|
+
: {};
|
|
32
|
+
|
|
33
|
+
const Comp = asChild ? Slot : View;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Comp ref={ref} {...listboxProps} {...props}>
|
|
37
|
+
{children}
|
|
38
|
+
</Comp>
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
SelectListbox.displayName = 'SelectListbox';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
forwardRef,
|
|
3
3
|
useCallback,
|
|
4
|
+
useEffect,
|
|
4
5
|
useId,
|
|
5
6
|
useMemo,
|
|
6
7
|
useRef,
|
|
@@ -12,19 +13,52 @@ import { useControllableState, useFormControl } from '@cdx-ui/utils';
|
|
|
12
13
|
import { Slot } from '../slot';
|
|
13
14
|
import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
|
|
14
15
|
import { SelectProvider } from './context';
|
|
15
|
-
import
|
|
16
|
+
import { defaultSelectFilter } from './filter';
|
|
17
|
+
import type { ISelectProps, SelectFilter, SelectItemData, SelectType } from './types';
|
|
16
18
|
|
|
17
|
-
export
|
|
19
|
+
export type SelectRootProps = ISelectProps &
|
|
20
|
+
Omit<ViewProps, 'children'> & {
|
|
21
|
+
asChild?: boolean;
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Internal widening of the public discriminated union: the runtime accepts either value
|
|
27
|
+
* shape and reads `type` to decide branch behavior. The public API stays narrow.
|
|
28
|
+
*/
|
|
29
|
+
type WidenedSelectRootProps = Omit<ViewProps, 'children'> & {
|
|
18
30
|
asChild?: boolean;
|
|
19
31
|
className?: string;
|
|
20
|
-
|
|
32
|
+
children?: ISelectProps['children'];
|
|
33
|
+
items: SelectItemData[];
|
|
34
|
+
type?: SelectType;
|
|
35
|
+
value?: string | string[];
|
|
36
|
+
defaultValue?: string | string[];
|
|
37
|
+
onValueChange?: (value: string | string[]) => void;
|
|
38
|
+
isOpen?: boolean;
|
|
39
|
+
defaultOpen?: boolean;
|
|
40
|
+
onOpenChange?: (open: boolean) => void;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
isRequired?: boolean;
|
|
43
|
+
isInvalid?: boolean;
|
|
44
|
+
isReadOnly?: boolean;
|
|
45
|
+
native?: boolean;
|
|
46
|
+
accessibilityLabel?: string;
|
|
47
|
+
inputValue?: string;
|
|
48
|
+
defaultInputValue?: string;
|
|
49
|
+
onInputChange?: (inputValue: string) => void;
|
|
50
|
+
filter?: SelectFilter;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const EMPTY_VALUES: string[] = [];
|
|
21
54
|
|
|
22
55
|
export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>(
|
|
23
|
-
(
|
|
24
|
-
{
|
|
56
|
+
(rootProps, ref) => {
|
|
57
|
+
const {
|
|
25
58
|
asChild,
|
|
26
59
|
children,
|
|
27
60
|
items,
|
|
61
|
+
type = 'single',
|
|
28
62
|
value: valueProp,
|
|
29
63
|
defaultValue,
|
|
30
64
|
onValueChange,
|
|
@@ -37,10 +71,13 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
|
|
|
37
71
|
isReadOnly,
|
|
38
72
|
native = false,
|
|
39
73
|
accessibilityLabel,
|
|
74
|
+
inputValue: inputValueProp,
|
|
75
|
+
defaultInputValue = '',
|
|
76
|
+
onInputChange,
|
|
77
|
+
filter = defaultSelectFilter,
|
|
40
78
|
...props
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
) => {
|
|
79
|
+
} = rootProps as WidenedSelectRootProps;
|
|
80
|
+
|
|
44
81
|
const formControlProps = useFormControl({
|
|
45
82
|
isDisabled: disabled,
|
|
46
83
|
isRequired,
|
|
@@ -53,9 +90,11 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
|
|
|
53
90
|
const isEffectivelyInvalid = !!formControlProps['aria-invalid'];
|
|
54
91
|
const isEffectivelyReadOnly = !!formControlProps.readOnly;
|
|
55
92
|
|
|
56
|
-
const
|
|
93
|
+
const isMultiple = type === 'multiple';
|
|
94
|
+
|
|
95
|
+
const [value, setValue] = useControllableState<string | string[] | undefined>({
|
|
57
96
|
prop: valueProp,
|
|
58
|
-
defaultProp: defaultValue,
|
|
97
|
+
defaultProp: defaultValue ?? (isMultiple ? EMPTY_VALUES : undefined),
|
|
59
98
|
onChange: (newValue) => {
|
|
60
99
|
if (newValue !== undefined) {
|
|
61
100
|
onValueChange?.(newValue);
|
|
@@ -63,6 +102,18 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
|
|
|
63
102
|
},
|
|
64
103
|
});
|
|
65
104
|
|
|
105
|
+
const selectedValues = useMemo<string[]>(() => {
|
|
106
|
+
if (isMultiple) {
|
|
107
|
+
return Array.isArray(value) ? value : EMPTY_VALUES;
|
|
108
|
+
}
|
|
109
|
+
return typeof value === 'string' ? [value] : EMPTY_VALUES;
|
|
110
|
+
}, [isMultiple, value]);
|
|
111
|
+
|
|
112
|
+
const isSelected = useCallback(
|
|
113
|
+
(itemValue: string) => selectedValues.includes(itemValue),
|
|
114
|
+
[selectedValues],
|
|
115
|
+
);
|
|
116
|
+
|
|
66
117
|
const [open, setOpenState] = useControllableState<boolean>({
|
|
67
118
|
prop: openProp,
|
|
68
119
|
defaultProp: defaultOpen,
|
|
@@ -81,20 +132,115 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
|
|
|
81
132
|
|
|
82
133
|
const [activeValue, setActiveValue] = useState<string | undefined>(undefined);
|
|
83
134
|
|
|
135
|
+
const [inputValueState, setInputValueState] = useControllableState<string>({
|
|
136
|
+
prop: inputValueProp,
|
|
137
|
+
defaultProp: defaultInputValue,
|
|
138
|
+
onChange: onInputChange,
|
|
139
|
+
});
|
|
140
|
+
const inputValue = inputValueState ?? '';
|
|
141
|
+
const setInputValue = useCallback(
|
|
142
|
+
(next: string) => setInputValueState(next),
|
|
143
|
+
[setInputValueState],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const wasOpenRef = useRef(!!open);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const wasOpen = wasOpenRef.current;
|
|
149
|
+
wasOpenRef.current = !!open;
|
|
150
|
+
if (wasOpen && !open) {
|
|
151
|
+
setInputValueState('');
|
|
152
|
+
}
|
|
153
|
+
}, [open, setInputValueState]);
|
|
154
|
+
|
|
155
|
+
const visibleItems = useMemo(
|
|
156
|
+
() => (inputValue === '' ? items : items.filter((item) => filter(item.label, inputValue))),
|
|
157
|
+
[items, inputValue, filter],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const visibleValues = useMemo(
|
|
161
|
+
() => new Set(visibleItems.map((item) => item.value)),
|
|
162
|
+
[visibleItems],
|
|
163
|
+
);
|
|
164
|
+
const isItemVisible = useCallback(
|
|
165
|
+
(itemValue: string) => inputValue === '' || visibleValues.has(itemValue),
|
|
166
|
+
[inputValue, visibleValues],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const [searchCount, setSearchCount] = useState(0);
|
|
170
|
+
const registerSearch = useCallback(() => {
|
|
171
|
+
setSearchCount((count) => count + 1);
|
|
172
|
+
return () => setSearchCount((count) => count - 1);
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
const [emptyText, setEmptyText] = useState<string | undefined>(undefined);
|
|
176
|
+
|
|
177
|
+
const toggleValue = useCallback(
|
|
178
|
+
(itemValue: string) => {
|
|
179
|
+
setValue((prev) => {
|
|
180
|
+
const current = Array.isArray(prev) ? prev : EMPTY_VALUES;
|
|
181
|
+
return current.includes(itemValue)
|
|
182
|
+
? current.filter((v) => v !== itemValue)
|
|
183
|
+
: [...current, itemValue];
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
[setValue],
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const selectValueAndClose = useCallback(
|
|
190
|
+
(itemValue: string) => {
|
|
191
|
+
setValue(itemValue);
|
|
192
|
+
setOpen(false);
|
|
193
|
+
},
|
|
194
|
+
[setValue, setOpen],
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const commitValue = isMultiple ? toggleValue : selectValueAndClose;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* `Select.Item disabled` lives on the rendered child, not in `items`, so mounted items
|
|
201
|
+
* register here for keyboard navigation. A ref keeps registration from re-rendering the root.
|
|
202
|
+
*/
|
|
203
|
+
const disabledItemsRef = useRef<Set<string>>(new Set());
|
|
204
|
+
|
|
205
|
+
const registerDisabledItem = useCallback((itemValue: string) => {
|
|
206
|
+
disabledItemsRef.current.add(itemValue);
|
|
207
|
+
return () => {
|
|
208
|
+
disabledItemsRef.current.delete(itemValue);
|
|
209
|
+
};
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
212
|
+
const isItemDisabled = useCallback(
|
|
213
|
+
(itemValue: string) =>
|
|
214
|
+
isEffectivelyDisabled ||
|
|
215
|
+
disabledItemsRef.current.has(itemValue) ||
|
|
216
|
+
items.some((item) => item.value === itemValue && !!item.disabled),
|
|
217
|
+
[isEffectivelyDisabled, items],
|
|
218
|
+
);
|
|
219
|
+
|
|
84
220
|
const triggerRef = useRef<ComponentRef<typeof View> | null>(null);
|
|
85
221
|
const reactId = useId();
|
|
86
222
|
/** Align with `useFormControl` default id (`${field.id}-input`) when inside `Form.Field`. */
|
|
87
223
|
const triggerId = formControlProps.id ?? `select-trigger-${reactId}`;
|
|
88
224
|
const contentId = `select-content-${reactId}`;
|
|
225
|
+
const listboxId = `${contentId}-listbox`;
|
|
89
226
|
const ariaDescribedBy = formControlProps['aria-describedby'];
|
|
227
|
+
const labelId = formControlProps['aria-labelledby'];
|
|
228
|
+
const hasSearch = searchCount > 0;
|
|
90
229
|
|
|
91
230
|
const contextValue = useMemo(
|
|
92
231
|
() => ({
|
|
93
232
|
open: open ?? false,
|
|
94
233
|
items,
|
|
95
234
|
setOpen,
|
|
235
|
+
type,
|
|
96
236
|
value,
|
|
97
|
-
|
|
237
|
+
selectedValues,
|
|
238
|
+
isSelected,
|
|
239
|
+
toggleValue,
|
|
240
|
+
selectValueAndClose,
|
|
241
|
+
commitValue,
|
|
242
|
+
isItemDisabled,
|
|
243
|
+
registerDisabledItem,
|
|
98
244
|
disabled: isEffectivelyDisabled,
|
|
99
245
|
required: isEffectivelyRequired,
|
|
100
246
|
invalid: isEffectivelyInvalid,
|
|
@@ -103,6 +249,16 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
|
|
|
103
249
|
triggerRef,
|
|
104
250
|
contentId,
|
|
105
251
|
triggerId,
|
|
252
|
+
inputValue,
|
|
253
|
+
setInputValue,
|
|
254
|
+
visibleItems,
|
|
255
|
+
isItemVisible,
|
|
256
|
+
hasSearch,
|
|
257
|
+
registerSearch,
|
|
258
|
+
emptyText,
|
|
259
|
+
setEmptyText,
|
|
260
|
+
listboxId,
|
|
261
|
+
labelId,
|
|
106
262
|
activeValue,
|
|
107
263
|
setActiveValue,
|
|
108
264
|
accessibilityLabel,
|
|
@@ -112,8 +268,15 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
|
|
|
112
268
|
open,
|
|
113
269
|
items,
|
|
114
270
|
setOpen,
|
|
271
|
+
type,
|
|
115
272
|
value,
|
|
116
|
-
|
|
273
|
+
selectedValues,
|
|
274
|
+
isSelected,
|
|
275
|
+
toggleValue,
|
|
276
|
+
selectValueAndClose,
|
|
277
|
+
commitValue,
|
|
278
|
+
isItemDisabled,
|
|
279
|
+
registerDisabledItem,
|
|
117
280
|
isEffectivelyDisabled,
|
|
118
281
|
isEffectivelyRequired,
|
|
119
282
|
isEffectivelyInvalid,
|
|
@@ -121,6 +284,15 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
|
|
|
121
284
|
native,
|
|
122
285
|
contentId,
|
|
123
286
|
triggerId,
|
|
287
|
+
inputValue,
|
|
288
|
+
setInputValue,
|
|
289
|
+
visibleItems,
|
|
290
|
+
isItemVisible,
|
|
291
|
+
hasSearch,
|
|
292
|
+
registerSearch,
|
|
293
|
+
emptyText,
|
|
294
|
+
listboxId,
|
|
295
|
+
labelId,
|
|
124
296
|
activeValue,
|
|
125
297
|
accessibilityLabel,
|
|
126
298
|
ariaDescribedBy,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { forwardRef, useLayoutEffect, type ReactNode } from 'react';
|
|
2
|
+
import { Platform, TextInput } from 'react-native';
|
|
3
|
+
import { composeEventHandlers } from '@cdx-ui/utils';
|
|
4
|
+
import { Slot } from '../slot';
|
|
5
|
+
import { useSelectContentContext, useSelectContext } from './context';
|
|
6
|
+
import { SelectSearchStatus } from './SelectSearchStatus';
|
|
7
|
+
import type { ISelectSearchProps } from './types';
|
|
8
|
+
|
|
9
|
+
export interface SelectSearchProps extends ISelectSearchProps {
|
|
10
|
+
/** Merge the search wiring onto the single child text input instead of rendering one. */
|
|
11
|
+
asChild?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const defaultResultCountLabel = (count: number) =>
|
|
17
|
+
count === 1 ? '1 result' : `${String(count)} results`;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Search input for `Select.Content`. Its query filters the options through the root `filter`.
|
|
21
|
+
* Mounting it turns the popup into a dialog that contains this searchbox and the listbox.
|
|
22
|
+
*
|
|
23
|
+
* Web uses virtual focus: DOM focus stays here, arrow keys move the highlight, and
|
|
24
|
+
* `aria-activedescendant` points at it. Native renders the input without virtual focus.
|
|
25
|
+
*/
|
|
26
|
+
export const SelectSearch = forwardRef<TextInput, SelectSearchProps>(
|
|
27
|
+
(
|
|
28
|
+
{
|
|
29
|
+
asChild,
|
|
30
|
+
children,
|
|
31
|
+
accessibilityLabel,
|
|
32
|
+
getResultCountLabel = defaultResultCountLabel,
|
|
33
|
+
onKeyPress,
|
|
34
|
+
...props
|
|
35
|
+
},
|
|
36
|
+
ref,
|
|
37
|
+
) => {
|
|
38
|
+
const {
|
|
39
|
+
inputValue,
|
|
40
|
+
setInputValue,
|
|
41
|
+
registerSearch,
|
|
42
|
+
listboxId,
|
|
43
|
+
contentId,
|
|
44
|
+
activeValue,
|
|
45
|
+
isItemVisible,
|
|
46
|
+
visibleItems,
|
|
47
|
+
emptyText,
|
|
48
|
+
} = useSelectContext();
|
|
49
|
+
const { handleKeyDown } = useSelectContentContext();
|
|
50
|
+
|
|
51
|
+
useLayoutEffect(() => registerSearch(), [registerSearch]);
|
|
52
|
+
|
|
53
|
+
const activeDescendant =
|
|
54
|
+
activeValue !== undefined && isItemVisible(activeValue)
|
|
55
|
+
? `${contentId}-option-${activeValue}`
|
|
56
|
+
: undefined;
|
|
57
|
+
|
|
58
|
+
// RNW's TextInput stops keydown propagation, so the listbox keys are handled here.
|
|
59
|
+
const platformProps =
|
|
60
|
+
Platform.OS === 'web'
|
|
61
|
+
? {
|
|
62
|
+
'aria-controls': listboxId,
|
|
63
|
+
'aria-autocomplete': 'list' as const,
|
|
64
|
+
'aria-activedescendant': activeDescendant,
|
|
65
|
+
onKeyPress: composeEventHandlers(onKeyPress, handleKeyDown),
|
|
66
|
+
}
|
|
67
|
+
: { onKeyPress };
|
|
68
|
+
|
|
69
|
+
const count = visibleItems.length;
|
|
70
|
+
const statusMessage = count === 0 && emptyText ? emptyText : getResultCountLabel(count);
|
|
71
|
+
|
|
72
|
+
const Comp = asChild ? Slot : TextInput;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<Comp
|
|
77
|
+
ref={ref}
|
|
78
|
+
role="searchbox"
|
|
79
|
+
value={inputValue}
|
|
80
|
+
onChangeText={setInputValue}
|
|
81
|
+
autoCapitalize="none"
|
|
82
|
+
autoComplete="off"
|
|
83
|
+
autoCorrect={false}
|
|
84
|
+
spellCheck={false}
|
|
85
|
+
accessibilityLabel={accessibilityLabel}
|
|
86
|
+
aria-label={accessibilityLabel}
|
|
87
|
+
{...platformProps}
|
|
88
|
+
{...props}
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
</Comp>
|
|
92
|
+
<SelectSearchStatus message={statusMessage} />
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
SelectSearch.displayName = 'SelectSearch';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SelectSearchStatusProps {
|
|
2
|
+
message: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Web-only. On native, screen readers move through the rows directly, in the anchored popover
|
|
7
|
+
* or in `Select.BottomSheet`, instead of following an input-driven live region.
|
|
8
|
+
*/
|
|
9
|
+
export function SelectSearchStatus(_props: SelectSearchStatusProps) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { VisuallyHidden } from '@react-aria/visually-hidden';
|
|
2
|
+
import type { SelectSearchStatusProps } from './SelectSearchStatus';
|
|
3
|
+
|
|
4
|
+
export type { SelectSearchStatusProps };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Polite live region for the search results. It stays mounted while the search is, so each
|
|
8
|
+
* change to the count (or to the empty-state text) is announced.
|
|
9
|
+
*/
|
|
10
|
+
export function SelectSearchStatus({ message }: SelectSearchStatusProps) {
|
|
11
|
+
return (
|
|
12
|
+
<VisuallyHidden>
|
|
13
|
+
<div role="status" aria-live="polite">
|
|
14
|
+
{message}
|
|
15
|
+
</div>
|
|
16
|
+
</VisuallyHidden>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { forwardRef, useCallback, type ComponentRef } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { composeEventHandlers } from '@cdx-ui/utils';
|
|
4
|
+
import { Slot } from '../slot';
|
|
5
|
+
import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
|
|
6
|
+
import { SelectContentProvider, useSelectContext, type SelectContentContextValue } from './context';
|
|
7
|
+
import { getPopupRoleProps } from './popupRole';
|
|
8
|
+
import type { ISelectSheetContentProps } from './types';
|
|
9
|
+
|
|
10
|
+
export interface SelectSheetContentProps extends ISelectSheetContentProps {
|
|
11
|
+
asChild?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Native rows take screen-reader focus directly, so the sheet has no listbox keyboard model
|
|
17
|
+
* for `Select.Search` to drive.
|
|
18
|
+
*/
|
|
19
|
+
const SHEET_CONTENT_CONTEXT: SelectContentContextValue = { handleKeyDown: () => undefined };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Body of a native sheet surface that presents the options in place of `SelectContent`. The
|
|
23
|
+
* styled layer owns presentation (open, swipe, backdrop, back) and renders this inside it.
|
|
24
|
+
* It carries the popup role and name, and closes on the VoiceOver escape gesture without
|
|
25
|
+
* changing the value.
|
|
26
|
+
*/
|
|
27
|
+
export const SelectSheetContent = forwardRef<ComponentRef<typeof View>, SelectSheetContentProps>(
|
|
28
|
+
({ asChild, children, onAccessibilityEscape, ...props }, ref) => {
|
|
29
|
+
const { setOpen, hasSearch, labelId, accessibilityLabel, triggerId, contentId } =
|
|
30
|
+
useSelectContext();
|
|
31
|
+
|
|
32
|
+
const dismiss = useCallback(() => setOpen(false), [setOpen]);
|
|
33
|
+
const roleProps = getPopupRoleProps({ hasSearch, labelId, accessibilityLabel, triggerId });
|
|
34
|
+
|
|
35
|
+
const Comp = asChild ? Slot : View;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<SelectContentProvider value={SHEET_CONTENT_CONTEXT}>
|
|
39
|
+
<Comp
|
|
40
|
+
ref={ref}
|
|
41
|
+
id={contentId}
|
|
42
|
+
{...roleProps}
|
|
43
|
+
{...props}
|
|
44
|
+
{...mergeDataAttributes(props, dataAttributes({ state: 'open' }))}
|
|
45
|
+
onAccessibilityEscape={composeEventHandlers(onAccessibilityEscape, dismiss)}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
</Comp>
|
|
49
|
+
</SelectContentProvider>
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
SelectSheetContent.displayName = 'SelectSheetContent';
|
package/src/select/context.tsx
CHANGED
|
@@ -10,3 +10,12 @@ export const [SelectProvider, useSelectContext] =
|
|
|
10
10
|
*/
|
|
11
11
|
export const [SelectItemProvider, useSelectItemContext] =
|
|
12
12
|
createContext<SelectItemInteractionState>('SelectItemContext');
|
|
13
|
+
|
|
14
|
+
export interface SelectContentContextValue {
|
|
15
|
+
/** Listbox keyboard handling, wired to the `Select.Search` input on web. */
|
|
16
|
+
handleKeyDown: (e: any) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Internal: lets `Select.Search` drive the listbox from its input. */
|
|
20
|
+
export const [SelectContentProvider, useSelectContentContext] =
|
|
21
|
+
createContext<SelectContentContextValue>('SelectContentContext');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SelectFilter } from './types';
|
|
2
|
+
|
|
3
|
+
const COMBINING_MARKS = /[̀-ͯ]/g;
|
|
4
|
+
|
|
5
|
+
function normalize(text: string): string {
|
|
6
|
+
const decomposed = typeof text.normalize === 'function' ? text.normalize('NFD') : text;
|
|
7
|
+
return decomposed.replace(COMBINING_MARKS, '').toLocaleLowerCase();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Case- and accent-insensitive `contains`: "cafe" matches "Café". */
|
|
11
|
+
export const defaultSelectFilter: SelectFilter = (textValue, inputValue) =>
|
|
12
|
+
normalize(textValue).includes(normalize(inputValue));
|
package/src/select/index.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
export type {
|
|
2
2
|
ISelectContentProps,
|
|
3
|
+
ISelectEmptyProps,
|
|
3
4
|
ISelectItemLabelProps,
|
|
4
5
|
ISelectItemProps,
|
|
6
|
+
ISelectListboxProps,
|
|
7
|
+
ISelectMultipleProps,
|
|
5
8
|
ISelectProps,
|
|
9
|
+
ISelectSearchProps,
|
|
10
|
+
ISelectSheetContentProps,
|
|
11
|
+
ISelectSingleProps,
|
|
6
12
|
ISelectTriggerProps,
|
|
7
13
|
ISelectValueProps,
|
|
8
14
|
SelectContextValue,
|
|
15
|
+
SelectFilter,
|
|
9
16
|
SelectItemData,
|
|
10
17
|
SelectItemInteractionState,
|
|
18
|
+
SelectType,
|
|
19
|
+
SelectValueRenderProps,
|
|
11
20
|
} from './types';
|
|
12
21
|
|
|
13
|
-
export { useSelectContext, useSelectItemContext } from './context';
|
|
22
|
+
export { SelectProvider, useSelectContext, useSelectItemContext } from './context';
|
|
14
23
|
export { SelectRoot, type SelectRootProps } from './SelectRoot';
|
|
15
24
|
export { SelectTrigger, type SelectTriggerProps } from './SelectTrigger';
|
|
16
25
|
export { SelectContent, type SelectContentProps } from './SelectContent';
|
|
26
|
+
export { SelectSheetContent, type SelectSheetContentProps } from './SelectSheetContent';
|
|
27
|
+
export { SelectSearch, type SelectSearchProps } from './SelectSearch';
|
|
28
|
+
export { SelectListbox, type SelectListboxProps } from './SelectListbox';
|
|
29
|
+
export { SelectEmpty, type SelectEmptyProps } from './SelectEmpty';
|
|
17
30
|
export { SelectItem, type SelectItemProps } from './SelectItem';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SelectContextValue } from './types';
|
|
2
|
+
|
|
3
|
+
type PopupRoleSource = Pick<
|
|
4
|
+
SelectContextValue,
|
|
5
|
+
'hasSearch' | 'labelId' | 'accessibilityLabel' | 'triggerId'
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Role and name of the popup surface. With `Select.Search` the popup is a dialog holding a
|
|
10
|
+
* searchbox and a listbox (a listbox may only own options), named by the field label, then
|
|
11
|
+
* `accessibilityLabel`. Without it the popup is the listbox.
|
|
12
|
+
*/
|
|
13
|
+
export function getPopupRoleProps({
|
|
14
|
+
hasSearch,
|
|
15
|
+
labelId,
|
|
16
|
+
accessibilityLabel,
|
|
17
|
+
triggerId,
|
|
18
|
+
}: PopupRoleSource) {
|
|
19
|
+
return hasSearch
|
|
20
|
+
? {
|
|
21
|
+
role: 'dialog' as const,
|
|
22
|
+
'aria-labelledby': labelId ?? (accessibilityLabel ? undefined : triggerId),
|
|
23
|
+
'aria-label': labelId ? undefined : accessibilityLabel,
|
|
24
|
+
}
|
|
25
|
+
: { role: 'listbox' as const, 'aria-labelledby': triggerId };
|
|
26
|
+
}
|