@lotics/ui 5.9.0 → 5.10.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/AGENTS.md +127 -44
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_record.tsx +5 -4
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +440 -0
- package/package.json +10 -5
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +43 -42
- package/src/file_preview.web.tsx +25 -11
- package/src/file_preview_types.ts +9 -0
- package/src/file_row.tsx +5 -1
- package/src/file_rows.tsx +6 -2
- package/src/file_thumbnail.tsx +13 -4
- package/src/file_thumbnail_grid.tsx +5 -0
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +2 -2
- package/src/image_gallery.tsx +8 -10
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- package/src/time_field.tsx +0 -297
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { ScrollView } from "react-native";
|
|
3
|
+
import { useListKeyboardNav } from "./use_list_keyboard_nav";
|
|
4
|
+
import type { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
5
|
+
|
|
6
|
+
const OPTION_HEIGHT = 40;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How the option list is searched — the seam between the two keyboard-ownership
|
|
10
|
+
* models. `none`: no filtering, native-`<select>` typeahead jumps rows (the body
|
|
11
|
+
* owns a hidden capture input). `internal`: the body renders its OWN search field
|
|
12
|
+
* and filters locally. `controlled`: a consumer's external input owns the query
|
|
13
|
+
* (the Combobox pattern) — `onServerSearch` present ⇒ server-side (no local
|
|
14
|
+
* filter), absent ⇒ local filter of the passed `options`.
|
|
15
|
+
*/
|
|
16
|
+
export type OptionListSearch =
|
|
17
|
+
| { mode: "none" }
|
|
18
|
+
| { mode: "internal"; placeholder?: string }
|
|
19
|
+
| {
|
|
20
|
+
mode: "controlled";
|
|
21
|
+
/** The filter query, owned by the consumer's external input (Combobox). */
|
|
22
|
+
query: string;
|
|
23
|
+
/** `options` are ALREADY filtered (a server search) — skip local filtering. */
|
|
24
|
+
serverFiltered?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A row's nature. `option`: a real choice. `custom`: the free-entry "create" row.
|
|
28
|
+
* `empty`: the leading "clear" row from `includeEmptyOption`. */
|
|
29
|
+
export type OptionRowKind = "option" | "custom" | "empty";
|
|
30
|
+
|
|
31
|
+
export interface OptionRow<T extends string, D> {
|
|
32
|
+
kind: OptionRowKind;
|
|
33
|
+
/** For `custom`, value/label carry the typed query + its create label; for
|
|
34
|
+
* `empty`, both are "". */
|
|
35
|
+
option: PickerOption<T, D>;
|
|
36
|
+
/** Flat index into the keyboard-nav space (and `optionId`). */
|
|
37
|
+
index: number;
|
|
38
|
+
/** Already chosen — multi ⇒ a checkbox, single ⇒ a trailing check. */
|
|
39
|
+
selected: boolean;
|
|
40
|
+
/** DOM id for `aria-activedescendant` wiring. */
|
|
41
|
+
nativeID: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UseOptionListParams<
|
|
45
|
+
T extends string = string,
|
|
46
|
+
MULTI extends boolean = false,
|
|
47
|
+
D = unknown,
|
|
48
|
+
> {
|
|
49
|
+
options: (PickerOption<T, D> | undefined | false)[];
|
|
50
|
+
multi?: MULTI;
|
|
51
|
+
value?: PickerValue<T, MULTI> | null;
|
|
52
|
+
onValueChange?: PickerOnValueChange<T, MULTI>;
|
|
53
|
+
/** Multi: commit-on-close. Single: fired (with the picked value) after a pick. */
|
|
54
|
+
onClose?: PickerOnClose<T, MULTI>;
|
|
55
|
+
/** Dismiss the surrounding popover (Escape, Tab, a single-select pick). */
|
|
56
|
+
onRequestClose?: () => void;
|
|
57
|
+
search: OptionListSearch;
|
|
58
|
+
/** Shown when the query is empty (e.g. a Combobox's recents). */
|
|
59
|
+
recentOptions?: PickerOption<T, D>[];
|
|
60
|
+
/** Offer a "create" row when the query matches no option. */
|
|
61
|
+
allowCustom?: boolean;
|
|
62
|
+
customOptionLabel?: (query: string) => string | null;
|
|
63
|
+
customOptionPlacement?: "top" | "bottom";
|
|
64
|
+
/** Commit the typed query as a new value (the create row was picked). */
|
|
65
|
+
onCustomCommit?: (rawQuery: string) => void;
|
|
66
|
+
/** Single-select: a leading "" row that clears the value. */
|
|
67
|
+
includeEmptyOption?: boolean;
|
|
68
|
+
enableSelectAll?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface UseOptionList<T extends string, D> {
|
|
72
|
+
rows: OptionRow<T, D>[];
|
|
73
|
+
activeIndex: number;
|
|
74
|
+
/** `rows[activeIndex].nativeID` — feed to the controlling input's `aria-activedescendant`. */
|
|
75
|
+
activeId: string | undefined;
|
|
76
|
+
/** Move the active row; pass `false` on mouse hover so it doesn't self-scroll. */
|
|
77
|
+
setActiveIndex: (index: number, scrollIntoView?: boolean) => void;
|
|
78
|
+
/** Feed from the controlling input's `onKeyPress`; returns true when handled. */
|
|
79
|
+
handleKey: (key: string) => boolean;
|
|
80
|
+
/** Activate a row (a click, or Enter) — resolves custom/empty/multi/single. */
|
|
81
|
+
pickRow: (index: number) => void;
|
|
82
|
+
query: string;
|
|
83
|
+
setQuery: (query: string) => void;
|
|
84
|
+
searching: boolean;
|
|
85
|
+
selectAll: () => void;
|
|
86
|
+
deselectAll: () => void;
|
|
87
|
+
showSelectAll: boolean;
|
|
88
|
+
showDeselectAll: boolean;
|
|
89
|
+
listboxId: string;
|
|
90
|
+
scrollRef: React.RefObject<ScrollView | null>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const defaultCustomLabel = (query: string) => `Add "${query}"`;
|
|
94
|
+
|
|
95
|
+
/** Fold case + diacritics for matching, so option search is forgiving: `cafe`
|
|
96
|
+
* matches `Café`, `dieu` matches `Điều`. Still substring-based (predictable —
|
|
97
|
+
* no subsequence/typo noise); the typed value keeps its original accents. */
|
|
98
|
+
function normalizeForSearch(s: string): string {
|
|
99
|
+
return s
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.normalize("NFD")
|
|
102
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
103
|
+
.replaceAll(String.fromCharCode(0x111), "d");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The headless core of the kit's option listbox — filtering, the custom/empty
|
|
108
|
+
* rows, single/multi selection + select-all, and keyboard nav + typeahead — with
|
|
109
|
+
* no rendering. `OptionList` is the visual default over it; `Combobox` consumes it
|
|
110
|
+
* directly so its own input keeps DOM focus (`aria-activedescendant`). The single
|
|
111
|
+
* source of truth for "what's in the list and what Enter does" across every kit
|
|
112
|
+
* selector.
|
|
113
|
+
*/
|
|
114
|
+
export function useOptionList<T extends string, MULTI extends boolean = false, D = unknown>(
|
|
115
|
+
params: UseOptionListParams<T, MULTI, D>,
|
|
116
|
+
): UseOptionList<T, D> {
|
|
117
|
+
const {
|
|
118
|
+
options,
|
|
119
|
+
multi = false as MULTI,
|
|
120
|
+
value,
|
|
121
|
+
onValueChange,
|
|
122
|
+
onClose,
|
|
123
|
+
onRequestClose,
|
|
124
|
+
search,
|
|
125
|
+
recentOptions,
|
|
126
|
+
allowCustom = false,
|
|
127
|
+
customOptionLabel,
|
|
128
|
+
customOptionPlacement = "bottom",
|
|
129
|
+
onCustomCommit,
|
|
130
|
+
includeEmptyOption = false,
|
|
131
|
+
enableSelectAll = false,
|
|
132
|
+
} = params;
|
|
133
|
+
|
|
134
|
+
const baseId = useId();
|
|
135
|
+
const listboxId = `${baseId}-listbox`;
|
|
136
|
+
const optionId = useCallback((i: number) => `${baseId}-option-${i}`, [baseId]);
|
|
137
|
+
const scrollRef = useRef<ScrollView | null>(null);
|
|
138
|
+
|
|
139
|
+
// --- query (mode-dependent) ---
|
|
140
|
+
const searchMode = search.mode;
|
|
141
|
+
const serverFiltered = search.mode === "controlled" && search.serverFiltered === true;
|
|
142
|
+
|
|
143
|
+
const [internalQuery, setInternalQuery] = useState("");
|
|
144
|
+
const query =
|
|
145
|
+
search.mode === "controlled" ? search.query : search.mode === "internal" ? internalQuery : "";
|
|
146
|
+
const searching = query.trim().length > 0;
|
|
147
|
+
|
|
148
|
+
const setQuery = useCallback(
|
|
149
|
+
(next: string) => {
|
|
150
|
+
// Only the internal-search body owns its query state; `none` (typeahead
|
|
151
|
+
// capture) and `controlled` (the Combobox's external input) callers own theirs.
|
|
152
|
+
if (searchMode === "internal") setInternalQuery(next);
|
|
153
|
+
},
|
|
154
|
+
[searchMode],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// --- selection model ---
|
|
158
|
+
const { multiValue, singleValue } = useMemo(() => {
|
|
159
|
+
if (multi) {
|
|
160
|
+
return { multiValue: (Array.isArray(value) ? value : []) as T[], singleValue: undefined };
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
multiValue: [] as T[],
|
|
164
|
+
singleValue: (Array.isArray(value) ? value[0] : value) as T | undefined | null,
|
|
165
|
+
};
|
|
166
|
+
}, [multi, value]);
|
|
167
|
+
const selectedSet = useMemo(() => new Set(multiValue), [multiValue]);
|
|
168
|
+
|
|
169
|
+
// --- filtering ---
|
|
170
|
+
const cleanOptions = useMemo(
|
|
171
|
+
() => options.filter((o): o is PickerOption<T, D> => !!o),
|
|
172
|
+
[options],
|
|
173
|
+
);
|
|
174
|
+
const filtered = useMemo(() => {
|
|
175
|
+
if (searchMode === "none" || serverFiltered) return cleanOptions;
|
|
176
|
+
if (!searching) {
|
|
177
|
+
// Empty query: a controlled (Combobox) field browses recents, else its full
|
|
178
|
+
// local set; an internal field shows everything.
|
|
179
|
+
return searchMode === "controlled" ? (recentOptions ?? cleanOptions) : cleanOptions;
|
|
180
|
+
}
|
|
181
|
+
const q = normalizeForSearch(query);
|
|
182
|
+
return cleanOptions.filter((o) => normalizeForSearch(o.label ?? o.value).includes(q));
|
|
183
|
+
}, [searchMode, serverFiltered, searching, cleanOptions, recentOptions, query]);
|
|
184
|
+
|
|
185
|
+
// --- the free-entry "create" row ---
|
|
186
|
+
const customRow = useMemo((): PickerOption<T, D> | null => {
|
|
187
|
+
if (!allowCustom || !searching) return null;
|
|
188
|
+
const q = query.trim();
|
|
189
|
+
const ql = normalizeForSearch(q);
|
|
190
|
+
const exact = cleanOptions.some(
|
|
191
|
+
(o) => normalizeForSearch(o.label ?? o.value) === ql || normalizeForSearch(String(o.value)) === ql,
|
|
192
|
+
);
|
|
193
|
+
if (exact) return null;
|
|
194
|
+
const label = customOptionLabel ? customOptionLabel(q) : defaultCustomLabel(q);
|
|
195
|
+
if (label === null) return null;
|
|
196
|
+
return { value: q as T, label };
|
|
197
|
+
}, [allowCustom, searching, query, cleanOptions, customOptionLabel]);
|
|
198
|
+
|
|
199
|
+
// --- the flat, ordered row list ---
|
|
200
|
+
const rows = useMemo<OptionRow<T, D>[]>(() => {
|
|
201
|
+
const out: OptionRow<T, D>[] = [];
|
|
202
|
+
const push = (kind: OptionRowKind, option: PickerOption<T, D>) => {
|
|
203
|
+
// The create row is never "selected"; a real option and the empty/clear row
|
|
204
|
+
// reflect the current value (the clear row shows checked when value is "").
|
|
205
|
+
const selected =
|
|
206
|
+
kind !== "custom" && (multi ? selectedSet.has(option.value) : option.value === singleValue);
|
|
207
|
+
out.push({ kind, option, index: out.length, selected, nativeID: optionId(out.length) });
|
|
208
|
+
};
|
|
209
|
+
if (includeEmptyOption && !multi && !searching) push("empty", { value: "" as T, label: "" });
|
|
210
|
+
if (customRow && customOptionPlacement === "top") push("custom", customRow);
|
|
211
|
+
for (const o of filtered) push("option", o);
|
|
212
|
+
if (customRow && customOptionPlacement === "bottom") push("custom", customRow);
|
|
213
|
+
return out;
|
|
214
|
+
}, [
|
|
215
|
+
filtered,
|
|
216
|
+
customRow,
|
|
217
|
+
customOptionPlacement,
|
|
218
|
+
includeEmptyOption,
|
|
219
|
+
multi,
|
|
220
|
+
searching,
|
|
221
|
+
selectedSet,
|
|
222
|
+
singleValue,
|
|
223
|
+
optionId,
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
// The first selectable real option (selection-INDEPENDENT — its value only moves
|
|
227
|
+
// when the filtered set does, never on a toggle).
|
|
228
|
+
const firstRealIndex = useMemo(() => {
|
|
229
|
+
const real = rows.findIndex((r) => r.kind === "option" && !r.option.disabled);
|
|
230
|
+
if (real >= 0) return real;
|
|
231
|
+
const any = rows.findIndex((r) => !r.option.disabled);
|
|
232
|
+
return any >= 0 ? any : 0;
|
|
233
|
+
}, [rows]);
|
|
234
|
+
|
|
235
|
+
// The row to highlight on OPEN: the selected option when browsing (none mode, so
|
|
236
|
+
// an open Select lands on the current value), else the first real option. Read
|
|
237
|
+
// once via the nav hook's `initialIndex`.
|
|
238
|
+
const initialActiveIndex = useMemo(() => {
|
|
239
|
+
if (searchMode === "none") {
|
|
240
|
+
const sel = rows.findIndex((r) => r.selected && !r.option.disabled);
|
|
241
|
+
if (sel >= 0) return sel;
|
|
242
|
+
}
|
|
243
|
+
return firstRealIndex;
|
|
244
|
+
}, [searchMode, rows, firstRealIndex]);
|
|
245
|
+
|
|
246
|
+
const scrollToIndex = useCallback((index: number) => {
|
|
247
|
+
if (index >= 0) scrollRef.current?.scrollTo({ y: index * OPTION_HEIGHT - 80, animated: false });
|
|
248
|
+
}, []);
|
|
249
|
+
|
|
250
|
+
const requestClose = useCallback(() => {
|
|
251
|
+
onRequestClose?.();
|
|
252
|
+
if (onClose) {
|
|
253
|
+
if (multi) (onClose as (v: T[]) => void)(multiValue);
|
|
254
|
+
else (onClose as (v: T) => void)((singleValue ?? "") as T);
|
|
255
|
+
}
|
|
256
|
+
}, [onRequestClose, onClose, multi, multiValue, singleValue]);
|
|
257
|
+
|
|
258
|
+
const pickRow = useCallback(
|
|
259
|
+
(i: number) => {
|
|
260
|
+
const row = rows[i];
|
|
261
|
+
if (!row || row.option.disabled) return;
|
|
262
|
+
if (row.kind === "custom") {
|
|
263
|
+
onCustomCommit?.(query.trim());
|
|
264
|
+
// Reset an internal search after creating, so the field is ready for the
|
|
265
|
+
// next add (a controlled/Combobox caller manages its own input instead).
|
|
266
|
+
if (searchMode === "internal") setInternalQuery("");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (row.kind === "empty") {
|
|
270
|
+
if (!multi) (onValueChange as ((v: T) => void) | undefined)?.("" as T);
|
|
271
|
+
requestClose();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (multi) {
|
|
275
|
+
if (!onValueChange) return;
|
|
276
|
+
const next = selectedSet.has(row.option.value)
|
|
277
|
+
? multiValue.filter((v) => v !== row.option.value)
|
|
278
|
+
: [...multiValue, row.option.value];
|
|
279
|
+
(onValueChange as (v: T[]) => void)(next);
|
|
280
|
+
// Multi stays open so more rows can be toggled.
|
|
281
|
+
} else {
|
|
282
|
+
(onValueChange as ((v: T) => void) | undefined)?.(row.option.value);
|
|
283
|
+
requestClose();
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
[rows, multi, onValueChange, onCustomCommit, query, multiValue, selectedSet, requestClose, searchMode],
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const { activeIndex, setActiveIndex, handleKey } = useListKeyboardNav({
|
|
290
|
+
count: rows.length,
|
|
291
|
+
isDisabled: (i) => rows[i]?.option.disabled ?? false,
|
|
292
|
+
onSelect: pickRow,
|
|
293
|
+
onClose: requestClose,
|
|
294
|
+
onActiveChange: scrollToIndex,
|
|
295
|
+
initialIndex: initialActiveIndex,
|
|
296
|
+
// Typeahead only when the body owns a hidden capture input — in search modes a
|
|
297
|
+
// printable key is a query character, so it must flow to the input instead.
|
|
298
|
+
typeahead:
|
|
299
|
+
searchMode === "none"
|
|
300
|
+
? { labelAt: (i) => rows[i]?.option.label ?? rows[i]?.option.value }
|
|
301
|
+
: undefined,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// After the initial seat, re-home the active row to the first real option ONLY
|
|
305
|
+
// when the FILTER changes (a new search) — so Enter on a partial query picks a
|
|
306
|
+
// match, never the create row. Keyed on the filtered set, NOT on selection: a
|
|
307
|
+
// multi toggle keeps the popover open and must leave the active row where it is.
|
|
308
|
+
const filterKey = useMemo(
|
|
309
|
+
() => filtered.map((o) => o.value).join("") + "" + (customRow?.value ?? ""),
|
|
310
|
+
[filtered, customRow],
|
|
311
|
+
);
|
|
312
|
+
const seatedRef = useRef(false);
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (!seatedRef.current) {
|
|
315
|
+
seatedRef.current = true; // mount already seated via initialIndex
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (searchMode !== "none") setActiveIndex(firstRealIndex);
|
|
319
|
+
}, [filterKey, firstRealIndex, searchMode, setActiveIndex]);
|
|
320
|
+
|
|
321
|
+
const selectAll = useCallback(() => {
|
|
322
|
+
if (!multi || !onValueChange) return;
|
|
323
|
+
(onValueChange as (v: T[]) => void)(
|
|
324
|
+
cleanOptions.filter((o) => !o.disabled).map((o) => o.value),
|
|
325
|
+
);
|
|
326
|
+
}, [multi, onValueChange, cleanOptions]);
|
|
327
|
+
const deselectAll = useCallback(() => {
|
|
328
|
+
if (!multi || !onValueChange) return;
|
|
329
|
+
(onValueChange as (v: T[]) => void)([]);
|
|
330
|
+
}, [multi, onValueChange]);
|
|
331
|
+
|
|
332
|
+
const hasOptions = rows.some((r) => r.kind === "option" && !r.option.disabled);
|
|
333
|
+
const hasSelection = multi ? multiValue.length > 0 : singleValue != null && singleValue !== "";
|
|
334
|
+
const showSelectAll = !!multi && enableSelectAll && hasOptions;
|
|
335
|
+
const showDeselectAll = showSelectAll && hasSelection;
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
rows,
|
|
339
|
+
activeIndex,
|
|
340
|
+
activeId: rows[activeIndex]?.nativeID,
|
|
341
|
+
setActiveIndex,
|
|
342
|
+
handleKey,
|
|
343
|
+
pickRow,
|
|
344
|
+
query,
|
|
345
|
+
setQuery,
|
|
346
|
+
searching,
|
|
347
|
+
selectAll,
|
|
348
|
+
deselectAll,
|
|
349
|
+
showSelectAll,
|
|
350
|
+
showDeselectAll,
|
|
351
|
+
listboxId,
|
|
352
|
+
scrollRef,
|
|
353
|
+
};
|
|
354
|
+
}
|
package/src/command_menu.tsx
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import { StyleSheet, View, ScrollView } from "react-native";
|
|
2
|
-
import { useState, useCallback, useMemo, useRef, useId } from "react";
|
|
3
|
-
import { colors } from "./colors";
|
|
4
|
-
import { TextInputField } from "./text_input_field";
|
|
5
|
-
import { MenuButton } from "./menu_button";
|
|
6
|
-
import { Text } from "./text";
|
|
7
|
-
import { useScreenSize } from "./use_screen_size";
|
|
8
|
-
|
|
9
|
-
export interface CommandOption {
|
|
10
|
-
value: string;
|
|
11
|
-
label: string;
|
|
12
|
-
disabled?: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface CommandMenuProps {
|
|
16
|
-
options: CommandOption[];
|
|
17
|
-
onSelect: (value: string) => void;
|
|
18
|
-
onRequestClose?: () => void;
|
|
19
|
-
enableSearch?: boolean;
|
|
20
|
-
/** Search input placeholder. Default: "Search...". Pass a translated string from the consumer. */
|
|
21
|
-
searchPlaceholder?: string;
|
|
22
|
-
/** Accessible name for the list. Default: "Options". Pass a translated string from the consumer. */
|
|
23
|
-
accessibilityLabel?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function CommandMenu(props: CommandMenuProps) {
|
|
27
|
-
const {
|
|
28
|
-
options,
|
|
29
|
-
onSelect,
|
|
30
|
-
onRequestClose,
|
|
31
|
-
enableSearch,
|
|
32
|
-
searchPlaceholder = "Search...",
|
|
33
|
-
accessibilityLabel,
|
|
34
|
-
} = props;
|
|
35
|
-
const scrollViewRef = useRef<ScrollView>(null);
|
|
36
|
-
const screenSize = useScreenSize();
|
|
37
|
-
const OPTION_HEIGHT = 40;
|
|
38
|
-
|
|
39
|
-
const baseId = useId();
|
|
40
|
-
const listboxId = `${baseId}-listbox`;
|
|
41
|
-
const optionId = (index: number) => `${baseId}-option-${index}`;
|
|
42
|
-
|
|
43
|
-
const [searchQuery, setSearchQuery] = useState("");
|
|
44
|
-
|
|
45
|
-
const filteredOptions = useMemo(() => {
|
|
46
|
-
if (!enableSearch || !searchQuery) return options;
|
|
47
|
-
const query = searchQuery.toLowerCase();
|
|
48
|
-
return options.filter((opt) => opt.label.toLowerCase().includes(query));
|
|
49
|
-
}, [options, enableSearch, searchQuery]);
|
|
50
|
-
|
|
51
|
-
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
52
|
-
|
|
53
|
-
const handleSearchChange = useCallback((text: string) => {
|
|
54
|
-
setSearchQuery(text);
|
|
55
|
-
setFocusedIndex(0);
|
|
56
|
-
}, []);
|
|
57
|
-
|
|
58
|
-
const handleSelect = useCallback(
|
|
59
|
-
(value: string) => {
|
|
60
|
-
onSelect(value);
|
|
61
|
-
},
|
|
62
|
-
[onSelect],
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
const scrollToIndex = useCallback((index: number) => {
|
|
66
|
-
if (scrollViewRef.current && index >= 0) {
|
|
67
|
-
scrollViewRef.current.scrollTo({ y: index * OPTION_HEIGHT - 80, animated: false });
|
|
68
|
-
}
|
|
69
|
-
}, []);
|
|
70
|
-
|
|
71
|
-
const handleKeyNavigation = useCallback(
|
|
72
|
-
(key: string): boolean => {
|
|
73
|
-
switch (key) {
|
|
74
|
-
case "ArrowDown": {
|
|
75
|
-
let next = focusedIndex + 1;
|
|
76
|
-
while (next < filteredOptions.length) {
|
|
77
|
-
if (!filteredOptions[next].disabled) {
|
|
78
|
-
setFocusedIndex(next);
|
|
79
|
-
scrollToIndex(next);
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
next++;
|
|
83
|
-
}
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
case "ArrowUp": {
|
|
87
|
-
let next = focusedIndex - 1;
|
|
88
|
-
while (next >= 0) {
|
|
89
|
-
if (!filteredOptions[next].disabled) {
|
|
90
|
-
setFocusedIndex(next);
|
|
91
|
-
scrollToIndex(next);
|
|
92
|
-
break;
|
|
93
|
-
}
|
|
94
|
-
next--;
|
|
95
|
-
}
|
|
96
|
-
return true;
|
|
97
|
-
}
|
|
98
|
-
case "Home":
|
|
99
|
-
setFocusedIndex(0);
|
|
100
|
-
scrollToIndex(0);
|
|
101
|
-
return true;
|
|
102
|
-
case "End": {
|
|
103
|
-
const last = filteredOptions.length - 1;
|
|
104
|
-
setFocusedIndex(last);
|
|
105
|
-
scrollToIndex(last);
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
case "Enter":
|
|
109
|
-
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
|
|
110
|
-
const opt = filteredOptions[focusedIndex];
|
|
111
|
-
if (opt && !opt.disabled) handleSelect(opt.value);
|
|
112
|
-
}
|
|
113
|
-
return true;
|
|
114
|
-
case "Escape":
|
|
115
|
-
onRequestClose?.();
|
|
116
|
-
return true;
|
|
117
|
-
case "Tab":
|
|
118
|
-
onRequestClose?.();
|
|
119
|
-
return false;
|
|
120
|
-
default:
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
[focusedIndex, filteredOptions, handleSelect, onRequestClose, scrollToIndex],
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
const handleSearchKeyPress = useCallback(
|
|
128
|
-
(e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
|
|
129
|
-
if (handleKeyNavigation(e.nativeEvent.key)) {
|
|
130
|
-
e.preventDefault();
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
[handleKeyNavigation],
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
const listLabel = accessibilityLabel ?? "Options";
|
|
137
|
-
const activeOptionId =
|
|
138
|
-
focusedIndex >= 0 && focusedIndex < filteredOptions.length ? optionId(focusedIndex) : undefined;
|
|
139
|
-
|
|
140
|
-
return (
|
|
141
|
-
<View style={[styles.container, screenSize.small ? { flex: 1 } : { maxHeight: 480 }]}>
|
|
142
|
-
{enableSearch && (
|
|
143
|
-
<TextInputField
|
|
144
|
-
autoFocus={!screenSize.small}
|
|
145
|
-
icon="search"
|
|
146
|
-
value={searchQuery}
|
|
147
|
-
onChangeText={handleSearchChange}
|
|
148
|
-
placeholder={searchPlaceholder}
|
|
149
|
-
placeholderTextColor={colors.zinc["400"]}
|
|
150
|
-
autoCapitalize="none"
|
|
151
|
-
autoCorrect={false}
|
|
152
|
-
onKeyPress={handleSearchKeyPress}
|
|
153
|
-
accessibilityLabel={listLabel}
|
|
154
|
-
role="combobox"
|
|
155
|
-
aria-expanded
|
|
156
|
-
aria-controls={listboxId}
|
|
157
|
-
aria-activedescendant={activeOptionId}
|
|
158
|
-
aria-autocomplete="list"
|
|
159
|
-
/>
|
|
160
|
-
)}
|
|
161
|
-
<ScrollView
|
|
162
|
-
ref={scrollViewRef}
|
|
163
|
-
style={styles.optionsList}
|
|
164
|
-
nativeID={listboxId}
|
|
165
|
-
accessibilityLabel={listLabel}
|
|
166
|
-
// Boundary adapter: `listbox` is a valid ARIA role but missing from
|
|
167
|
-
// React Native's `Role` enum, so TypeScript cannot accept the literal
|
|
168
|
-
// here. `react-native-web` forwards the attribute verbatim, which is
|
|
169
|
-
// what assistive technology reads on web.
|
|
170
|
-
role={"listbox" as "list"}
|
|
171
|
-
>
|
|
172
|
-
{filteredOptions.map((item, index) => (
|
|
173
|
-
<MenuButton
|
|
174
|
-
key={item.value}
|
|
175
|
-
nativeID={optionId(index)}
|
|
176
|
-
testID={`command-option-${item.value}`}
|
|
177
|
-
title={
|
|
178
|
-
<Text userSelect="none" numberOfLines={1}>
|
|
179
|
-
{item.label}
|
|
180
|
-
</Text>
|
|
181
|
-
}
|
|
182
|
-
accessibilityLabel={item.label}
|
|
183
|
-
role="option"
|
|
184
|
-
right={undefined}
|
|
185
|
-
focused={index === focusedIndex}
|
|
186
|
-
selected={index === focusedIndex}
|
|
187
|
-
disabled={item.disabled}
|
|
188
|
-
onPress={() => handleSelect(item.value)}
|
|
189
|
-
onHoverIn={() => setFocusedIndex(index)}
|
|
190
|
-
/>
|
|
191
|
-
))}
|
|
192
|
-
</ScrollView>
|
|
193
|
-
</View>
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const styles = StyleSheet.create({
|
|
198
|
-
container: {
|
|
199
|
-
gap: 4,
|
|
200
|
-
},
|
|
201
|
-
optionsList: {
|
|
202
|
-
flex: 1,
|
|
203
|
-
gap: 4,
|
|
204
|
-
},
|
|
205
|
-
});
|