@inshorts/glint-ui 0.3.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.
@@ -0,0 +1,666 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { Check, ChevronDown, Search, X } from "lucide-react";
6
+
7
+ import { useHasMounted } from "./modal";
8
+ import { cn } from "./utils";
9
+
10
+ /** Room the list wants below the control before it gives up and flips. */
11
+ const PREFERRED_LIST_HEIGHT = 224;
12
+ /** Below this there is no point opening downward at all. */
13
+ const MIN_LIST_HEIGHT = 120;
14
+ const VIEWPORT_MARGIN = 8;
15
+ const ANCHOR_GAP = 4;
16
+
17
+ type DropdownPosition = {
18
+ left: number;
19
+ width: number;
20
+ maxHeight: number;
21
+ /** One of the two is set: `top` opens downward, `bottom` opens upward. */
22
+ top?: number;
23
+ bottom?: number;
24
+ };
25
+
26
+ export type AutocompleteOption = {
27
+ value: string;
28
+ label: string;
29
+ disabled?: boolean;
30
+ };
31
+
32
+ type BaseAutocompleteProps = {
33
+ options: AutocompleteOption[];
34
+ placeholder?: string;
35
+ disabled?: boolean;
36
+ className?: string;
37
+ emptyMessage?: string;
38
+ id?: string;
39
+ /**
40
+ * Accessible name for the combobox.
41
+ *
42
+ * `Field` renders its label as a `<span>`, so without this the control is
43
+ * announced as an unnamed combobox and cannot be queried by name — the
44
+ * filter toolbars work around it by counting comboboxes on the page.
45
+ */
46
+ ariaLabel?: string;
47
+ /**
48
+ * `compact` is the spreadsheet cell: no search icon, no min height, and a
49
+ * list wide enough to read a name. The closed control still shows the
50
+ * chosen label, and typing filters the list.
51
+ */
52
+ size?: "default" | "compact";
53
+ /**
54
+ * Type a value and press Enter (or paste a list) to add it, like antd
55
+ * `Select mode="tags"`. With `multiple` a pasted list is split into several
56
+ * values; on a single-value control it accepts one typed value the options
57
+ * do not offer — an id for a record the list has not loaded, say.
58
+ */
59
+ creatable?: boolean;
60
+ /**
61
+ * Shows the clear button once a value is chosen. Off for dropdowns that
62
+ * always keep one of their options, where clearing would blank a required
63
+ * field.
64
+ */
65
+ clearable?: boolean;
66
+ };
67
+
68
+ type SingleAutocompleteProps = BaseAutocompleteProps & {
69
+ multiple?: false;
70
+ value: string;
71
+ onChange: (value: string) => void;
72
+ };
73
+
74
+ type MultipleAutocompleteProps = BaseAutocompleteProps & {
75
+ multiple: true;
76
+ value: string[];
77
+ onChange: (value: string[]) => void;
78
+ };
79
+
80
+ export type AutocompleteProps =
81
+ SingleAutocompleteProps | MultipleAutocompleteProps;
82
+
83
+ function normalizeQuery(query: string) {
84
+ return query.trim().toLowerCase();
85
+ }
86
+
87
+ function filterOptions(options: AutocompleteOption[], query: string) {
88
+ const normalized = normalizeQuery(query);
89
+ if (!normalized) return options;
90
+
91
+ return options.filter(
92
+ (option) =>
93
+ option.label.toLowerCase().includes(normalized) ||
94
+ option.value.toLowerCase().includes(normalized),
95
+ );
96
+ }
97
+
98
+ function getOptionLabel(options: AutocompleteOption[], value: string) {
99
+ return options.find((option) => option.value === value)?.label ?? value;
100
+ }
101
+
102
+ function parseTagTokens(raw: string): string[] {
103
+ const seen = new Set<string>();
104
+ const tokens: string[] = [];
105
+ for (const part of raw.split(/[\s,]+/)) {
106
+ const token = part.trim();
107
+ if (!token || seen.has(token)) continue;
108
+ seen.add(token);
109
+ tokens.push(token);
110
+ }
111
+ return tokens;
112
+ }
113
+
114
+ export function Autocomplete(props: AutocompleteProps) {
115
+ const {
116
+ options,
117
+ placeholder = "Search…",
118
+ disabled = false,
119
+ className,
120
+ emptyMessage = "No results found",
121
+ id,
122
+ ariaLabel,
123
+ size = "default",
124
+ creatable = false,
125
+ clearable = true,
126
+ } = props;
127
+
128
+ const multiple = props.multiple ?? false;
129
+ const singleValue = multiple
130
+ ? undefined
131
+ : (props as SingleAutocompleteProps).value;
132
+ const multipleValue = multiple
133
+ ? (props as MultipleAutocompleteProps).value
134
+ : undefined;
135
+
136
+ // Memoized so dependent callbacks below keep stable identities across renders.
137
+ const selectedValues = React.useMemo<string[]>(() => {
138
+ if (multiple) return multipleValue ?? [];
139
+ return singleValue ? [singleValue] : [];
140
+ }, [multiple, multipleValue, singleValue]);
141
+
142
+ const onSingleChange = !multiple
143
+ ? (props as SingleAutocompleteProps).onChange
144
+ : undefined;
145
+ const onMultipleChange = multiple
146
+ ? (props as MultipleAutocompleteProps).onChange
147
+ : undefined;
148
+
149
+ const containerRef = React.useRef<HTMLDivElement>(null);
150
+ const dropdownRef = React.useRef<HTMLDivElement>(null);
151
+ const inputRef = React.useRef<HTMLInputElement>(null);
152
+ const listRef = React.useRef<HTMLUListElement>(null);
153
+ const ignoreBlurRef = React.useRef(false);
154
+
155
+ const mounted = useHasMounted();
156
+ const [open, setOpen] = React.useState(false);
157
+ const [position, setPosition] = React.useState<DropdownPosition | null>(null);
158
+ const [query, setQuery] = React.useState("");
159
+ const [highlightedIndex, setHighlightedIndex] = React.useState(0);
160
+
161
+ const filteredOptions = React.useMemo(
162
+ () => filterOptions(options, query),
163
+ [options, query],
164
+ );
165
+
166
+ // Clamp during render instead of via a setState-in-effect: if the option list
167
+ // shrinks below the stored index, fall back to the last valid row.
168
+ const safeHighlightedIndex =
169
+ highlightedIndex >= filteredOptions.length
170
+ ? Math.max(filteredOptions.length - 1, 0)
171
+ : highlightedIndex;
172
+
173
+ const close = React.useCallback(() => {
174
+ setOpen(false);
175
+ setQuery("");
176
+ setHighlightedIndex(0);
177
+ }, []);
178
+
179
+ const openDropdown = React.useCallback(() => {
180
+ if (disabled) return;
181
+ setOpen(true);
182
+ setHighlightedIndex(0);
183
+ }, [disabled]);
184
+
185
+ const commitSingleValue = React.useCallback(
186
+ (value: string) => {
187
+ if (!multiple) {
188
+ onSingleChange?.(value);
189
+ close();
190
+ }
191
+ },
192
+ [close, multiple, onSingleChange],
193
+ );
194
+
195
+ const toggleMultipleValue = React.useCallback(
196
+ (value: string) => {
197
+ if (!multiple || !onMultipleChange) return;
198
+
199
+ onMultipleChange(
200
+ selectedValues.includes(value)
201
+ ? selectedValues.filter((item) => item !== value)
202
+ : [...selectedValues, value],
203
+ );
204
+ setQuery("");
205
+ setHighlightedIndex(0);
206
+ },
207
+ [multiple, onMultipleChange, selectedValues],
208
+ );
209
+
210
+ const commitCustomValues = React.useCallback(
211
+ (raw: string) => {
212
+ if (!creatable) return;
213
+
214
+ if (!multiple) {
215
+ const value = raw.trim();
216
+ if (!value) return;
217
+ commitSingleValue(value);
218
+ return;
219
+ }
220
+
221
+ if (!onMultipleChange) return;
222
+ const tokens = parseTagTokens(raw);
223
+ if (!tokens.length) return;
224
+ const next = [...selectedValues];
225
+ for (const token of tokens) {
226
+ if (!next.includes(token)) next.push(token);
227
+ }
228
+ onMultipleChange(next);
229
+ setQuery("");
230
+ setHighlightedIndex(0);
231
+ },
232
+ [commitSingleValue, creatable, multiple, onMultipleChange, selectedValues],
233
+ );
234
+
235
+ const removeValue = React.useCallback(
236
+ (value: string) => {
237
+ if (!multiple || !onMultipleChange) return;
238
+ onMultipleChange(selectedValues.filter((item) => item !== value));
239
+ window.requestAnimationFrame(() => inputRef.current?.focus());
240
+ },
241
+ [multiple, onMultipleChange, selectedValues],
242
+ );
243
+
244
+ const selectOption = React.useCallback(
245
+ (option: AutocompleteOption) => {
246
+ if (option.disabled) return;
247
+
248
+ ignoreBlurRef.current = true;
249
+ window.setTimeout(() => {
250
+ ignoreBlurRef.current = false;
251
+ }, 0);
252
+
253
+ if (multiple) {
254
+ toggleMultipleValue(option.value);
255
+ window.requestAnimationFrame(() => inputRef.current?.focus());
256
+ return;
257
+ }
258
+
259
+ commitSingleValue(option.value);
260
+ },
261
+ [commitSingleValue, multiple, toggleMultipleValue],
262
+ );
263
+
264
+ const selectHighlighted = React.useCallback(() => {
265
+ const option = filteredOptions[safeHighlightedIndex];
266
+ if (!option) return;
267
+ selectOption(option);
268
+ }, [filteredOptions, safeHighlightedIndex, selectOption]);
269
+
270
+ React.useEffect(() => {
271
+ if (!open) return;
272
+
273
+ function handlePointerDown(event: PointerEvent) {
274
+ const target = event.target as Node;
275
+ // The list is portalled to the body, so containment has to be checked
276
+ // against both halves or picking an option would close the control
277
+ // before the choice registered.
278
+ if (
279
+ containerRef.current?.contains(target) ||
280
+ dropdownRef.current?.contains(target)
281
+ ) {
282
+ return;
283
+ }
284
+ close();
285
+ }
286
+
287
+ document.addEventListener("pointerdown", handlePointerDown);
288
+ return () => document.removeEventListener("pointerdown", handlePointerDown);
289
+ }, [close, open]);
290
+
291
+ /*
292
+ The list is measured against the viewport and rendered in a portal.
293
+
294
+ Anchored inside the control it was clipped by whatever `overflow` ancestor
295
+ happened to be above it — inside a dialog body that is the scroll region,
296
+ so the last field on a form showed one row of a six-hundred-row list. It
297
+ opens downward when there is room, upward when there is not, and shrinks to
298
+ what is left when neither side can take the whole list.
299
+ */
300
+ React.useLayoutEffect(() => {
301
+ // Not cleared on close: the render is gated on `open`, and the layout
302
+ // effect recomputes before the next paint, so a stale value is never seen.
303
+ if (!open) return;
304
+
305
+ const update = () => {
306
+ const anchor = containerRef.current;
307
+ if (!anchor) return;
308
+ const rect = anchor.getBoundingClientRect();
309
+ const below =
310
+ window.innerHeight - rect.bottom - ANCHOR_GAP - VIEWPORT_MARGIN;
311
+ const above = rect.top - ANCHOR_GAP - VIEWPORT_MARGIN;
312
+ const flip =
313
+ below < Math.min(PREFERRED_LIST_HEIGHT, MIN_LIST_HEIGHT) &&
314
+ above > below;
315
+ const room = flip ? above : below;
316
+ const width = Math.max(rect.width, size === "compact" ? 220 : 0);
317
+ const left = Math.max(
318
+ VIEWPORT_MARGIN,
319
+ Math.min(rect.left, window.innerWidth - width - VIEWPORT_MARGIN),
320
+ );
321
+ setPosition({
322
+ left,
323
+ width,
324
+ maxHeight: Math.max(
325
+ MIN_LIST_HEIGHT,
326
+ Math.min(PREFERRED_LIST_HEIGHT, room),
327
+ ),
328
+ ...(flip
329
+ ? { bottom: window.innerHeight - rect.top + ANCHOR_GAP }
330
+ : { top: rect.bottom + ANCHOR_GAP }),
331
+ });
332
+ };
333
+
334
+ update();
335
+ // Capture, so a scroll in any ancestor — the dialog body included — moves
336
+ // the list with its control instead of leaving it behind.
337
+ window.addEventListener("scroll", update, true);
338
+ window.addEventListener("resize", update);
339
+ return () => {
340
+ window.removeEventListener("scroll", update, true);
341
+ window.removeEventListener("resize", update);
342
+ };
343
+ }, [open, size]);
344
+
345
+ React.useEffect(() => {
346
+ if (!open || !listRef.current) return;
347
+
348
+ const highlighted = listRef.current.children[safeHighlightedIndex] as
349
+ HTMLElement | undefined;
350
+
351
+ highlighted?.scrollIntoView({ block: "nearest" });
352
+ }, [safeHighlightedIndex, open]);
353
+
354
+ function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
355
+ if (disabled) return;
356
+
357
+ switch (event.key) {
358
+ case "ArrowDown":
359
+ event.preventDefault();
360
+ if (!open) {
361
+ openDropdown();
362
+ return;
363
+ }
364
+ setHighlightedIndex(
365
+ Math.min(safeHighlightedIndex + 1, filteredOptions.length - 1),
366
+ );
367
+ break;
368
+ case "ArrowUp":
369
+ event.preventDefault();
370
+ if (!open) {
371
+ openDropdown();
372
+ return;
373
+ }
374
+ setHighlightedIndex(Math.max(safeHighlightedIndex - 1, 0));
375
+ break;
376
+ case "Enter":
377
+ event.preventDefault();
378
+ if (creatable && query.trim()) {
379
+ // A single-value control filters its list as you type, so a match
380
+ // is what Enter means there; only an unmatched query is taken as a
381
+ // new value. Tags have no such list to defer to.
382
+ if (multiple || filteredOptions.length === 0) {
383
+ commitCustomValues(query);
384
+ return;
385
+ }
386
+ }
387
+ if (!open) {
388
+ openDropdown();
389
+ return;
390
+ }
391
+ selectHighlighted();
392
+ break;
393
+ case ",":
394
+ if (creatable && multiple && query.trim()) {
395
+ event.preventDefault();
396
+ commitCustomValues(query);
397
+ }
398
+ break;
399
+ case "Escape":
400
+ event.preventDefault();
401
+ close();
402
+ break;
403
+ case "Backspace":
404
+ if (multiple && query.length === 0 && selectedValues.length > 0) {
405
+ event.preventDefault();
406
+ removeValue(selectedValues[selectedValues.length - 1]);
407
+ }
408
+ break;
409
+ case "Tab":
410
+ close();
411
+ break;
412
+ default:
413
+ break;
414
+ }
415
+ }
416
+
417
+ const displayValue =
418
+ !multiple && !open && !query && selectedValues[0]
419
+ ? getOptionLabel(options, selectedValues[0])
420
+ : query;
421
+
422
+ const inputPlaceholder =
423
+ multiple && selectedValues.length > 0 ? "" : placeholder;
424
+
425
+ const createLabel = query.trim();
426
+ const canCreate =
427
+ creatable && Boolean(createLabel) && !selectedValues.includes(createLabel);
428
+
429
+ const listboxId = id ? `${id}-listbox` : undefined;
430
+
431
+ return (
432
+ <div
433
+ ref={containerRef}
434
+ className={cn(
435
+ "relative",
436
+ size === "compact" ? "w-auto min-w-24" : "w-full",
437
+ className,
438
+ )}
439
+ >
440
+ <div
441
+ className={cn(
442
+ "border-border bg-background flex w-full cursor-pointer items-center rounded-md border transition-colors",
443
+ "focus-within:border-ring focus-within:ring-ring/30 focus-within:ring-2",
444
+ size === "compact"
445
+ ? "min-h-0 gap-0.5 border-transparent bg-transparent px-1 py-0.5 text-xs shadow-none"
446
+ : "min-h-9 gap-1.5 px-2 py-1.5 text-sm shadow-sm",
447
+ disabled && "cursor-not-allowed opacity-50",
448
+ open && "border-ring ring-ring/30 ring-2",
449
+ )}
450
+ onPointerDown={(event) => {
451
+ if (disabled) return;
452
+ if ((event.target as HTMLElement).closest("[data-option]")) return;
453
+ if ((event.target as HTMLElement).closest("[data-chip-remove]"))
454
+ return;
455
+
456
+ if (!(event.target as HTMLElement).closest("input")) {
457
+ event.preventDefault();
458
+ inputRef.current?.focus();
459
+ }
460
+
461
+ openDropdown();
462
+ }}
463
+ >
464
+ {size === "compact" ? null : (
465
+ <Search className="text-muted-foreground size-4 shrink-0" />
466
+ )}
467
+
468
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
469
+ {multiple &&
470
+ selectedValues.map((value) => (
471
+ <span
472
+ key={value}
473
+ className="bg-primary/10 text-primary inline-flex max-w-full items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium"
474
+ >
475
+ <span className="truncate">
476
+ {getOptionLabel(options, value)}
477
+ </span>
478
+ <button
479
+ type="button"
480
+ data-chip-remove
481
+ className="text-primary/70 hover:bg-primary/15 hover:text-primary rounded-sm transition"
482
+ onPointerDown={(event) => {
483
+ event.preventDefault();
484
+ event.stopPropagation();
485
+ removeValue(value);
486
+ }}
487
+ aria-label={`Remove ${getOptionLabel(options, value)}`}
488
+ >
489
+ <X className="size-3" />
490
+ </button>
491
+ </span>
492
+ ))}
493
+
494
+ <input
495
+ ref={inputRef}
496
+ id={id}
497
+ type="text"
498
+ role="combobox"
499
+ aria-label={ariaLabel}
500
+ aria-expanded={open}
501
+ aria-controls={listboxId}
502
+ aria-autocomplete="list"
503
+ disabled={disabled}
504
+ value={displayValue}
505
+ placeholder={inputPlaceholder}
506
+ className={cn(
507
+ "placeholder:text-muted-foreground flex-1 bg-transparent outline-none",
508
+ size === "compact" ? "min-w-0" : "min-w-[6rem]",
509
+ )}
510
+ onFocus={openDropdown}
511
+ onBlur={() => {
512
+ if (ignoreBlurRef.current) return;
513
+ close();
514
+ }}
515
+ onChange={(event) => {
516
+ setQuery(event.target.value);
517
+ setHighlightedIndex(0);
518
+ if (!open) setOpen(true);
519
+ }}
520
+ onKeyDown={handleInputKeyDown}
521
+ onPaste={(event) => {
522
+ if (!creatable || !multiple) return;
523
+ const text = event.clipboardData.getData("text");
524
+ if (!/[\s,]/.test(text)) return;
525
+ event.preventDefault();
526
+ commitCustomValues(text);
527
+ }}
528
+ />
529
+ </div>
530
+
531
+ {clearable && !multiple && selectedValues[0] && !disabled && (
532
+ <button
533
+ type="button"
534
+ className="text-muted-foreground hover:bg-muted hover:text-foreground rounded-sm p-0.5 transition"
535
+ onPointerDown={(event) => {
536
+ event.preventDefault();
537
+ event.stopPropagation();
538
+ onSingleChange?.("");
539
+ setQuery("");
540
+ inputRef.current?.focus();
541
+ }}
542
+ aria-label="Clear selection"
543
+ >
544
+ <X className="size-3.5" />
545
+ </button>
546
+ )}
547
+
548
+ <ChevronDown
549
+ className={cn(
550
+ "text-muted-foreground shrink-0 transition-transform",
551
+ size === "compact" ? "size-3" : "size-4",
552
+ open && "rotate-180",
553
+ )}
554
+ />
555
+ </div>
556
+
557
+ {open && mounted && position
558
+ ? createPortal(
559
+ <div
560
+ ref={dropdownRef}
561
+ data-autocomplete-dropdown
562
+ style={{
563
+ position: "fixed",
564
+ left: position.left,
565
+ width: position.width,
566
+ top: position.top,
567
+ bottom: position.bottom,
568
+ // Above the modal shell (z-100) it is usually opened inside.
569
+ zIndex: 200,
570
+ }}
571
+ className="border-border bg-background overflow-hidden rounded-md border shadow-lg"
572
+ >
573
+ <ul
574
+ ref={listRef}
575
+ id={listboxId}
576
+ role="listbox"
577
+ aria-multiselectable={multiple || undefined}
578
+ className="overflow-y-auto p-1"
579
+ style={{ maxHeight: position.maxHeight }}
580
+ >
581
+ {filteredOptions.length === 0 && !canCreate ? (
582
+ <li className="text-muted-foreground px-3 py-6 text-center text-sm">
583
+ {emptyMessage}
584
+ </li>
585
+ ) : (
586
+ <>
587
+ {filteredOptions.map((option, index) => {
588
+ const isSelected = selectedValues.includes(option.value);
589
+ const isHighlighted = index === safeHighlightedIndex;
590
+
591
+ return (
592
+ <li
593
+ key={option.value}
594
+ data-option
595
+ role="option"
596
+ aria-selected={isSelected}
597
+ aria-disabled={option.disabled || undefined}
598
+ className={cn(
599
+ "flex cursor-pointer items-center gap-2 rounded-sm transition-colors",
600
+ size === "compact"
601
+ ? "px-2 py-1 text-xs"
602
+ : "px-2.5 py-2 text-sm",
603
+ isHighlighted && "bg-muted",
604
+ option.disabled && "cursor-not-allowed opacity-50",
605
+ )}
606
+ onMouseEnter={() => setHighlightedIndex(index)}
607
+ onPointerDown={(event) => {
608
+ event.preventDefault();
609
+ event.stopPropagation();
610
+ selectOption(option);
611
+ }}
612
+ >
613
+ {multiple ? (
614
+ <span
615
+ className={cn(
616
+ "border-border flex size-4 shrink-0 items-center justify-center rounded border",
617
+ isSelected &&
618
+ "border-primary bg-primary text-primary-foreground",
619
+ )}
620
+ >
621
+ {isSelected ? <Check className="size-3" /> : null}
622
+ </span>
623
+ ) : (
624
+ <Check
625
+ className={cn(
626
+ "text-primary size-4 shrink-0",
627
+ isSelected ? "opacity-100" : "opacity-0",
628
+ )}
629
+ />
630
+ )}
631
+ <span className="truncate">{option.label}</span>
632
+ </li>
633
+ );
634
+ })}
635
+ {canCreate ? (
636
+ <li
637
+ data-option
638
+ role="option"
639
+ aria-selected={false}
640
+ className="hover:bg-muted flex cursor-pointer items-center gap-2 rounded-sm px-2.5 py-2 text-sm transition-colors"
641
+ onPointerDown={(event) => {
642
+ event.preventDefault();
643
+ event.stopPropagation();
644
+ ignoreBlurRef.current = true;
645
+ window.setTimeout(() => {
646
+ ignoreBlurRef.current = false;
647
+ }, 0);
648
+ commitCustomValues(query);
649
+ window.requestAnimationFrame(() =>
650
+ inputRef.current?.focus(),
651
+ );
652
+ }}
653
+ >
654
+ Add “{createLabel}”
655
+ </li>
656
+ ) : null}
657
+ </>
658
+ )}
659
+ </ul>
660
+ </div>,
661
+ document.body,
662
+ )
663
+ : null}
664
+ </div>
665
+ );
666
+ }