@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 +2 -1
- package/src/combobox.tsx +2 -24
- package/src/kanban/kanban_board.tsx +19 -0
- package/src/kanban/kanban_card.tsx +36 -27
- package/src/kanban/kanban_column.tsx +25 -13
- package/src/picker.tsx +6 -1
- package/src/popover.tsx +9 -2
- package/src/search_select.tsx +295 -0
- package/src/use_debounced_callback.ts +35 -0
- package/src/use_list_keyboard_nav.test.ts +106 -0
- package/src/use_list_keyboard_nav.ts +90 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
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
|
|
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(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
}, TOUCH_DRAG_DELAY);
|
|
130
|
-
} else {
|
|
131
|
-
canDragRef.current = true;
|
|
132
|
-
}
|
|
139
|
+
}
|
|
133
140
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
}
|