gbs-add-block 1.2.8 → 1.2.9

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.
@@ -0,0 +1,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
+ 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
+ }