@lotics/ui 1.13.1 → 1.13.3
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/package.json +2 -1
- package/src/combobox.tsx +177 -0
- package/src/picker.tsx +13 -1
- package/src/picker_menu.tsx +45 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"./tooltip": "./src/tooltip.tsx",
|
|
44
44
|
"./button": "./src/button.tsx",
|
|
45
45
|
"./checkbox": "./src/checkbox.tsx",
|
|
46
|
+
"./combobox": "./src/combobox.tsx",
|
|
46
47
|
"./portal": "./src/portal.tsx",
|
|
47
48
|
"./popover_nav": "./src/popover_nav.tsx",
|
|
48
49
|
"./popover": "./src/popover.tsx",
|
package/src/combobox.tsx
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Pressable, StyleProp, ViewStyle, StyleSheet } from "react-native";
|
|
2
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { Icon } from "./icon";
|
|
6
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
7
|
+
import { PickerMenu } from "./picker_menu";
|
|
8
|
+
import { PickerOption } from "./picker";
|
|
9
|
+
|
|
10
|
+
export interface ComboboxProps<T extends string = string> {
|
|
11
|
+
/** The selected option. It carries its own label, so the trigger still shows
|
|
12
|
+
* the selection even when that option is no longer in the current (async)
|
|
13
|
+
* result set. */
|
|
14
|
+
value: PickerOption<T> | null;
|
|
15
|
+
onValueChange: (option: PickerOption<T>) => void;
|
|
16
|
+
/** Result options to show in the list. In server-search mode the consumer
|
|
17
|
+
* refreshes these in response to `onSearchChange`. */
|
|
18
|
+
options: PickerOption<T>[];
|
|
19
|
+
/** Called (debounced) when the search text changes. Provide it to drive
|
|
20
|
+
* server-side search — the consumer re-queries and updates `options`. Omit to
|
|
21
|
+
* filter the given `options` locally (static list). */
|
|
22
|
+
onSearchChange?: (query: string) => void;
|
|
23
|
+
/** Show a loading indicator while async results are in flight. */
|
|
24
|
+
loading?: boolean;
|
|
25
|
+
/** Debounce applied to `onSearchChange`, in ms. Default 200. */
|
|
26
|
+
searchDebounceMs?: number;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
searchPlaceholder?: string;
|
|
29
|
+
/** Shown when there are no results and not loading. Default: "No results". */
|
|
30
|
+
emptyText?: string;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
autoFocus?: boolean;
|
|
33
|
+
testID?: string;
|
|
34
|
+
style?: StyleProp<ViewStyle>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Debounce a possibly-undefined callback; cleared on unmount. */
|
|
38
|
+
function useDebouncedCallback<A extends unknown[]>(
|
|
39
|
+
fn: ((...args: A) => void) | undefined,
|
|
40
|
+
ms: number,
|
|
41
|
+
) {
|
|
42
|
+
const fnRef = useRef(fn);
|
|
43
|
+
fnRef.current = fn;
|
|
44
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
45
|
+
useEffect(
|
|
46
|
+
() => () => {
|
|
47
|
+
if (timer.current) clearTimeout(timer.current);
|
|
48
|
+
},
|
|
49
|
+
[],
|
|
50
|
+
);
|
|
51
|
+
return useCallback(
|
|
52
|
+
(...args: A) => {
|
|
53
|
+
if (timer.current) clearTimeout(timer.current);
|
|
54
|
+
timer.current = setTimeout(() => fnRef.current?.(...args), ms);
|
|
55
|
+
},
|
|
56
|
+
[ms],
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Async-search select — a keyboard-accessible combobox. The trigger opens a
|
|
62
|
+
* Popover with a search box and a result list (arrows to move, Enter to pick,
|
|
63
|
+
* Esc to close; the trigger is tab-focusable and opens on Enter/Space).
|
|
64
|
+
*
|
|
65
|
+
* Pass `onSearchChange` to drive search **server-side** (the consumer re-queries
|
|
66
|
+
* and refreshes `options`) — the right pattern for large/growing datasets where
|
|
67
|
+
* prefetching the whole table to the client is wrong. Omit it to filter the
|
|
68
|
+
* given `options` locally, so the same component also serves static lists.
|
|
69
|
+
*/
|
|
70
|
+
export function Combobox<T extends string>(props: ComboboxProps<T>) {
|
|
71
|
+
const {
|
|
72
|
+
value,
|
|
73
|
+
onValueChange,
|
|
74
|
+
options,
|
|
75
|
+
onSearchChange,
|
|
76
|
+
loading,
|
|
77
|
+
searchDebounceMs = 200,
|
|
78
|
+
placeholder,
|
|
79
|
+
searchPlaceholder,
|
|
80
|
+
emptyText,
|
|
81
|
+
disabled = false,
|
|
82
|
+
autoFocus = false,
|
|
83
|
+
testID,
|
|
84
|
+
style,
|
|
85
|
+
} = props;
|
|
86
|
+
|
|
87
|
+
const [open, setOpen] = useState(autoFocus);
|
|
88
|
+
const debouncedSearch = useDebouncedCallback(onSearchChange, searchDebounceMs);
|
|
89
|
+
|
|
90
|
+
const handleToggle = useCallback(() => {
|
|
91
|
+
if (!disabled) setOpen((o) => !o);
|
|
92
|
+
}, [disabled]);
|
|
93
|
+
|
|
94
|
+
const handleSelect = useCallback(
|
|
95
|
+
(v: T) => {
|
|
96
|
+
const opt = options.find((o) => o.value === v);
|
|
97
|
+
if (opt) onValueChange(opt);
|
|
98
|
+
setOpen(false);
|
|
99
|
+
},
|
|
100
|
+
[options, onValueChange],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Popover
|
|
105
|
+
open={open && !disabled}
|
|
106
|
+
onOpenChange={setOpen}
|
|
107
|
+
side="bottom"
|
|
108
|
+
align="start"
|
|
109
|
+
inheritTriggerWidth={true}
|
|
110
|
+
>
|
|
111
|
+
<PopoverTrigger>
|
|
112
|
+
<Pressable
|
|
113
|
+
testID={testID}
|
|
114
|
+
// A bare Pressable renders as an unfocusable <div> on web; `button`
|
|
115
|
+
// puts it in the tab order and maps Enter/Space to onPress, while
|
|
116
|
+
// `expanded` announces open/closed.
|
|
117
|
+
accessibilityRole="button"
|
|
118
|
+
accessibilityState={{ expanded: open, disabled }}
|
|
119
|
+
style={[styles.trigger, open && styles.opened, disabled && styles.disabled, style]}
|
|
120
|
+
onPress={!disabled ? handleToggle : undefined}
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
>
|
|
123
|
+
{value ? (
|
|
124
|
+
<Text userSelect="none" numberOfLines={1}>
|
|
125
|
+
{value.label ?? value.value}
|
|
126
|
+
</Text>
|
|
127
|
+
) : (
|
|
128
|
+
<Text color="zinc-500" userSelect="none" numberOfLines={1}>
|
|
129
|
+
{placeholder}
|
|
130
|
+
</Text>
|
|
131
|
+
)}
|
|
132
|
+
<Icon name="chevron-down" size={18} color={colors.zinc["500"]} />
|
|
133
|
+
</Pressable>
|
|
134
|
+
</PopoverTrigger>
|
|
135
|
+
<PopoverContent>
|
|
136
|
+
<PickerMenu
|
|
137
|
+
options={options}
|
|
138
|
+
value={value?.value ?? null}
|
|
139
|
+
onValueChange={handleSelect}
|
|
140
|
+
onRequestClose={() => setOpen(false)}
|
|
141
|
+
enableSearch
|
|
142
|
+
onSearchChange={debouncedSearch}
|
|
143
|
+
// Local filtering is wrong when the consumer drives search server-side.
|
|
144
|
+
serverFiltered={onSearchChange !== undefined}
|
|
145
|
+
loading={loading}
|
|
146
|
+
emptyText={emptyText}
|
|
147
|
+
searchPlaceholder={searchPlaceholder}
|
|
148
|
+
/>
|
|
149
|
+
</PopoverContent>
|
|
150
|
+
</Popover>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const styles = StyleSheet.create({
|
|
155
|
+
trigger: {
|
|
156
|
+
flexDirection: "row",
|
|
157
|
+
alignItems: "center",
|
|
158
|
+
justifyContent: "space-between",
|
|
159
|
+
gap: 6,
|
|
160
|
+
paddingHorizontal: 6,
|
|
161
|
+
borderWidth: 1,
|
|
162
|
+
borderColor: colors.border,
|
|
163
|
+
borderRadius: 8,
|
|
164
|
+
height: 40,
|
|
165
|
+
cursor: "pointer",
|
|
166
|
+
},
|
|
167
|
+
opened: {
|
|
168
|
+
outlineColor: colors.black,
|
|
169
|
+
outlineWidth: 2,
|
|
170
|
+
outlineStyle: "solid",
|
|
171
|
+
outlineOffset: -2,
|
|
172
|
+
},
|
|
173
|
+
disabled: {
|
|
174
|
+
opacity: 0.5,
|
|
175
|
+
cursor: "auto",
|
|
176
|
+
},
|
|
177
|
+
});
|
package/src/picker.tsx
CHANGED
|
@@ -109,7 +109,12 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
|
|
|
109
109
|
placeholder={placeholder}
|
|
110
110
|
enabled={!disabled}
|
|
111
111
|
>
|
|
112
|
-
{(!value || includeEmptyOption) &&
|
|
112
|
+
{(!value || includeEmptyOption) && (
|
|
113
|
+
// Show the placeholder as the empty option (the standard
|
|
114
|
+
// `<option value="" selected>Placeholder</option>` pattern) so a
|
|
115
|
+
// native select hints what to choose.
|
|
116
|
+
<RNPicker.Item label={!value ? (placeholder ?? "") : ""} value="" />
|
|
117
|
+
)}
|
|
113
118
|
{options.map((option) =>
|
|
114
119
|
option ? (
|
|
115
120
|
<RNPicker.Item
|
|
@@ -259,6 +264,13 @@ function PickerTrigger<T extends string>({
|
|
|
259
264
|
<Pressable
|
|
260
265
|
ref={ref}
|
|
261
266
|
testID={testID}
|
|
267
|
+
// Without a role this Pressable renders as an unfocusable <div> on web —
|
|
268
|
+
// the trigger drops out of the tab order and Enter/Space can't open it.
|
|
269
|
+
// `button` makes it tab-focusable and maps keyboard activation to onPress;
|
|
270
|
+
// `expanded` announces open/closed to assistive tech. The accessible name
|
|
271
|
+
// comes from the visible selection/placeholder text below.
|
|
272
|
+
accessibilityRole="button"
|
|
273
|
+
accessibilityState={{ expanded: open, disabled }}
|
|
262
274
|
style={[
|
|
263
275
|
styles.pressable,
|
|
264
276
|
open && !enableSearch && styles.opened,
|
package/src/picker_menu.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { Checkbox } from "./checkbox";
|
|
|
7
7
|
import { Spacer } from "./spacer";
|
|
8
8
|
import { TextInputField } from "./text_input_field";
|
|
9
9
|
import { MenuButton } from "./menu_button";
|
|
10
|
+
import { ActivityIndicator } from "./activity_indicator";
|
|
10
11
|
import { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
11
12
|
import { useScreenSize } from "./use_screen_size";
|
|
12
13
|
|
|
@@ -23,6 +24,17 @@ export interface PickerMenuProps<T extends string = string, MULTI extends boolea
|
|
|
23
24
|
enableSearch?: boolean;
|
|
24
25
|
enableSelectAll?: boolean;
|
|
25
26
|
includeEmptyOption?: boolean;
|
|
27
|
+
/** Called whenever the search text changes. Provide it to drive server-side
|
|
28
|
+
* search — the consumer refreshes `options` in response. Omit for the default
|
|
29
|
+
* local filtering of `options`. */
|
|
30
|
+
onSearchChange?: (query: string) => void;
|
|
31
|
+
/** When true, `options` are treated as already filtered (server-side), so the
|
|
32
|
+
* local label filter is skipped and the full result set shows. */
|
|
33
|
+
serverFiltered?: boolean;
|
|
34
|
+
/** Show a loading indicator (e.g. while async results are in flight). */
|
|
35
|
+
loading?: boolean;
|
|
36
|
+
/** Shown when there are no options and not loading. Default: "No results". */
|
|
37
|
+
emptyText?: string;
|
|
26
38
|
/** Placeholder for the search input. Pass a translated string. Default: "Search..." */
|
|
27
39
|
searchPlaceholder?: string;
|
|
28
40
|
/** Label for the "select all" link in multi-select mode. Default: "Select all" */
|
|
@@ -50,6 +62,10 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
50
62
|
enableSelectAll,
|
|
51
63
|
enableSearch,
|
|
52
64
|
includeEmptyOption,
|
|
65
|
+
onSearchChange,
|
|
66
|
+
serverFiltered,
|
|
67
|
+
loading,
|
|
68
|
+
emptyText = "No results",
|
|
53
69
|
searchPlaceholder = "Search...",
|
|
54
70
|
selectAllLabel = "Select all",
|
|
55
71
|
deselectAllLabel = "Deselect all",
|
|
@@ -78,7 +94,9 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
78
94
|
|
|
79
95
|
const filteredOptions = useMemo(() => {
|
|
80
96
|
let result = options;
|
|
81
|
-
|
|
97
|
+
// Skip the local filter in server-driven mode — `options` already reflect
|
|
98
|
+
// the query for `searchQuery`.
|
|
99
|
+
if (enableSearch && searchQuery && !serverFiltered) {
|
|
82
100
|
const query = searchQuery.toLowerCase();
|
|
83
101
|
result = options.filter((opt) => opt && opt.label?.toLowerCase().includes(query));
|
|
84
102
|
}
|
|
@@ -90,7 +108,7 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
90
108
|
result = [emptyOption, ...result];
|
|
91
109
|
}
|
|
92
110
|
return result;
|
|
93
|
-
}, [options, enableSearch, searchQuery, includeEmptyOption]);
|
|
111
|
+
}, [options, enableSearch, searchQuery, includeEmptyOption, serverFiltered]);
|
|
94
112
|
|
|
95
113
|
const [focusedIndex, setFocusedIndex] = useState<number>(() => {
|
|
96
114
|
const selectedIndex = filteredOptions.findIndex((opt) => {
|
|
@@ -105,10 +123,14 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
105
123
|
return firstValid >= 0 ? firstValid : 0;
|
|
106
124
|
});
|
|
107
125
|
|
|
108
|
-
const handleSearchChange = useCallback(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
126
|
+
const handleSearchChange = useCallback(
|
|
127
|
+
(text: string) => {
|
|
128
|
+
setSearchQuery(text);
|
|
129
|
+
setFocusedIndex(0);
|
|
130
|
+
onSearchChange?.(text);
|
|
131
|
+
},
|
|
132
|
+
[onSearchChange],
|
|
133
|
+
);
|
|
112
134
|
|
|
113
135
|
const handleClose = useCallback(() => {
|
|
114
136
|
onRequestClose?.();
|
|
@@ -256,6 +278,18 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
256
278
|
/>
|
|
257
279
|
)}
|
|
258
280
|
<ScrollView ref={scrollViewRef} style={styles.optionsList}>
|
|
281
|
+
{loading && (
|
|
282
|
+
<View style={styles.statusRow}>
|
|
283
|
+
<ActivityIndicator />
|
|
284
|
+
</View>
|
|
285
|
+
)}
|
|
286
|
+
{!loading && filteredOptions.length === 0 && (
|
|
287
|
+
<View style={styles.statusRow}>
|
|
288
|
+
<Text color="zinc-500" size="sm">
|
|
289
|
+
{emptyText}
|
|
290
|
+
</Text>
|
|
291
|
+
</View>
|
|
292
|
+
)}
|
|
259
293
|
{filteredOptions.map((item, index) => {
|
|
260
294
|
if (!item) return null;
|
|
261
295
|
|
|
@@ -334,4 +368,9 @@ const styles = StyleSheet.create({
|
|
|
334
368
|
option: {
|
|
335
369
|
marginHorizontal: 0,
|
|
336
370
|
},
|
|
371
|
+
statusRow: {
|
|
372
|
+
alignItems: "center",
|
|
373
|
+
justifyContent: "center",
|
|
374
|
+
paddingVertical: 16,
|
|
375
|
+
},
|
|
337
376
|
});
|