@lotics/ui 1.13.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.13.2",
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",
@@ -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
+ });
@@ -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
- if (enableSearch && searchQuery) {
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((text: string) => {
109
- setSearchQuery(text);
110
- setFocusedIndex(0);
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
  });