gbs-add-block 1.2.17 → 1.2.18

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.
@@ -1,399 +1,409 @@
1
- "use client";
2
-
3
- import {
4
- useCallback,
5
- useEffect,
6
- useEffectEvent,
7
- useMemo,
8
- useRef,
9
- useState,
10
- type KeyboardEvent,
11
- type RefObject,
12
- } from "react";
13
- import {
14
- buildListItems,
15
- filterOptions,
16
- findByPrefix,
17
- firstEnabledIndex,
18
- lastEnabledIndex,
19
- matchRanges,
20
- nextEnabledIndex,
21
- splitTerms,
22
- toggleValue,
23
- } from "../core/filter";
24
- import type { ComboboxLocaleText, ComboboxOption, OptionValue } from "../core/types";
25
- import type { ComboboxHandle } from "./props";
26
-
27
- const TYPEAHEAD_RESET_MS = 700;
28
- const PAGE_STEP = 10;
29
-
30
- export interface UseComboboxParams<V extends OptionValue> {
31
- options: readonly ComboboxOption<V>[];
32
- multiple: boolean;
33
- /** Controlled values. `undefined` keeps them internal. */
34
- value?: V[];
35
- defaultValue: V[];
36
- onValuesChange(values: V[], options: ComboboxOption<V>[]): void;
37
- text: ComboboxLocaleText;
38
- id: string;
39
- /** Owned by the component so the hook's result stays free of refs. */
40
- controlRef: RefObject<HTMLDivElement | null>;
41
- searchRef: RefObject<HTMLInputElement | null>;
42
- listRef: RefObject<HTMLDivElement | null>;
43
- mode?: "client" | "server";
44
- searchable?: boolean;
45
- onSearchChange?(search: string): void;
46
- searchDebounce?: number;
47
- disabled?: boolean;
48
- filterFn?(option: ComboboxOption<V>, search: string): boolean;
49
- allowCreate?: boolean;
50
- onCreate?(label: string): void;
51
- closeOnSelect?: boolean;
52
- max?: number;
53
- onOpenChange?(open: boolean): void;
54
- }
55
-
56
- /**
57
- * State, filtering and keyboard behavior shared by Select and MultiSelect.
58
- * Exported so an app can build its own combobox UI on the same engine.
59
- */
60
- export function useCombobox<V extends OptionValue>(params: UseComboboxParams<V>) {
61
- const {
62
- options,
63
- multiple,
64
- value,
65
- defaultValue,
66
- onValuesChange,
67
- id,
68
- mode = "client",
69
- searchable = true,
70
- onSearchChange,
71
- searchDebounce = 250,
72
- disabled = false,
73
- filterFn,
74
- allowCreate = false,
75
- onCreate,
76
- closeOnSelect = !multiple,
77
- max,
78
- onOpenChange,
79
- controlRef,
80
- searchRef,
81
- listRef,
82
- } = params;
83
-
84
- const [open, setOpenState] = useState(false);
85
- const [search, setSearchState] = useState("");
86
- const [requestedIndex, setRequestedIndex] = useState(-1);
87
- const [internalValues, setInternalValues] = useState<V[]>(defaultValue);
88
- const [anchor, setAnchor] = useState<HTMLElement | null>(null);
89
- /** Labels of options chosen earlier, so they survive a server-side search. */
90
- const [known, setKnown] = useState<ReadonlyMap<V, ComboboxOption<V>>>(() => new Map());
91
-
92
- const typeahead = useRef({ text: "", time: 0 });
93
- /** Was the list open when the pointer went down on the control? */
94
- const dismissedByPointer = useRef(false);
95
-
96
- const values = value ?? internalValues;
97
- const listboxId = `${id}-listbox`;
98
- const getOptionId = useCallback((index: number) => `${id}-option-${index}`, [id]);
99
-
100
- // Server mode shows what the server returned; the search only highlights.
101
- const entries = useMemo(() => {
102
- if (mode === "server") {
103
- const terms = splitTerms(search);
104
- return options.map((option) => ({ option, matches: matchRanges(option.label, terms) ?? [] }));
105
- }
106
- return filterOptions(options, search, filterFn);
107
- }, [options, search, mode, filterFn]);
108
-
109
- const items = useMemo(() => buildListItems(entries), [entries]);
110
- const optionsByValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
111
-
112
- const resolveOption = useCallback(
113
- (v: V): ComboboxOption<V> =>
114
- optionsByValue.get(v) ?? known.get(v) ?? ({ value: v, label: String(v) } as ComboboxOption<V>),
115
- [optionsByValue, known],
116
- );
117
- const selectedOptions = useMemo(() => values.map(resolveOption), [values, resolveOption]);
118
- const isSelected = useCallback((v: V) => values.includes(v), [values]);
119
-
120
- // Highlight the selected option when opening, otherwise the first one.
121
- const fallbackIndex = useMemo(() => {
122
- const selected = entries.findIndex((e) => !e.option.disabled && values.includes(e.option.value));
123
- return selected === -1 ? firstEnabledIndex(entries) : selected;
124
- }, [entries, values]);
125
-
126
- const activeIndex =
127
- requestedIndex >= 0 && requestedIndex < entries.length && !entries[requestedIndex].option.disabled
128
- ? requestedIndex
129
- : open
130
- ? fallbackIndex
131
- : -1;
132
-
133
- const setOpen = useCallback(
134
- (next: boolean) => {
135
- if (next === open) return;
136
- setOpenState(next);
137
- setAnchor(next ? controlRef.current : null);
138
- setRequestedIndex(-1);
139
- if (!next) setSearchState("");
140
- onOpenChange?.(next);
141
- },
142
- [open, onOpenChange, controlRef],
143
- );
144
-
145
- const closeAndFocus = useCallback(() => {
146
- setOpen(false);
147
- controlRef.current?.focus({ preventScroll: true });
148
- }, [setOpen, controlRef]);
149
-
150
- const setSearch = useCallback((next: string) => {
151
- setSearchState(next);
152
- setRequestedIndex(-1);
153
- }, []);
154
-
155
- const commit = useCallback(
156
- (next: V[]) => {
157
- if (value === undefined) setInternalValues(next);
158
- onValuesChange(next, next.map(resolveOption));
159
- },
160
- [value, onValuesChange, resolveOption],
161
- );
162
-
163
- const selectOption = useCallback(
164
- (option: ComboboxOption<V>) => {
165
- if (option.disabled || disabled) return;
166
- setKnown((prev) => (prev.has(option.value) ? prev : new Map(prev).set(option.value, option)));
167
- commit(multiple ? toggleValue(values, option.value, max) : [option.value]);
168
- if (closeOnSelect) closeAndFocus();
169
- else if (!multiple) setSearchState("");
170
- },
171
- [disabled, multiple, values, max, commit, closeOnSelect, closeAndFocus],
172
- );
173
-
174
- const removeValue = useCallback(
175
- (v: V) => {
176
- if (disabled) return;
177
- commit(values.filter((current) => current !== v));
178
- },
179
- [disabled, values, commit],
180
- );
181
-
182
- const clear = useCallback(() => {
183
- if (disabled) return;
184
- commit([]);
185
- }, [disabled, commit]);
186
-
187
- const selectAllVisible = useCallback(() => {
188
- const added = entries
189
- .filter((entry) => !entry.option.disabled && !values.includes(entry.option.value))
190
- .map((entry) => entry.option);
191
- const room = max === undefined ? added.length : Math.max(0, max - values.length);
192
- const next = [...values, ...added.slice(0, room).map((option) => option.value)];
193
- setKnown((prev) => {
194
- const map = new Map(prev);
195
- for (const option of added) map.set(option.value, option);
196
- return map;
197
- });
198
- commit(next);
199
- }, [entries, values, max, commit]);
200
-
201
- const trimmed = search.trim();
202
- const createLabel =
203
- allowCreate && trimmed && !entries.some((e) => e.option.label.toLocaleLowerCase() === trimmed.toLocaleLowerCase())
204
- ? trimmed
205
- : null;
206
-
207
- const commitCreate = useCallback(() => {
208
- if (!createLabel) return;
209
- onCreate?.(createLabel);
210
- setSearchState("");
211
- if (!multiple) closeAndFocus();
212
- }, [createLabel, onCreate, multiple, closeAndFocus]);
213
-
214
- // Search requests: debounced in server mode, immediate when opening.
215
- const emitSearch = useEffectEvent((term: string) => onSearchChange?.(term));
216
- useEffect(() => {
217
- if (!onSearchChange || !open) return;
218
- const delay = mode === "server" && search ? searchDebounce : 0;
219
- const timer = setTimeout(() => emitSearch(search), delay);
220
- return () => clearTimeout(timer);
221
- }, [open, search, mode, searchDebounce, onSearchChange]);
222
-
223
- const runTypeahead = useCallback(
224
- (key: string) => {
225
- const now = Date.now();
226
- const buffer = now - typeahead.current.time > TYPEAHEAD_RESET_MS ? key : typeahead.current.text + key;
227
- typeahead.current = { text: buffer, time: now };
228
- const index = findByPrefix(entries, buffer, buffer.length > 1 ? activeIndex - 1 : activeIndex);
229
- if (index !== -1) setRequestedIndex(index);
230
- return index;
231
- },
232
- [entries, activeIndex],
233
- );
234
-
235
- const onKeyDown = useCallback(
236
- (event: KeyboardEvent<HTMLElement>) => {
237
- if (disabled) return;
238
- const { key } = event;
239
- const modified = event.ctrlKey || event.metaKey || event.altKey;
240
-
241
- if (!open) {
242
- if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === " ") {
243
- event.preventDefault();
244
- setOpen(true);
245
- return;
246
- }
247
- if (multiple && key === "Backspace" && values.length > 0) {
248
- event.preventDefault();
249
- removeValue(values[values.length - 1]);
250
- return;
251
- }
252
- if (key.length === 1 && !modified) {
253
- event.preventDefault();
254
- if (searchable) {
255
- setOpen(true);
256
- setSearchState(key);
257
- } else {
258
- runTypeahead(key);
259
- }
260
- }
261
- return;
262
- }
263
-
264
- switch (key) {
265
- case "ArrowDown":
266
- event.preventDefault();
267
- setRequestedIndex(nextEnabledIndex(entries, activeIndex, 1));
268
- break;
269
- case "ArrowUp":
270
- event.preventDefault();
271
- setRequestedIndex(nextEnabledIndex(entries, activeIndex, -1));
272
- break;
273
- case "Home":
274
- event.preventDefault();
275
- setRequestedIndex(firstEnabledIndex(entries));
276
- break;
277
- case "End":
278
- event.preventDefault();
279
- setRequestedIndex(lastEnabledIndex(entries));
280
- break;
281
- case "PageDown":
282
- event.preventDefault();
283
- setRequestedIndex(firstEnabledIndex(entries, Math.min(entries.length - 1, activeIndex + PAGE_STEP)));
284
- break;
285
- case "PageUp":
286
- event.preventDefault();
287
- setRequestedIndex(firstEnabledIndex(entries, Math.max(0, activeIndex - PAGE_STEP)));
288
- break;
289
- case "Enter": {
290
- event.preventDefault();
291
- const entry = entries[activeIndex];
292
- if (entry) selectOption(entry.option);
293
- else commitCreate();
294
- break;
295
- }
296
- case "Escape":
297
- event.preventDefault();
298
- closeAndFocus();
299
- break;
300
- case "Tab":
301
- setOpen(false);
302
- break;
303
- case "Backspace":
304
- if (multiple && search === "" && values.length > 0) {
305
- event.preventDefault();
306
- removeValue(values[values.length - 1]);
307
- }
308
- break;
309
- default:
310
- if (!searchable && key.length === 1 && !modified) {
311
- event.preventDefault();
312
- runTypeahead(key);
313
- }
314
- }
315
- },
316
- [
317
- disabled,
318
- open,
319
- multiple,
320
- values,
321
- searchable,
322
- entries,
323
- activeIndex,
324
- search,
325
- setOpen,
326
- removeValue,
327
- selectOption,
328
- commitCreate,
329
- closeAndFocus,
330
- runTypeahead,
331
- ],
332
- );
333
-
334
- /** The pointer press that light-dismissed an open list must not reopen it. */
335
- const onControlPointerDown = useCallback(() => {
336
- dismissedByPointer.current = open;
337
- }, [open]);
338
-
339
- const onControlClick = useCallback(() => {
340
- if (disabled) return;
341
- if (dismissedByPointer.current) {
342
- dismissedByPointer.current = false;
343
- return;
344
- }
345
- setOpen(!open);
346
- }, [disabled, open, setOpen]);
347
-
348
- /** Light dismiss and Escape come from the native popover. */
349
- const onPopoverClose = useCallback(() => {
350
- if (!open) return;
351
- const active = document.activeElement;
352
- const insideList = active === searchRef.current || listRef.current?.contains(active) === true;
353
- setOpen(false);
354
- if (insideList || active === document.body || active === null) {
355
- controlRef.current?.focus({ preventScroll: true });
356
- }
357
- }, [open, setOpen, controlRef, searchRef, listRef]);
358
-
359
- const handle = useMemo<ComboboxHandle<V>>(
360
- () => ({
361
- open: () => setOpen(true),
362
- close: () => setOpen(false),
363
- toggle: () => setOpen(!open),
364
- focus: () => controlRef.current?.focus(),
365
- clear: () => commit([]),
366
- getValue: () => [...values],
367
- getSelectedOptions: () => selectedOptions,
368
- }),
369
- [setOpen, open, commit, values, selectedOptions, controlRef],
370
- );
371
-
372
- return {
373
- open,
374
- setOpen,
375
- anchor,
376
- search,
377
- setSearch,
378
- entries,
379
- items,
380
- activeIndex,
381
- setActiveIndex: setRequestedIndex,
382
- values,
383
- selectedOptions,
384
- isSelected,
385
- selectOption,
386
- removeValue,
387
- clear,
388
- selectAllVisible,
389
- createLabel,
390
- commitCreate,
391
- listboxId,
392
- getOptionId,
393
- onKeyDown,
394
- onControlClick,
395
- onControlPointerDown,
396
- onPopoverClose,
397
- handle,
398
- };
399
- }
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useEffectEvent,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type KeyboardEvent,
11
+ type RefObject,
12
+ } from "react";
13
+ import {
14
+ buildListItems,
15
+ filterOptions,
16
+ findByPrefix,
17
+ firstEnabledIndex,
18
+ lastEnabledIndex,
19
+ matchRanges,
20
+ nextEnabledIndex,
21
+ splitTerms,
22
+ toggleValue,
23
+ } from "../core/filter";
24
+ import type { ComboboxLocaleText, ComboboxOption, OptionValue } from "../core/types";
25
+ import type { ComboboxHandle } from "./props";
26
+
27
+ const TYPEAHEAD_RESET_MS = 700;
28
+ const PAGE_STEP = 10;
29
+
30
+ export interface UseComboboxParams<V extends OptionValue> {
31
+ options: readonly ComboboxOption<V>[];
32
+ multiple: boolean;
33
+ /** Controlled values. `undefined` keeps them internal. */
34
+ value?: V[];
35
+ defaultValue: V[];
36
+ onValuesChange(values: V[], options: ComboboxOption<V>[]): void;
37
+ text: ComboboxLocaleText;
38
+ id: string;
39
+ /** Owned by the component so the hook's result stays free of refs. */
40
+ controlRef: RefObject<HTMLDivElement | null>;
41
+ searchRef: RefObject<HTMLInputElement | null>;
42
+ listRef: RefObject<HTMLDivElement | null>;
43
+ mode?: "client" | "server";
44
+ searchable?: boolean;
45
+ onSearchChange?(search: string): void;
46
+ searchDebounce?: number;
47
+ disabled?: boolean;
48
+ filterFn?(option: ComboboxOption<V>, search: string): boolean;
49
+ allowCreate?: boolean;
50
+ onCreate?(label: string): void;
51
+ closeOnSelect?: boolean;
52
+ max?: number;
53
+ onOpenChange?(open: boolean): void;
54
+ }
55
+
56
+ /**
57
+ * State, filtering and keyboard behavior shared by Select and MultiSelect.
58
+ * Exported so an app can build its own combobox UI on the same engine.
59
+ */
60
+ export function useCombobox<V extends OptionValue>(params: UseComboboxParams<V>) {
61
+ const {
62
+ options,
63
+ multiple,
64
+ value,
65
+ defaultValue,
66
+ onValuesChange,
67
+ id,
68
+ mode = "client",
69
+ searchable = true,
70
+ onSearchChange,
71
+ searchDebounce = 250,
72
+ disabled = false,
73
+ filterFn,
74
+ allowCreate = false,
75
+ onCreate,
76
+ closeOnSelect = !multiple,
77
+ max,
78
+ onOpenChange,
79
+ controlRef,
80
+ searchRef,
81
+ listRef,
82
+ } = params;
83
+
84
+ const [open, setOpenState] = useState(false);
85
+ const [search, setSearchState] = useState("");
86
+ const [requestedIndex, setRequestedIndex] = useState(-1);
87
+ const [internalValues, setInternalValues] = useState<V[]>(defaultValue);
88
+ const [anchor, setAnchor] = useState<HTMLElement | null>(null);
89
+ /** Labels of options chosen earlier, so they survive a server-side search. */
90
+ const [known, setKnown] = useState<ReadonlyMap<V, ComboboxOption<V>>>(() => new Map());
91
+
92
+ const typeahead = useRef({ text: "", time: 0 });
93
+ /** Was the list open when the pointer went down on the control? */
94
+ const dismissedByPointer = useRef(false);
95
+
96
+ const values = value ?? internalValues;
97
+ const listboxId = `${id}-listbox`;
98
+ const getOptionId = useCallback((index: number) => `${id}-option-${index}`, [id]);
99
+
100
+ // Server mode shows what the server returned; the search only highlights.
101
+ const entries = useMemo(() => {
102
+ if (mode === "server") {
103
+ const terms = splitTerms(search);
104
+ return options.map((option) => ({ option, matches: matchRanges(option.label, terms) ?? [] }));
105
+ }
106
+ return filterOptions(options, search, filterFn);
107
+ }, [options, search, mode, filterFn]);
108
+
109
+ const items = useMemo(() => buildListItems(entries), [entries]);
110
+ const optionsByValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
111
+
112
+ // `values.includes` in the hot paths below is O(values) per option, which is
113
+ // O(options x values) across a render — noticeable in a MultiSelect holding
114
+ // hundreds of selections over a long list.
115
+ const selectedSet = useMemo(() => new Set(values), [values]);
116
+
117
+ const resolveOption = useCallback(
118
+ (v: V): ComboboxOption<V> =>
119
+ optionsByValue.get(v) ?? known.get(v) ?? ({ value: v, label: String(v) } as ComboboxOption<V>),
120
+ [optionsByValue, known],
121
+ );
122
+ const selectedOptions = useMemo(() => values.map(resolveOption), [values, resolveOption]);
123
+ const isSelected = useCallback((v: V) => selectedSet.has(v), [selectedSet]);
124
+
125
+ // Highlight the selected option when opening, otherwise the first one.
126
+ const fallbackIndex = useMemo(() => {
127
+ const selected = entries.findIndex((e) => !e.option.disabled && selectedSet.has(e.option.value));
128
+ return selected === -1 ? firstEnabledIndex(entries) : selected;
129
+ }, [entries, selectedSet]);
130
+
131
+ const activeIndex =
132
+ requestedIndex >= 0 && requestedIndex < entries.length && !entries[requestedIndex].option.disabled
133
+ ? requestedIndex
134
+ : open
135
+ ? fallbackIndex
136
+ : -1;
137
+
138
+ const setOpen = useCallback(
139
+ (next: boolean) => {
140
+ if (next === open) return;
141
+ setOpenState(next);
142
+ setAnchor(next ? controlRef.current : null);
143
+ setRequestedIndex(-1);
144
+ if (!next) setSearchState("");
145
+ onOpenChange?.(next);
146
+ },
147
+ [open, onOpenChange, controlRef],
148
+ );
149
+
150
+ const closeAndFocus = useCallback(() => {
151
+ setOpen(false);
152
+ controlRef.current?.focus({ preventScroll: true });
153
+ }, [setOpen, controlRef]);
154
+
155
+ const setSearch = useCallback((next: string) => {
156
+ setSearchState(next);
157
+ setRequestedIndex(-1);
158
+ }, []);
159
+
160
+ const commit = useCallback(
161
+ (next: V[]) => {
162
+ if (value === undefined) setInternalValues(next);
163
+ onValuesChange(next, next.map(resolveOption));
164
+ },
165
+ [value, onValuesChange, resolveOption],
166
+ );
167
+
168
+ const selectOption = useCallback(
169
+ (option: ComboboxOption<V>) => {
170
+ if (option.disabled || disabled) return;
171
+ setKnown((prev) => (prev.has(option.value) ? prev : new Map(prev).set(option.value, option)));
172
+ commit(multiple ? toggleValue(values, option.value, max) : [option.value]);
173
+ if (closeOnSelect) closeAndFocus();
174
+ else if (!multiple) setSearchState("");
175
+ },
176
+ [disabled, multiple, values, max, commit, closeOnSelect, closeAndFocus],
177
+ );
178
+
179
+ const removeValue = useCallback(
180
+ (v: V) => {
181
+ if (disabled) return;
182
+ commit(values.filter((current) => current !== v));
183
+ },
184
+ [disabled, values, commit],
185
+ );
186
+
187
+ const clear = useCallback(() => {
188
+ if (disabled) return;
189
+ commit([]);
190
+ }, [disabled, commit]);
191
+
192
+ const selectAllVisible = useCallback(() => {
193
+ const added = entries
194
+ .filter((entry) => !entry.option.disabled && !selectedSet.has(entry.option.value))
195
+ .map((entry) => entry.option);
196
+ const room = max === undefined ? added.length : Math.max(0, max - values.length);
197
+ const next = [...values, ...added.slice(0, room).map((option) => option.value)];
198
+ setKnown((prev) => {
199
+ const map = new Map(prev);
200
+ for (const option of added) map.set(option.value, option);
201
+ return map;
202
+ });
203
+ commit(next);
204
+ }, [entries, values, selectedSet, max, commit]);
205
+
206
+ const trimmed = search.trim();
207
+ const createLabel =
208
+ allowCreate && trimmed && !entries.some((e) => e.option.label.toLocaleLowerCase() === trimmed.toLocaleLowerCase())
209
+ ? trimmed
210
+ : null;
211
+
212
+ const commitCreate = useCallback(() => {
213
+ if (!createLabel) return;
214
+ onCreate?.(createLabel);
215
+ setSearchState("");
216
+ if (!multiple) closeAndFocus();
217
+ }, [createLabel, onCreate, multiple, closeAndFocus]);
218
+
219
+ // Search requests: debounced in server mode, immediate when opening. Only
220
+ // whether there is a handler belongs in the dependency list, not the handler
221
+ // itself: an inline `onSearchChange` is a new function on every render of the
222
+ // parent, and depending on it would restart the debounce each time, holding
223
+ // the request back for as long as the parent kept rendering.
224
+ const wantsSearch = onSearchChange !== undefined;
225
+ const emitSearch = useEffectEvent((term: string) => onSearchChange?.(term));
226
+ useEffect(() => {
227
+ if (!wantsSearch || !open) return;
228
+ const delay = mode === "server" && search ? searchDebounce : 0;
229
+ const timer = setTimeout(() => emitSearch(search), delay);
230
+ return () => clearTimeout(timer);
231
+ }, [open, search, mode, searchDebounce, wantsSearch]);
232
+
233
+ const runTypeahead = useCallback(
234
+ (key: string) => {
235
+ const now = Date.now();
236
+ const buffer = now - typeahead.current.time > TYPEAHEAD_RESET_MS ? key : typeahead.current.text + key;
237
+ typeahead.current = { text: buffer, time: now };
238
+ const index = findByPrefix(entries, buffer, buffer.length > 1 ? activeIndex - 1 : activeIndex);
239
+ if (index !== -1) setRequestedIndex(index);
240
+ return index;
241
+ },
242
+ [entries, activeIndex],
243
+ );
244
+
245
+ const onKeyDown = useCallback(
246
+ (event: KeyboardEvent<HTMLElement>) => {
247
+ if (disabled) return;
248
+ const { key } = event;
249
+ const modified = event.ctrlKey || event.metaKey || event.altKey;
250
+
251
+ if (!open) {
252
+ if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === " ") {
253
+ event.preventDefault();
254
+ setOpen(true);
255
+ return;
256
+ }
257
+ if (multiple && key === "Backspace" && values.length > 0) {
258
+ event.preventDefault();
259
+ removeValue(values[values.length - 1]);
260
+ return;
261
+ }
262
+ if (key.length === 1 && !modified) {
263
+ event.preventDefault();
264
+ if (searchable) {
265
+ setOpen(true);
266
+ setSearchState(key);
267
+ } else {
268
+ runTypeahead(key);
269
+ }
270
+ }
271
+ return;
272
+ }
273
+
274
+ switch (key) {
275
+ case "ArrowDown":
276
+ event.preventDefault();
277
+ setRequestedIndex(nextEnabledIndex(entries, activeIndex, 1));
278
+ break;
279
+ case "ArrowUp":
280
+ event.preventDefault();
281
+ setRequestedIndex(nextEnabledIndex(entries, activeIndex, -1));
282
+ break;
283
+ case "Home":
284
+ event.preventDefault();
285
+ setRequestedIndex(firstEnabledIndex(entries));
286
+ break;
287
+ case "End":
288
+ event.preventDefault();
289
+ setRequestedIndex(lastEnabledIndex(entries));
290
+ break;
291
+ case "PageDown":
292
+ event.preventDefault();
293
+ setRequestedIndex(firstEnabledIndex(entries, Math.min(entries.length - 1, activeIndex + PAGE_STEP)));
294
+ break;
295
+ case "PageUp":
296
+ event.preventDefault();
297
+ setRequestedIndex(firstEnabledIndex(entries, Math.max(0, activeIndex - PAGE_STEP)));
298
+ break;
299
+ case "Enter": {
300
+ event.preventDefault();
301
+ const entry = entries[activeIndex];
302
+ if (entry) selectOption(entry.option);
303
+ else commitCreate();
304
+ break;
305
+ }
306
+ case "Escape":
307
+ event.preventDefault();
308
+ closeAndFocus();
309
+ break;
310
+ case "Tab":
311
+ setOpen(false);
312
+ break;
313
+ case "Backspace":
314
+ if (multiple && search === "" && values.length > 0) {
315
+ event.preventDefault();
316
+ removeValue(values[values.length - 1]);
317
+ }
318
+ break;
319
+ default:
320
+ if (!searchable && key.length === 1 && !modified) {
321
+ event.preventDefault();
322
+ runTypeahead(key);
323
+ }
324
+ }
325
+ },
326
+ [
327
+ disabled,
328
+ open,
329
+ multiple,
330
+ values,
331
+ searchable,
332
+ entries,
333
+ activeIndex,
334
+ search,
335
+ setOpen,
336
+ removeValue,
337
+ selectOption,
338
+ commitCreate,
339
+ closeAndFocus,
340
+ runTypeahead,
341
+ ],
342
+ );
343
+
344
+ /** The pointer press that light-dismissed an open list must not reopen it. */
345
+ const onControlPointerDown = useCallback(() => {
346
+ dismissedByPointer.current = open;
347
+ }, [open]);
348
+
349
+ const onControlClick = useCallback(() => {
350
+ if (disabled) return;
351
+ if (dismissedByPointer.current) {
352
+ dismissedByPointer.current = false;
353
+ return;
354
+ }
355
+ setOpen(!open);
356
+ }, [disabled, open, setOpen]);
357
+
358
+ /** Light dismiss and Escape come from the native popover. */
359
+ const onPopoverClose = useCallback(() => {
360
+ if (!open) return;
361
+ const active = document.activeElement;
362
+ const insideList = active === searchRef.current || listRef.current?.contains(active) === true;
363
+ setOpen(false);
364
+ if (insideList || active === document.body || active === null) {
365
+ controlRef.current?.focus({ preventScroll: true });
366
+ }
367
+ }, [open, setOpen, controlRef, searchRef, listRef]);
368
+
369
+ const handle = useMemo<ComboboxHandle<V>>(
370
+ () => ({
371
+ open: () => setOpen(true),
372
+ close: () => setOpen(false),
373
+ toggle: () => setOpen(!open),
374
+ focus: () => controlRef.current?.focus(),
375
+ clear: () => commit([]),
376
+ getValue: () => [...values],
377
+ getSelectedOptions: () => selectedOptions,
378
+ }),
379
+ [setOpen, open, commit, values, selectedOptions, controlRef],
380
+ );
381
+
382
+ return {
383
+ open,
384
+ setOpen,
385
+ anchor,
386
+ search,
387
+ setSearch,
388
+ entries,
389
+ items,
390
+ activeIndex,
391
+ setActiveIndex: setRequestedIndex,
392
+ values,
393
+ selectedOptions,
394
+ isSelected,
395
+ selectOption,
396
+ removeValue,
397
+ clear,
398
+ selectAllVisible,
399
+ createLabel,
400
+ commitCreate,
401
+ listboxId,
402
+ getOptionId,
403
+ onKeyDown,
404
+ onControlClick,
405
+ onControlPointerDown,
406
+ onPopoverClose,
407
+ handle,
408
+ };
409
+ }