@lotics/ui 1.13.4 → 1.14.0

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.4",
3
+ "version": "1.14.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -44,6 +44,7 @@
44
44
  "./button": "./src/button.tsx",
45
45
  "./checkbox": "./src/checkbox.tsx",
46
46
  "./combobox": "./src/combobox.tsx",
47
+ "./search_select": "./src/search_select.tsx",
47
48
  "./portal": "./src/portal.tsx",
48
49
  "./popover_nav": "./src/popover_nav.tsx",
49
50
  "./popover": "./src/popover.tsx",
package/src/combobox.tsx CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Pressable, StyleProp, ViewStyle, StyleSheet } from "react-native";
2
- import { useState, useCallback, useRef, useEffect } from "react";
2
+ import { useState, useCallback } from "react";
3
3
  import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
5
  import { Icon } from "./icon";
6
6
  import { Popover, PopoverTrigger, PopoverContent } from "./popover";
7
7
  import { PickerMenu } from "./picker_menu";
8
8
  import { PickerOption } from "./picker";
9
+ import { useDebouncedCallback } from "./use_debounced_callback";
9
10
 
10
11
  export interface ComboboxProps<T extends string = string> {
11
12
  /** The selected option. It carries its own label, so the trigger still shows
@@ -34,29 +35,6 @@ export interface ComboboxProps<T extends string = string> {
34
35
  style?: StyleProp<ViewStyle>;
35
36
  }
36
37
 
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
38
  /**
61
39
  * Async-search select — a keyboard-accessible combobox. The trigger opens a
62
40
  * Popover with a search box and a result list (arrows to move, Enter to pick,
@@ -53,6 +53,15 @@ export interface KanbanBoardProps<T> {
53
53
  itemGap?: number;
54
54
  /** When true, cards can only be dropped into columns (highlights entire column), not between specific cards */
55
55
  columnOnlyCardDrop?: boolean;
56
+ /**
57
+ * Whether cards can be dragged. Defaults to whether `onCardMove` is provided —
58
+ * a card with no move handler does not initiate a drag (it can still be
59
+ * pressed via `onCardPress`). Pass `false` for a read-only / action-driven
60
+ * board.
61
+ */
62
+ cardDraggable?: boolean;
63
+ /** Whether columns can be reordered. Defaults to whether `onColumnMove` is provided. */
64
+ columnDraggable?: boolean;
56
65
  }
57
66
 
58
67
  /** Column registration data */
@@ -80,7 +89,15 @@ export function KanbanBoard<T>({
80
89
  itemHeight,
81
90
  itemGap = DEFAULT_ITEM_GAP,
82
91
  columnOnlyCardDrop = false,
92
+ cardDraggable,
93
+ columnDraggable,
83
94
  }: KanbanBoardProps<T>) {
95
+ // A card/column is draggable only when there is a handler to receive the
96
+ // move — an explicit prop overrides. This makes a read-only or action-driven
97
+ // board (no move handler) non-draggable without extra wiring.
98
+ const cardsDraggable = cardDraggable ?? onCardMove != null;
99
+ const columnsDraggable = columnDraggable ?? onColumnMove != null;
100
+
84
101
  // Helper to get height for an item
85
102
  const getHeight = useCallback(
86
103
  (item: T, index: number): number => {
@@ -571,6 +588,8 @@ export function KanbanBoard<T>({
571
588
  onCardPress={onCardPress}
572
589
  onAddCard={onAddCard}
573
590
  onAddCardAtIndex={onAddCardAtIndex}
591
+ cardDraggable={cardsDraggable}
592
+ columnDraggable={columnsDraggable}
574
593
  startCardDrag={startCardDrag}
575
594
  startColumnDrag={startColumnDrag}
576
595
  registerColumn={registerColumn}
@@ -15,6 +15,8 @@ interface KanbanCardProps<T> {
15
15
  isDropTarget: boolean;
16
16
  /** True when any card is being dragged (disables insert zones) */
17
17
  isDragInProgress: boolean;
18
+ /** When false, this card does not initiate a drag (press still works) */
19
+ cardDraggable: boolean;
18
20
  onCardPress?: (cardId: string, item: T, columnKey: string) => void;
19
21
  onAddCardAtIndex?: (columnKey: string, index: number) => void;
20
22
  renderInsertCardButton?: (props: KanbanRenderInsertCardButtonProps) => React.ReactNode;
@@ -45,6 +47,7 @@ function KanbanCardInner<T>({
45
47
  isDragging,
46
48
  isDropTarget,
47
49
  isDragInProgress,
50
+ cardDraggable,
48
51
  onCardPress,
49
52
  onAddCardAtIndex,
50
53
  renderInsertCardButton,
@@ -106,37 +109,43 @@ function KanbanCardInner<T>({
106
109
  }, []);
107
110
 
108
111
  // Handle pointer down - attached via useEffect on web
109
- const handlePointerDown = useCallback((e: PointerEvent) => {
110
- // Determine pointer type
111
- const pointerType = e.pointerType as PointerType;
112
- pointerTypeRef.current = pointerType;
112
+ const handlePointerDown = useCallback(
113
+ (e: PointerEvent) => {
114
+ // Determine pointer type
115
+ const pointerType = e.pointerType as PointerType;
116
+ pointerTypeRef.current = pointerType;
113
117
 
114
- pressStartRef.current = { x: e.clientX, y: e.clientY };
115
- hasDraggedRef.current = false;
116
- wasCancelledRef.current = false;
117
-
118
- // For touch/pen: require a delay before drag can start
119
- // For mouse: drag can start immediately
120
- const isTouch = pointerType === "touch" || pointerType === "pen";
121
-
122
- if (isTouch) {
123
- canDragRef.current = false;
124
- setIsDragPending(true);
118
+ pressStartRef.current = { x: e.clientX, y: e.clientY };
119
+ hasDraggedRef.current = false;
120
+ wasCancelledRef.current = false;
125
121
 
126
- delayTimerRef.current = setTimeout(() => {
122
+ // For touch/pen: require a delay before drag can start
123
+ // For mouse: drag can start immediately
124
+ const isTouch = pointerType === "touch" || pointerType === "pen";
125
+
126
+ if (!cardDraggable) {
127
+ // Card cannot be dragged — only a press is detected. Never enable drag.
128
+ canDragRef.current = false;
129
+ } else if (isTouch) {
130
+ canDragRef.current = false;
131
+ setIsDragPending(true);
132
+
133
+ delayTimerRef.current = setTimeout(() => {
134
+ canDragRef.current = true;
135
+ delayTimerRef.current = null;
136
+ }, TOUCH_DRAG_DELAY);
137
+ } else {
127
138
  canDragRef.current = true;
128
- delayTimerRef.current = null;
129
- }, TOUCH_DRAG_DELAY);
130
- } else {
131
- canDragRef.current = true;
132
- }
139
+ }
133
140
 
134
- // Measure element position
135
- containerRef.current?.measureInWindow((x, y, width, height) => {
136
- elementRectRef.current = { x, y, width, height };
137
- setIsPressing(true);
138
- });
139
- }, []);
141
+ // Measure element position
142
+ containerRef.current?.measureInWindow((x, y, width, height) => {
143
+ elementRectRef.current = { x, y, width, height };
144
+ setIsPressing(true);
145
+ });
146
+ },
147
+ [cardDraggable],
148
+ );
140
149
 
141
150
  // Attach pointer down listener to DOM element on web
142
151
  // Must depend on isDragging because when isDragging=true, the element returns null
@@ -68,6 +68,10 @@ interface KanbanColumnProps<T> {
68
68
  onCardPress?: (cardId: string, item: T, columnKey: string) => void;
69
69
  onAddCard?: (columnKey: string) => void;
70
70
  onAddCardAtIndex?: (columnKey: string, index: number) => void;
71
+ /** Whether cards in this column can be dragged */
72
+ cardDraggable: boolean;
73
+ /** Whether this column can be reordered by dragging its header */
74
+ columnDraggable: boolean;
71
75
  startCardDrag: (
72
76
  cardId: string,
73
77
  columnKey: string,
@@ -115,6 +119,8 @@ function KanbanColumnInner<T>({
115
119
  onCardPress,
116
120
  onAddCard,
117
121
  onAddCardAtIndex,
122
+ cardDraggable,
123
+ columnDraggable,
118
124
  startCardDrag,
119
125
  startColumnDrag,
120
126
  registerColumn,
@@ -215,20 +221,24 @@ function KanbanColumnInner<T>({
215
221
  };
216
222
  }, [isHeaderPressing, columnKey, index, startColumnDrag]);
217
223
 
218
- const handleHeaderPressIn = useCallback((event: GestureResponderEvent) => {
219
- // Use clientX/clientY (viewport-relative) for consistency with mousemove events
220
- const clientX = event.nativeEvent.pageX - window.scrollX;
221
- const clientY = event.nativeEvent.pageY - window.scrollY;
222
- headerPressStartRef.current = { x: clientX, y: clientY };
223
- hasColumnDraggedRef.current = false;
224
+ const handleHeaderPressIn = useCallback(
225
+ (event: GestureResponderEvent) => {
226
+ if (!columnDraggable) return;
227
+ // Use clientX/clientY (viewport-relative) for consistency with mousemove events
228
+ const clientX = event.nativeEvent.pageX - window.scrollX;
229
+ const clientY = event.nativeEvent.pageY - window.scrollY;
230
+ headerPressStartRef.current = { x: clientX, y: clientY };
231
+ hasColumnDraggedRef.current = false;
224
232
 
225
- const element = containerRef.current as unknown as HTMLElement | null;
226
- if (element) {
227
- const rect = element.getBoundingClientRect();
228
- columnRectRef.current = rect;
229
- setIsHeaderPressing(true);
230
- }
231
- }, []);
233
+ const element = containerRef.current as unknown as HTMLElement | null;
234
+ if (element) {
235
+ const rect = element.getBoundingClientRect();
236
+ columnRectRef.current = rect;
237
+ setIsHeaderPressing(true);
238
+ }
239
+ },
240
+ [columnDraggable],
241
+ );
232
242
 
233
243
  const handleScroll = useCallback(
234
244
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -325,6 +335,7 @@ function KanbanColumnInner<T>({
325
335
  isDragging={isCardDragging}
326
336
  isDropTarget={isCardDropTarget}
327
337
  isDragInProgress={isDragInProgress}
338
+ cardDraggable={cardDraggable}
328
339
  onCardPress={onCardPress}
329
340
  onAddCardAtIndex={onAddCardAtIndex}
330
341
  renderInsertCardButton={renderInsertCardButton}
@@ -342,6 +353,7 @@ function KanbanColumnInner<T>({
342
353
  onCardPress,
343
354
  onAddCardAtIndex,
344
355
  renderInsertCardButton,
356
+ cardDraggable,
345
357
  startCardDrag,
346
358
  getHeight,
347
359
  itemGap,
package/src/picker.tsx CHANGED
@@ -8,11 +8,16 @@ import { Icon } from "./icon";
8
8
  import { Popover, PopoverTrigger, PopoverContent } from "./popover";
9
9
  import { PickerMenu } from "./picker_menu";
10
10
 
11
- export interface PickerOption<T extends string = string> {
11
+ export interface PickerOption<T extends string = string, D = unknown> {
12
12
  label?: string;
13
13
  value: T;
14
14
  disabled?: boolean;
15
15
  testID?: string;
16
+ /** Arbitrary payload carried alongside the option. Lets a rich
17
+ * `renderOptionContent` (and async/recents flows) render from the source
18
+ * row without a side lookup keyed by `value`. Untyped by default; a
19
+ * component generic over `D` surfaces it typed. */
20
+ data?: D;
16
21
  }
17
22
 
18
23
  export type PickerValue<T extends string, MULTI extends boolean> = MULTI extends true ? T[] : T;
package/src/popover.tsx CHANGED
@@ -184,6 +184,12 @@ export interface PopoverContentProps {
184
184
  small?: boolean;
185
185
  /** Accessible name for the bottom-sheet close button. Default: "Close". Pass a translated string from the consumer. */
186
186
  closeLabel?: string;
187
+ /** When true (default), the popover moves focus into its content on open and
188
+ * restores it on close. Set false for an anchored panel whose trigger must
189
+ * keep focus — e.g. a search-as-you-type combobox whose input lives outside
190
+ * the popover and drives the list via `aria-activedescendant`. Focus then
191
+ * stays on the trigger; keyboard still reaches it through the overlay. */
192
+ manageFocus?: boolean;
187
193
  }
188
194
 
189
195
  export function PopoverContent(props: PopoverContentProps) {
@@ -195,6 +201,7 @@ export function PopoverContent(props: PopoverContentProps) {
195
201
  disableBodyScroll,
196
202
  small = false,
197
203
  closeLabel = "Close",
204
+ manageFocus = true,
198
205
  } = props;
199
206
  const { open, onOpenChange, triggerRef, side, align, offset, inheritTriggerWidth } =
200
207
  usePopoverContext();
@@ -232,7 +239,7 @@ export function PopoverContent(props: PopoverContentProps) {
232
239
  // When it closes, restore focus to the prior element so keyboard users don't
233
240
  // land at the top of the page.
234
241
  useEffect(() => {
235
- if (!open) return;
242
+ if (!open || !manageFocus) return;
236
243
  const previouslyFocused = document.activeElement as HTMLElement | null;
237
244
  returnFocusRef.current = previouslyFocused;
238
245
 
@@ -253,7 +260,7 @@ export function PopoverContent(props: PopoverContentProps) {
253
260
  target.focus();
254
261
  }
255
262
  };
256
- }, [open]);
263
+ }, [open, manageFocus]);
257
264
 
258
265
  // Separate header, footer, and body from children
259
266
  let header: React.ReactNode = null;
@@ -0,0 +1,295 @@
1
+ import {
2
+ View,
3
+ ScrollView,
4
+ StyleSheet,
5
+ type StyleProp,
6
+ type ViewStyle,
7
+ type TextInput as RNTextInput,
8
+ } from "react-native";
9
+ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
10
+ import { colors } from "./colors";
11
+ import { Text } from "./text";
12
+ import { Icon } from "./icon";
13
+ import { TextInputField } from "./text_input_field";
14
+ import { MenuButton } from "./menu_button";
15
+ import { ActivityIndicator } from "./activity_indicator";
16
+ import { Popover, PopoverContent } from "./popover";
17
+ import type { PickerOption } from "./picker";
18
+ import { useDebouncedCallback } from "./use_debounced_callback";
19
+ import { useListKeyboardNav } from "./use_list_keyboard_nav";
20
+
21
+ export interface SearchSelectProps<T extends string = string, D = unknown> {
22
+ /** Result options for the current query. The consumer refreshes these in
23
+ * response to `onSearchChange` — search runs server-side, so the whole table
24
+ * is never shipped to the client. */
25
+ options: PickerOption<T, D>[];
26
+ /** Debounced; fires as the user types. Drive a server re-query from it. */
27
+ onSearchChange: (query: string) => void;
28
+ /** Fires when the user picks a result or a recent. */
29
+ onValueChange: (option: PickerOption<T, D>) => void;
30
+ /** Shown — under a heading — when the box is focused but empty (e.g. the
31
+ * output of `useRecents`). Omit for no recents. */
32
+ recentOptions?: PickerOption<T, D>[];
33
+ /** Render a rich row from the option (use its `data` payload). Falls back to
34
+ * `option.label`. */
35
+ renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
36
+ /** Marks the matching row as the current selection (check + highlight). The
37
+ * box itself stays a search affordance — the selection is shown by the host. */
38
+ selectedValue?: T | null;
39
+ /** Show a spinner while results are in flight. */
40
+ loading?: boolean;
41
+ /** Debounce for `onSearchChange`, in ms. Default 200. */
42
+ searchDebounceMs?: number;
43
+ placeholder?: string;
44
+ /** Heading above the recents list. Default "Recent". Pass a translated string. */
45
+ recentsLabel?: string;
46
+ /** No-results-for-query text. Default "No results". Pass a translated string. */
47
+ emptyText?: string;
48
+ /** Accessible name for the results listbox. Default "Results". */
49
+ accessibilityLabel?: string;
50
+ disabled?: boolean;
51
+ autoFocus?: boolean;
52
+ testID?: string;
53
+ style?: StyleProp<ViewStyle>;
54
+ }
55
+
56
+ const OPTION_HEIGHT = 44;
57
+
58
+ /**
59
+ * Search-first combobox: an always-visible search input (the "panel") whose
60
+ * typing drives a debounced, server-side search; results drop into a Popover
61
+ * listbox below. Focused while empty, it offers `recentOptions`. Picking a row
62
+ * fires `onValueChange` and clears the box for the next search — the selection
63
+ * itself is rendered by the host (a summary above, details below).
64
+ *
65
+ * Focus stays on the input the whole time (Popover `manageFocus={false}`); the
66
+ * input owns the keyboard (↑/↓ move the active row via `aria-activedescendant`,
67
+ * Enter selects, Esc closes) — the ARIA combobox pattern, not a button trigger.
68
+ */
69
+ export function SearchSelect<T extends string = string, D = unknown>(
70
+ props: SearchSelectProps<T, D>,
71
+ ) {
72
+ const {
73
+ options,
74
+ onSearchChange,
75
+ onValueChange,
76
+ recentOptions,
77
+ renderOptionContent,
78
+ selectedValue = null,
79
+ loading = false,
80
+ searchDebounceMs = 200,
81
+ placeholder,
82
+ recentsLabel = "Recent",
83
+ emptyText = "No results",
84
+ accessibilityLabel = "Results",
85
+ disabled = false,
86
+ autoFocus = false,
87
+ testID,
88
+ style,
89
+ } = props;
90
+
91
+ const [query, setQuery] = useState("");
92
+ const [open, setOpen] = useState(autoFocus);
93
+ const triggerRef = useRef<View>(null);
94
+ const inputRef = useRef<RNTextInput>(null);
95
+ const scrollRef = useRef<ScrollView>(null);
96
+ const baseId = useId();
97
+ const listboxId = `${baseId}-listbox`;
98
+ const optionId = (i: number) => `${baseId}-option-${i}`;
99
+
100
+ const searching = query.trim().length > 0;
101
+ const list = searching ? options : (recentOptions ?? []);
102
+
103
+ const debouncedSearch = useDebouncedCallback(onSearchChange, searchDebounceMs);
104
+
105
+ const scrollToIndex = useCallback((index: number) => {
106
+ scrollRef.current?.scrollTo({ y: Math.max(0, index * OPTION_HEIGHT - 80), animated: false });
107
+ }, []);
108
+
109
+ const handleSelect = useCallback(
110
+ (index: number) => {
111
+ const opt = list[index];
112
+ if (!opt || opt.disabled) return;
113
+ onValueChange(opt);
114
+ // The trailing search is irrelevant once a pick is made. Cancel it and
115
+ // tell the consumer the term is now empty (the box clears) so its query
116
+ // state matches and any search-driven fetch goes idle.
117
+ debouncedSearch.cancel();
118
+ setQuery("");
119
+ onSearchChange("");
120
+ setOpen(false);
121
+ // A click on a row blurs the input; keep it focused so the user can
122
+ // search again without re-clicking.
123
+ inputRef.current?.focus();
124
+ },
125
+ [list, onValueChange, onSearchChange, debouncedSearch],
126
+ );
127
+
128
+ const { activeIndex, setActiveIndex, handleKey } = useListKeyboardNav({
129
+ count: list.length,
130
+ isDisabled: (i) => list[i]?.disabled ?? false,
131
+ onSelect: handleSelect,
132
+ onClose: () => setOpen(false),
133
+ onActiveChange: scrollToIndex,
134
+ });
135
+
136
+ // Reset the active row when the visible list changes (new results, or toggling
137
+ // between results and recents). Keyed on primitives so a consumer re-render
138
+ // with an equal-but-new array doesn't reset mid-navigation.
139
+ const firstValue = list[0]?.value;
140
+ useEffect(() => {
141
+ setActiveIndex(0);
142
+ }, [searching, list.length, firstValue, setActiveIndex]);
143
+
144
+ const handleChangeText = useCallback(
145
+ (text: string) => {
146
+ setQuery(text);
147
+ if (!disabled) setOpen(true);
148
+ debouncedSearch(text);
149
+ },
150
+ [disabled, debouncedSearch],
151
+ );
152
+
153
+ const handleKeyPress = useCallback(
154
+ (e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
155
+ const key = e.nativeEvent.key;
156
+ if (!open && (key === "ArrowDown" || key === "ArrowUp")) setOpen(true);
157
+ if (handleKey(key)) e.preventDefault();
158
+ },
159
+ [open, handleKey],
160
+ );
161
+
162
+ // Nothing to show ⇒ don't open an empty popover (focused box, no recents).
163
+ const showList = open && !disabled && (searching || list.length > 0);
164
+ const activeOptionId =
165
+ activeIndex >= 0 && activeIndex < list.length ? optionId(activeIndex) : undefined;
166
+ const listLabel = searching ? accessibilityLabel : recentsLabel;
167
+
168
+ return (
169
+ <View style={style}>
170
+ <View ref={triggerRef}>
171
+ <TextInputField
172
+ ref={inputRef}
173
+ testID={testID}
174
+ icon="search"
175
+ clearable
176
+ value={query}
177
+ onChangeText={handleChangeText}
178
+ onFocus={() => {
179
+ if (!disabled) setOpen(true);
180
+ }}
181
+ onKeyPress={handleKeyPress}
182
+ placeholder={placeholder}
183
+ placeholderTextColor={colors.zinc["400"]}
184
+ editable={!disabled}
185
+ autoFocus={autoFocus}
186
+ autoCapitalize="none"
187
+ autoCorrect={false}
188
+ role="combobox"
189
+ aria-expanded={showList}
190
+ aria-controls={listboxId}
191
+ aria-activedescendant={activeOptionId}
192
+ aria-autocomplete="list"
193
+ />
194
+ </View>
195
+ <Popover
196
+ open={showList}
197
+ onOpenChange={setOpen}
198
+ triggerRef={triggerRef}
199
+ side="bottom"
200
+ align="start"
201
+ offset={4}
202
+ inheritTriggerWidth
203
+ >
204
+ <PopoverContent
205
+ manageFocus={false}
206
+ disableBodyScroll
207
+ testID={testID ? `${testID}-popover` : undefined}
208
+ >
209
+ <View style={styles.menu}>
210
+ {!searching && list.length > 0 ? (
211
+ <View style={styles.sectionHeader}>
212
+ <Text size="xs" weight="medium" color="zinc-500">
213
+ {recentsLabel}
214
+ </Text>
215
+ </View>
216
+ ) : null}
217
+ <ScrollView
218
+ ref={scrollRef}
219
+ style={styles.scroll}
220
+ nativeID={listboxId}
221
+ accessibilityLabel={listLabel}
222
+ keyboardShouldPersistTaps="handled"
223
+ // `listbox` is valid ARIA but absent from React Native's Role enum;
224
+ // react-native-web forwards it verbatim for assistive tech.
225
+ role={"listbox" as "list"}
226
+ >
227
+ {loading && searching ? (
228
+ <View style={styles.statusRow}>
229
+ <ActivityIndicator />
230
+ </View>
231
+ ) : list.length === 0 ? (
232
+ <View style={styles.statusRow}>
233
+ <Text size="sm" color="zinc-500">
234
+ {emptyText}
235
+ </Text>
236
+ </View>
237
+ ) : (
238
+ list.map((opt, i) => {
239
+ const isSelected = opt.value === selectedValue;
240
+ return (
241
+ <MenuButton
242
+ key={opt.value}
243
+ nativeID={optionId(i)}
244
+ testID={`search-option-${opt.value}`}
245
+ role="option"
246
+ title={
247
+ renderOptionContent ? (
248
+ renderOptionContent(opt)
249
+ ) : (
250
+ <Text userSelect="none" numberOfLines={1}>
251
+ {opt.label ?? opt.value}
252
+ </Text>
253
+ )
254
+ }
255
+ accessibilityLabel={opt.label ?? opt.value}
256
+ focused={i === activeIndex}
257
+ selected={isSelected}
258
+ right={
259
+ isSelected ? (
260
+ <Icon name="check" size={18} color={colors.zinc["950"]} />
261
+ ) : undefined
262
+ }
263
+ disabled={opt.disabled}
264
+ onPress={() => handleSelect(i)}
265
+ onHoverIn={() => setActiveIndex(i)}
266
+ />
267
+ );
268
+ })
269
+ )}
270
+ </ScrollView>
271
+ </View>
272
+ </PopoverContent>
273
+ </Popover>
274
+ </View>
275
+ );
276
+ }
277
+
278
+ const styles = StyleSheet.create({
279
+ menu: {
280
+ gap: 2,
281
+ },
282
+ sectionHeader: {
283
+ paddingHorizontal: 8,
284
+ paddingTop: 4,
285
+ paddingBottom: 2,
286
+ },
287
+ scroll: {
288
+ maxHeight: 320,
289
+ },
290
+ statusRow: {
291
+ alignItems: "center",
292
+ justifyContent: "center",
293
+ paddingVertical: 16,
294
+ },
295
+ });
@@ -0,0 +1,35 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * Debounce a possibly-undefined callback. The latest `fn` is always invoked
5
+ * (captured by ref, so changing it between renders doesn't reset the timer),
6
+ * and any pending call is cleared on unmount. `cancel` drops a pending call —
7
+ * use it when a selection makes the trailing search irrelevant.
8
+ */
9
+ export function useDebouncedCallback<A extends unknown[]>(
10
+ fn: ((...args: A) => void) | undefined,
11
+ ms: number,
12
+ ): ((...args: A) => void) & { cancel: () => void } {
13
+ const fnRef = useRef(fn);
14
+ fnRef.current = fn;
15
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
16
+
17
+ const cancel = useCallback(() => {
18
+ if (timer.current) {
19
+ clearTimeout(timer.current);
20
+ timer.current = null;
21
+ }
22
+ }, []);
23
+
24
+ useEffect(() => cancel, [cancel]);
25
+
26
+ const debounced = useCallback(
27
+ (...args: A) => {
28
+ cancel();
29
+ timer.current = setTimeout(() => fnRef.current?.(...args), ms);
30
+ },
31
+ [ms, cancel],
32
+ );
33
+
34
+ return Object.assign(debounced, { cancel });
35
+ }
@@ -0,0 +1,106 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { useListKeyboardNav, type ListKeyboardNavOptions } from "./use_list_keyboard_nav";
5
+
6
+ function setup(overrides: Partial<ListKeyboardNavOptions> = {}) {
7
+ const onSelect = vi.fn();
8
+ const onClose = vi.fn();
9
+ const onActiveChange = vi.fn();
10
+ const opts: ListKeyboardNavOptions = {
11
+ count: 4,
12
+ onSelect,
13
+ onClose,
14
+ onActiveChange,
15
+ ...overrides,
16
+ };
17
+ const view = renderHook((p: ListKeyboardNavOptions) => useListKeyboardNav(p), {
18
+ initialProps: opts,
19
+ });
20
+ return { view, onSelect, onClose, onActiveChange };
21
+ }
22
+
23
+ describe("useListKeyboardNav", () => {
24
+ it("starts at index 0", () => {
25
+ const { view } = setup();
26
+ expect(view.result.current.activeIndex).toBe(0);
27
+ });
28
+
29
+ it("ArrowDown / ArrowUp move within bounds and report handled", () => {
30
+ const { view } = setup();
31
+ let handled = false;
32
+ act(() => {
33
+ handled = view.result.current.handleKey("ArrowDown");
34
+ });
35
+ expect(handled).toBe(true);
36
+ expect(view.result.current.activeIndex).toBe(1);
37
+ act(() => void view.result.current.handleKey("ArrowUp"));
38
+ expect(view.result.current.activeIndex).toBe(0);
39
+ });
40
+
41
+ it("does not move past the ends", () => {
42
+ const { view } = setup({ count: 2 });
43
+ act(() => void view.result.current.handleKey("ArrowUp"));
44
+ expect(view.result.current.activeIndex).toBe(0);
45
+ act(() => void view.result.current.handleKey("ArrowDown"));
46
+ act(() => void view.result.current.handleKey("ArrowDown"));
47
+ expect(view.result.current.activeIndex).toBe(1);
48
+ });
49
+
50
+ it("skips disabled rows", () => {
51
+ const { view } = setup({ count: 4, isDisabled: (i) => i === 1 || i === 2 });
52
+ act(() => void view.result.current.handleKey("ArrowDown"));
53
+ expect(view.result.current.activeIndex).toBe(3);
54
+ });
55
+
56
+ it("Home / End jump to the first / last enabled row", () => {
57
+ const { view } = setup({ count: 4, isDisabled: (i) => i === 3 });
58
+ act(() => void view.result.current.handleKey("End"));
59
+ expect(view.result.current.activeIndex).toBe(2);
60
+ act(() => void view.result.current.handleKey("Home"));
61
+ expect(view.result.current.activeIndex).toBe(0);
62
+ });
63
+
64
+ it("Enter selects the active row", () => {
65
+ const { view, onSelect } = setup();
66
+ act(() => void view.result.current.handleKey("ArrowDown"));
67
+ act(() => void view.result.current.handleKey("Enter"));
68
+ expect(onSelect).toHaveBeenCalledWith(1);
69
+ });
70
+
71
+ it("Enter does not select a disabled active row", () => {
72
+ const { view, onSelect } = setup({ count: 2, isDisabled: () => true });
73
+ act(() => void view.result.current.handleKey("Enter"));
74
+ expect(onSelect).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it("Escape closes and is handled; Tab closes but is not handled", () => {
78
+ const { view, onClose } = setup();
79
+ let escHandled = true;
80
+ let tabHandled = true;
81
+ act(() => {
82
+ escHandled = view.result.current.handleKey("Escape");
83
+ });
84
+ act(() => {
85
+ tabHandled = view.result.current.handleKey("Tab");
86
+ });
87
+ expect(onClose).toHaveBeenCalledTimes(2);
88
+ expect(escHandled).toBe(true);
89
+ expect(tabHandled).toBe(false);
90
+ });
91
+
92
+ it("reports unknown keys as not handled", () => {
93
+ const { view } = setup();
94
+ let handled = true;
95
+ act(() => {
96
+ handled = view.result.current.handleKey("a");
97
+ });
98
+ expect(handled).toBe(false);
99
+ });
100
+
101
+ it("notifies onActiveChange when the active row moves", () => {
102
+ const { view, onActiveChange } = setup();
103
+ act(() => void view.result.current.handleKey("ArrowDown"));
104
+ expect(onActiveChange).toHaveBeenCalledWith(1);
105
+ });
106
+ });
@@ -0,0 +1,90 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ export interface ListKeyboardNavOptions {
4
+ /** Number of rows in the list. */
5
+ count: number;
6
+ /** Whether the row at `index` is disabled (skipped by arrow nav). */
7
+ isDisabled?: (index: number) => boolean;
8
+ /** Activate the row at `index` (Enter, or a click the caller wires up). */
9
+ onSelect: (index: number) => void;
10
+ /** Dismiss the list (Escape, Tab). */
11
+ onClose?: () => void;
12
+ /** Fires when the active row changes — use it to scroll the row into view. */
13
+ onActiveChange?: (index: number) => void;
14
+ }
15
+
16
+ export interface ListKeyboardNav {
17
+ /** The virtually-focused row. The list stays visually highlighted here while
18
+ * DOM focus remains on the controlling input (ARIA `aria-activedescendant`). */
19
+ activeIndex: number;
20
+ setActiveIndex: (index: number) => void;
21
+ /** Feed a key from the controlling input's `onKeyPress`. Returns true when it
22
+ * handled the key, so the caller can `preventDefault()`. */
23
+ handleKey: (key: string) => boolean;
24
+ }
25
+
26
+ /**
27
+ * Roving keyboard navigation for a single-select option list driven by an
28
+ * external input (the combobox / command-menu pattern): Up/Down move past
29
+ * disabled rows, Home/End jump to the ends, Enter selects, Escape/Tab dismiss.
30
+ * Focus never leaves the input — this only tracks the active index.
31
+ */
32
+ export function useListKeyboardNav(opts: ListKeyboardNavOptions): ListKeyboardNav {
33
+ const { count, isDisabled, onSelect, onClose, onActiveChange } = opts;
34
+ const [activeIndex, setActiveIndexState] = useState(0);
35
+
36
+ const setActiveIndex = useCallback(
37
+ (index: number) => {
38
+ setActiveIndexState(index);
39
+ onActiveChange?.(index);
40
+ },
41
+ [onActiveChange],
42
+ );
43
+
44
+ const handleKey = useCallback(
45
+ (key: string): boolean => {
46
+ const enabled = (i: number) => i >= 0 && i < count && !(isDisabled?.(i) ?? false);
47
+ switch (key) {
48
+ case "ArrowDown": {
49
+ let next = activeIndex + 1;
50
+ while (next < count && !enabled(next)) next++;
51
+ if (next < count) setActiveIndex(next);
52
+ return true;
53
+ }
54
+ case "ArrowUp": {
55
+ let prev = activeIndex - 1;
56
+ while (prev >= 0 && !enabled(prev)) prev--;
57
+ if (prev >= 0) setActiveIndex(prev);
58
+ return true;
59
+ }
60
+ case "Home": {
61
+ let first = 0;
62
+ while (first < count && !enabled(first)) first++;
63
+ if (first < count) setActiveIndex(first);
64
+ return true;
65
+ }
66
+ case "End": {
67
+ let last = count - 1;
68
+ while (last >= 0 && !enabled(last)) last--;
69
+ if (last >= 0) setActiveIndex(last);
70
+ return true;
71
+ }
72
+ case "Enter":
73
+ if (enabled(activeIndex)) onSelect(activeIndex);
74
+ return true;
75
+ case "Escape":
76
+ onClose?.();
77
+ return true;
78
+ case "Tab":
79
+ // Let focus move on, but dismiss the list.
80
+ onClose?.();
81
+ return false;
82
+ default:
83
+ return false;
84
+ }
85
+ },
86
+ [activeIndex, count, isDisabled, onSelect, onClose, setActiveIndex],
87
+ );
88
+
89
+ return { activeIndex, setActiveIndex, handleKey };
90
+ }