@kahitsan/ksui 0.8.0 → 0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "ksui is a set of shared SolidJS UI components plus the @kserp/host-ui type contract for KahitSan/Hilinga plugins. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with solid-js + @kserp/host-ui externalized to the host runtime.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,615 @@
1
+ // ComboBox — a generic searchable-combobox for picking record(s) from a
2
+ // sibling plugin's list (clients, payees, …). It owns the whole interaction:
3
+ // a debounced portal popup with search/results, an optional inline "create new"
4
+ // row, viewport-aware positioning, keyboard/click-outside dismissal, and
5
+ // graceful degradation when the backing plugin is unreachable.
6
+ //
7
+ // It is DOMAIN-FREE: it knows nothing about clients or payees. The caller wires
8
+ // the data via `search` / `onCreate` and the display via `idOf` / `labelOf` /
9
+ // `secondaryOf` / `icon` / `noun` at each call site (e.g. a payee or client
10
+ // picker), so there is exactly one copy of the popup mechanics.
11
+ //
12
+ // Two modes share that one engine:
13
+ // • single (default) — a button trigger; picking one record fills it. The
14
+ // `selectedName` free-text fallback is shown when nothing is picked (handy
15
+ // when the backing API persists the name as a plain string regardless, so
16
+ // the form still saves if the plugin is absent).
17
+ // • multiple — an inline chips+input row; picking adds a chip, so the value
18
+ // is an ordered T[]. Optional `primaryStar` marks value[0] as the primary
19
+ // (star) with click-to-promote, `invalid` paints the required/empty tone,
20
+ // and `lockedIds` anchors specific chips.
21
+
22
+ import { Portal } from "solid-js/web";
23
+ import { createEffect, createSignal, For, onMount, Show, type JSX } from "solid-js";
24
+ import { highlightMatch } from "@kserp/host-ui";
25
+ import UserPlus from "lucide-solid/icons/user-plus";
26
+ import Search from "lucide-solid/icons/search";
27
+ import Star from "lucide-solid/icons/star";
28
+ import X from "lucide-solid/icons/x";
29
+ import Loader2 from "lucide-solid/icons/loader-2";
30
+ import { createPickerPopup } from "./picker-engine";
31
+
32
+ interface ComboBoxCommonProps<T> {
33
+ /** Search the backing list. Receives the trimmed query (may be empty for the
34
+ * initial list). Should resolve to the matching records, or reject so the
35
+ * popup shows the error/fallback. */
36
+ search: (query: string) => Promise<T[]>;
37
+ /** Optional create-new handler. When provided AND the query has no exact
38
+ * match, a "New <noun> …" row appears; resolving picks/adds the created
39
+ * record. */
40
+ onCreate?: (name: string) => Promise<T>;
41
+
42
+ /** Stable identity for selection matching + result keys. */
43
+ idOf: (item: T) => string | number;
44
+ /** Primary display label. */
45
+ labelOf: (item: T) => string;
46
+ /** Optional muted secondary line under the label in results. */
47
+ secondaryOf?: (item: T) => string | null;
48
+
49
+ /** Leading icon component (lucide-solid), e.g. Store / UserRound. */
50
+ icon: (p: { size?: number; class?: string }) => JSX.Element;
51
+ /** Singular noun for UI copy: "payee", "client". */
52
+ noun: string;
53
+
54
+ placeholder?: string;
55
+ disabled?: boolean;
56
+ /** Prefix for the component's data-testids (default "combo-box"). */
57
+ testIdPrefix?: string;
58
+ }
59
+
60
+ export interface ComboBoxSingleProps<T> extends ComboBoxCommonProps<T> {
61
+ /** Single-select mode (the default). */
62
+ multiple?: false;
63
+ /** Currently selected record, or null. */
64
+ selected: T | null;
65
+ /** Free-text fallback shown in the trigger when `selected` is null. */
66
+ selectedName?: string | null;
67
+ /** Fired with the chosen record (or null when cleared). */
68
+ onChange: (next: T | null) => void;
69
+ /** Open the popup immediately on mount. */
70
+ defaultOpen?: boolean;
71
+ }
72
+
73
+ export interface ComboBoxMultiProps<T> extends ComboBoxCommonProps<T> {
74
+ /** Multi-select mode: an inline chips+input row, value is an ordered list. */
75
+ multiple: true;
76
+ /** The selected records, in order. With `primaryStar`, value[0] is primary. */
77
+ value: T[];
78
+ /** Fired with the new ordered list on add / remove / promote. */
79
+ onChange: (next: T[]) => void;
80
+ /** Treat value[0] as the primary: star it and let other chips promote to it. */
81
+ primaryStar?: boolean;
82
+ /** Paint the required/empty (red) tone — e.g. a mandatory field left empty. */
83
+ invalid?: boolean;
84
+ /** Chip ids that stay anchored: their remove/promote controls are disabled,
85
+ * but the input stays enabled so more records can still be added. */
86
+ lockedIds?: (string | number)[];
87
+ /** Focus the inline input on mount (marks the wrapper with `data-autofocus`
88
+ * for a host modal's focus helper). */
89
+ autoFocusOnMount?: boolean;
90
+ }
91
+
92
+ export type ComboBoxProps<T> = ComboBoxSingleProps<T> | ComboBoxMultiProps<T>;
93
+
94
+ export default function ComboBox<T>(props: ComboBoxProps<T>): JSX.Element {
95
+ // Mode is read once at setup — call sites pick single/multi statically (same
96
+ // convention as the host-ui Modal variant). Narrowing makes each branch see
97
+ // its concrete prop shape.
98
+ if (props.multiple) return MultiComboBox(props);
99
+ return SingleComboBox(props);
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Single-select — button trigger + popup with its own search input.
104
+ // ---------------------------------------------------------------------------
105
+ function SingleComboBox<T>(props: ComboBoxSingleProps<T>): JSX.Element {
106
+ let triggerRef: HTMLButtonElement | undefined;
107
+ let popupRef: HTMLDivElement | undefined;
108
+ let inputRef: HTMLInputElement | undefined;
109
+
110
+ const eng = createPickerPopup<T>({
111
+ search: (q) => props.search(q),
112
+ getAnchor: () => triggerRef,
113
+ getPopup: () => popupRef,
114
+ });
115
+
116
+ const tid = (suffix: string) => `${props.testIdPrefix ?? "combo-box"}-${suffix}`;
117
+
118
+ if (props.defaultOpen) queueMicrotask(() => eng.setOpen(true));
119
+
120
+ // Focus the popup's search input whenever it opens.
121
+ createEffect(() => {
122
+ if (eng.open()) queueMicrotask(() => inputRef?.focus());
123
+ });
124
+
125
+ const hasExactMatch = () => {
126
+ const q = eng.trimmedQuery().toLowerCase();
127
+ if (!q) return true;
128
+ return eng.results().some((r) => props.labelOf(r).trim().toLowerCase() === q);
129
+ };
130
+ const showCreateOption = () =>
131
+ !!props.onCreate && eng.trimmedQuery().length > 0 && !hasExactMatch() && !eng.loading();
132
+
133
+ const select = (item: T) => {
134
+ props.onChange(item);
135
+ eng.close();
136
+ };
137
+
138
+ const createAndSelect = async () => {
139
+ const name = eng.trimmedQuery();
140
+ if (!name || eng.creating() || !props.onCreate) return;
141
+ eng.setCreating(true);
142
+ eng.setError(null);
143
+ try {
144
+ const created = await props.onCreate(name);
145
+ props.onChange(created);
146
+ eng.close();
147
+ } catch (e) {
148
+ eng.setError(e instanceof Error ? e.message : `Failed to create ${props.noun}`);
149
+ } finally {
150
+ eng.setCreating(false);
151
+ }
152
+ };
153
+
154
+ const clear = (e: MouseEvent) => {
155
+ e.stopPropagation();
156
+ props.onChange(null);
157
+ };
158
+
159
+ const triggerLabel = () => {
160
+ if (props.selected) return props.labelOf(props.selected);
161
+ if (props.selectedName && props.selectedName.trim()) return props.selectedName.trim();
162
+ return null;
163
+ };
164
+
165
+ const placeholder = () => props.placeholder ?? `Tap to pick a ${props.noun}`;
166
+ const Icon = props.icon;
167
+
168
+ return (
169
+ <>
170
+ <button
171
+ ref={triggerRef}
172
+ type="button"
173
+ data-testid={tid("trigger")}
174
+ disabled={props.disabled}
175
+ onClick={() => !props.disabled && eng.setOpen((o) => !o)}
176
+ class="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg bg-zinc-800/30 border border-zinc-700/50 hover:border-amber-500/40 hover:bg-amber-500/5 transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60"
177
+ aria-haspopup="listbox"
178
+ aria-expanded={eng.open()}
179
+ >
180
+ <Icon size={16} class="shrink-0 text-zinc-400" />
181
+ <Show when={triggerLabel()} fallback={<span class="text-zinc-500 italic">{placeholder()}</span>}>
182
+ <span class="flex-1 min-w-0">
183
+ <span class="block truncate text-zinc-100 font-medium">{triggerLabel()}</span>
184
+ </span>
185
+ <button
186
+ type="button"
187
+ data-testid={tid("clear")}
188
+ onClick={clear}
189
+ class="shrink-0 p-1 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
190
+ title="Clear"
191
+ aria-label={`Clear ${props.noun}`}
192
+ >
193
+ <X size={14} />
194
+ </button>
195
+ </Show>
196
+ </button>
197
+
198
+ <Show when={eng.open()}>
199
+ <Portal>
200
+ <div
201
+ ref={popupRef}
202
+ data-testid={tid("popup")}
203
+ class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
204
+ style={eng.popupStyle()}
205
+ >
206
+ <div class="px-2 py-2 border-b border-zinc-800 flex items-center gap-2">
207
+ <Search size={14} class="text-zinc-500 shrink-0 ml-1" />
208
+ <input
209
+ ref={inputRef}
210
+ type="text"
211
+ data-testid={tid("input")}
212
+ role="combobox"
213
+ aria-expanded={eng.open()}
214
+ aria-controls={`${tid("listbox")}`}
215
+ aria-autocomplete="list"
216
+ aria-label={`Search ${props.noun}s`}
217
+ value={eng.query()}
218
+ onInput={(e) => eng.setQuery(e.currentTarget.value)}
219
+ placeholder={props.onCreate ? `Search or add a new ${props.noun}…` : `Search ${props.noun}s…`}
220
+ class="w-full px-1 py-1 text-sm bg-transparent text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
221
+ />
222
+ <Show when={eng.loading()}>
223
+ <Loader2 size={14} class="animate-spin text-zinc-500 mr-1 shrink-0" />
224
+ </Show>
225
+ </div>
226
+ <div class="flex-1 overflow-y-auto">
227
+ <Show when={eng.error()}>
228
+ <div role="status" class="px-3 py-2 text-xs text-red-400">
229
+ {eng.error()}
230
+ </div>
231
+ </Show>
232
+ <Show when={!eng.loading() && eng.results().length === 0 && !showCreateOption() && !eng.error()}>
233
+ <div role="status" class="px-3 py-4 text-xs text-zinc-500 text-center">
234
+ {eng.trimmedQuery() ? "No matches" : "Start typing or pick from your list…"}
235
+ </div>
236
+ </Show>
237
+ <Show when={eng.results().length > 0}>
238
+ <ul
239
+ id={tid("listbox")}
240
+ data-testid={tid("listbox")}
241
+ role="listbox"
242
+ aria-label={`${props.noun} search results`}
243
+ class="m-0 p-0 list-none"
244
+ >
245
+ <For each={eng.results()}>
246
+ {(item) => {
247
+ const secondary = props.secondaryOf?.(item) ?? null;
248
+ const isSel = () =>
249
+ props.selected != null && props.idOf(props.selected) === props.idOf(item);
250
+ return (
251
+ <li role="option" aria-selected={isSel()}>
252
+ <button
253
+ type="button"
254
+ data-testid={`${tid("result")}-${props.idOf(item)}`}
255
+ onClick={() => select(item)}
256
+ class="w-full text-left px-3 py-2 hover:bg-amber-500/10 transition-colors flex items-start gap-2 cursor-pointer"
257
+ >
258
+ <Icon size={14} class="text-zinc-500 shrink-0 mt-0.5" />
259
+ <span class="flex-1 min-w-0">
260
+ <span class="block text-sm text-zinc-100 truncate">
261
+ {highlightMatch(props.labelOf(item), eng.debouncedQuery().trim())}
262
+ </span>
263
+ <Show when={secondary}>
264
+ <span class="block text-[11px] text-zinc-500 truncate">
265
+ {highlightMatch(secondary!, eng.debouncedQuery().trim())}
266
+ </span>
267
+ </Show>
268
+ </span>
269
+ <Show when={isSel()}>
270
+ <span class="text-amber-400 text-xs shrink-0 mt-0.5">✓</span>
271
+ </Show>
272
+ </button>
273
+ </li>
274
+ );
275
+ }}
276
+ </For>
277
+ </ul>
278
+ </Show>
279
+ <Show when={showCreateOption()}>
280
+ <div class="border-t border-zinc-800">
281
+ <button
282
+ type="button"
283
+ data-testid={tid("create")}
284
+ onClick={createAndSelect}
285
+ disabled={eng.creating()}
286
+ class="w-full text-left px-3 py-2.5 hover:bg-emerald-500/10 transition-colors flex items-center gap-2 cursor-pointer disabled:cursor-wait disabled:opacity-60"
287
+ >
288
+ <Show
289
+ when={!eng.creating()}
290
+ fallback={<Loader2 size={14} class="animate-spin text-emerald-400 shrink-0" />}
291
+ >
292
+ <UserPlus size={14} class="text-emerald-400 shrink-0" />
293
+ </Show>
294
+ <span class="text-sm text-emerald-300">
295
+ New {props.noun} "<span class="font-medium">{eng.trimmedQuery()}</span>"
296
+ </span>
297
+ </button>
298
+ </div>
299
+ </Show>
300
+ </div>
301
+ </div>
302
+ </Portal>
303
+ </Show>
304
+ </>
305
+ );
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Multi-select — inline chips + input row; popup is results only.
310
+ // ---------------------------------------------------------------------------
311
+ type DisplayOption<T> = { create: true; name: string } | { create: false; item: T };
312
+
313
+ function MultiComboBox<T>(props: ComboBoxMultiProps<T>): JSX.Element {
314
+ let wrapperRef: HTMLDivElement | undefined;
315
+ let popupRef: HTMLDivElement | undefined;
316
+ let inputRef: HTMLInputElement | undefined;
317
+
318
+ const [focusedIdx, setFocusedIdx] = createSignal(0);
319
+
320
+ const eng = createPickerPopup<T>({
321
+ search: (q) => props.search(q),
322
+ getAnchor: () => wrapperRef,
323
+ getPopup: () => popupRef,
324
+ clearOnDismiss: false,
325
+ onResults: () => setFocusedIdx(0),
326
+ });
327
+
328
+ const tid = (suffix: string) => `${props.testIdPrefix ?? "combo-box"}-${suffix}`;
329
+
330
+ const idOf = (item: T) => props.idOf(item);
331
+ const isLocked = (item: T) => (props.lockedIds ?? []).includes(idOf(item));
332
+ const selectedIds = () => new Set(props.value.map(idOf));
333
+ const filteredResults = () => eng.results().filter((r) => !selectedIds().has(idOf(r)));
334
+
335
+ const hasExactMatch = () => {
336
+ const q = eng.trimmedQuery().toLowerCase();
337
+ if (!q) return true;
338
+ return [...eng.results(), ...props.value].some((r) => props.labelOf(r).trim().toLowerCase() === q);
339
+ };
340
+ const showCreateOption = () =>
341
+ !!props.onCreate && eng.trimmedQuery().length > 0 && !hasExactMatch() && !eng.loading();
342
+
343
+ const displayOptions = (): DisplayOption<T>[] => {
344
+ const list: DisplayOption<T>[] = [];
345
+ if (showCreateOption()) list.push({ create: true, name: eng.trimmedQuery() });
346
+ for (const r of filteredResults()) list.push({ create: false, item: r });
347
+ return list;
348
+ };
349
+
350
+ const resetInput = () => {
351
+ eng.setQuery("");
352
+ if (inputRef) inputRef.value = "";
353
+ queueMicrotask(() => inputRef?.focus());
354
+ };
355
+
356
+ const addToPool = (item: T) => {
357
+ if (props.value.some((x) => idOf(x) === idOf(item))) return;
358
+ props.onChange([...props.value, item]);
359
+ resetInput();
360
+ };
361
+
362
+ const removeFromPool = (item: T) => {
363
+ if (isLocked(item)) return;
364
+ props.onChange(props.value.filter((c) => idOf(c) !== idOf(item)));
365
+ };
366
+
367
+ const promoteToPrimary = (item: T) => {
368
+ if (!props.primaryStar || isLocked(item)) return;
369
+ const others = props.value.filter((c) => idOf(c) !== idOf(item));
370
+ props.onChange([item, ...others]);
371
+ };
372
+
373
+ const createAndAdd = async () => {
374
+ const name = eng.trimmedQuery();
375
+ if (!name || eng.creating() || !props.onCreate) return;
376
+ eng.setCreating(true);
377
+ eng.setError(null);
378
+ try {
379
+ const created = await props.onCreate(name);
380
+ addToPool(created);
381
+ } catch (e) {
382
+ eng.setError(e instanceof Error ? e.message : `Failed to create ${props.noun}`);
383
+ } finally {
384
+ eng.setCreating(false);
385
+ }
386
+ };
387
+
388
+ const selectOption = (opt: DisplayOption<T>) => {
389
+ if (opt.create) {
390
+ void createAndAdd();
391
+ return;
392
+ }
393
+ addToPool(opt.item);
394
+ };
395
+
396
+ const onKeyDown = (e: KeyboardEvent) => {
397
+ if (props.disabled) return;
398
+ if (e.key === "Escape") {
399
+ eng.setOpen(false);
400
+ return;
401
+ }
402
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
403
+ e.preventDefault();
404
+ if (!eng.open()) eng.setOpen(true);
405
+ const max = displayOptions().length - 1;
406
+ setFocusedIdx((i) => (e.key === "ArrowDown" ? (i >= max ? 0 : i + 1) : i <= 0 ? max : i - 1));
407
+ return;
408
+ }
409
+ if (e.key === "Enter") {
410
+ e.preventDefault();
411
+ const opt = displayOptions()[focusedIdx()];
412
+ if (opt) selectOption(opt);
413
+ return;
414
+ }
415
+ if (e.key === "Backspace" && eng.query() === "" && props.value.length > 0) {
416
+ removeFromPool(props.value[props.value.length - 1]!);
417
+ }
418
+ };
419
+
420
+ onMount(() => {
421
+ if (props.autoFocusOnMount) queueMicrotask(() => inputRef?.focus());
422
+ });
423
+
424
+ const Icon = props.icon;
425
+ const isEmpty = () => props.value.length === 0;
426
+
427
+ const wrapperTone = () => {
428
+ if (props.disabled) return "opacity-60 cursor-not-allowed bg-zinc-800/30 border-zinc-700/50";
429
+ if (props.invalid)
430
+ return "bg-red-500/5 border-red-500/40 hover:bg-red-500/10 hover:border-red-500/60";
431
+ return "bg-zinc-800/30 border-zinc-700/50 hover:border-amber-500/40 focus-within:border-amber-500/60";
432
+ };
433
+
434
+ return (
435
+ <div
436
+ ref={wrapperRef}
437
+ class="relative w-full"
438
+ {...(props.autoFocusOnMount ? { "data-autofocus": true } : {})}
439
+ >
440
+ {/* The wrapper is a styled click target that forwards focus to the inner
441
+ input (which owns the keyboard surface). role="presentation" keeps the
442
+ a11y tree clean about the bare-div onClick. */}
443
+ <div
444
+ data-testid={tid("control")}
445
+ role="presentation"
446
+ class={`w-full flex flex-wrap items-center gap-1.5 px-2 py-1.5 rounded-lg border transition-colors text-sm cursor-text ${wrapperTone()}`}
447
+ onClick={() => {
448
+ if (props.disabled) return;
449
+ inputRef?.focus();
450
+ if (!eng.open()) eng.setOpen(true);
451
+ }}
452
+ >
453
+ <For each={props.value}>
454
+ {(item, i) => {
455
+ const locked = () => isLocked(item);
456
+ const primary = () => props.primaryStar === true && i() === 0;
457
+ return (
458
+ <span
459
+ data-testid={`${tid("chip")}-${idOf(item)}`}
460
+ class={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${
461
+ primary()
462
+ ? "border-amber-500/50 bg-amber-500/10 text-amber-200"
463
+ : "border-zinc-700 bg-zinc-800/40 text-zinc-200"
464
+ }`}
465
+ >
466
+ <Show when={primary()}>
467
+ <Star size={10} class="text-amber-400 shrink-0" aria-label="Primary" />
468
+ </Show>
469
+ <Show
470
+ when={props.primaryStar}
471
+ fallback={<span class="truncate max-w-[140px]">{props.labelOf(item)}</span>}
472
+ >
473
+ <button
474
+ type="button"
475
+ data-testid={`${tid("chip")}-${idOf(item)}-promote`}
476
+ onClick={(e) => {
477
+ e.stopPropagation();
478
+ if (props.disabled || locked() || i() === 0) return;
479
+ promoteToPrimary(item);
480
+ }}
481
+ disabled={props.disabled || locked() || i() === 0}
482
+ title={
483
+ props.disabled || locked()
484
+ ? "Anchored — can't be re-arranged."
485
+ : i() === 0
486
+ ? "Primary"
487
+ : "Promote to primary"
488
+ }
489
+ class="truncate max-w-[140px] text-left cursor-pointer disabled:cursor-default"
490
+ >
491
+ {props.labelOf(item)}
492
+ </button>
493
+ </Show>
494
+ <Show when={!props.disabled && !locked()}>
495
+ <button
496
+ type="button"
497
+ data-testid={`${tid("chip")}-${idOf(item)}-remove`}
498
+ onClick={(e) => {
499
+ e.stopPropagation();
500
+ removeFromPool(item);
501
+ }}
502
+ aria-label={`Remove ${props.labelOf(item)}`}
503
+ class="shrink-0 rounded-full p-0.5 text-zinc-400 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
504
+ >
505
+ <X size={11} />
506
+ </button>
507
+ </Show>
508
+ </span>
509
+ );
510
+ }}
511
+ </For>
512
+ <Show when={isEmpty()}>
513
+ <Icon size={14} class={`shrink-0 ml-1 ${props.invalid ? "text-red-300" : "text-zinc-400"}`} />
514
+ </Show>
515
+ <input
516
+ ref={inputRef}
517
+ type="text"
518
+ aria-label={`Pick a ${props.noun}`}
519
+ data-testid={tid("input")}
520
+ disabled={props.disabled}
521
+ value={eng.query()}
522
+ placeholder={isEmpty() ? props.placeholder ?? `Walk-in — type to pick a ${props.noun}` : ""}
523
+ onInput={(e) => {
524
+ eng.setQuery(e.currentTarget.value);
525
+ if (!eng.open()) eng.setOpen(true);
526
+ }}
527
+ onKeyDown={onKeyDown}
528
+ class={`flex-1 min-w-[120px] bg-transparent outline-none text-sm text-zinc-100 ${
529
+ props.invalid && isEmpty() ? "placeholder-red-300/80 italic" : "placeholder-zinc-500"
530
+ }`}
531
+ />
532
+ </div>
533
+
534
+ <Show when={eng.open() && !props.disabled}>
535
+ <Portal>
536
+ <div
537
+ ref={popupRef}
538
+ data-testid={tid("popup")}
539
+ role="listbox"
540
+ aria-label={`${props.noun} search results`}
541
+ class="z-[110] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
542
+ style={eng.popupStyle()}
543
+ >
544
+ <Show when={eng.error()}>
545
+ <div role="status" class="px-3 py-2 text-xs text-red-400 border-b border-zinc-800">
546
+ {eng.error()}
547
+ </div>
548
+ </Show>
549
+ <div class="flex-1 overflow-y-auto">
550
+ <Show when={eng.loading() && eng.results().length === 0}>
551
+ <div role="status" class="px-3 py-3 text-xs text-zinc-500 italic">Searching…</div>
552
+ </Show>
553
+ <Show when={displayOptions().length === 0 && !eng.loading()}>
554
+ <div role="status" class="px-3 py-3 text-xs text-zinc-500 italic">
555
+ {eng.trimmedQuery() ? `No matching ${props.noun}s.` : `Start typing to find a ${props.noun}…`}
556
+ </div>
557
+ </Show>
558
+ <For each={displayOptions()}>
559
+ {(opt, i) => {
560
+ const isFocused = () => focusedIdx() === i();
561
+ const secondary = () => (opt.create ? null : props.secondaryOf?.(opt.item) ?? null);
562
+ return (
563
+ <button
564
+ type="button"
565
+ role="option"
566
+ aria-selected={isFocused()}
567
+ data-testid={opt.create ? tid("create") : `${tid("result")}-${idOf(opt.item)}`}
568
+ onMouseEnter={() => setFocusedIdx(i())}
569
+ onClick={() => selectOption(opt)}
570
+ disabled={opt.create && eng.creating()}
571
+ class={`w-full flex items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
572
+ isFocused() ? "bg-amber-500/15 text-amber-200" : "text-zinc-100 hover:bg-zinc-800"
573
+ } ${opt.create ? "border-t border-zinc-800" : ""}`}
574
+ >
575
+ <Show
576
+ when={opt.create}
577
+ fallback={<Icon size={14} class="text-zinc-500 shrink-0 mt-0.5" />}
578
+ >
579
+ <Show
580
+ when={!eng.creating()}
581
+ fallback={<Loader2 size={14} class="animate-spin text-emerald-400 shrink-0 mt-0.5" />}
582
+ >
583
+ <UserPlus size={14} class="text-emerald-400 shrink-0 mt-0.5" />
584
+ </Show>
585
+ </Show>
586
+ <Show
587
+ when={opt.create}
588
+ fallback={
589
+ <span class="flex-1 min-w-0">
590
+ <span class="block truncate font-medium">
591
+ {highlightMatch(props.labelOf((opt as { create: false; item: T }).item), eng.debouncedQuery().trim())}
592
+ </span>
593
+ <Show when={secondary()}>
594
+ <span class="block truncate text-[11px] text-zinc-500">
595
+ {highlightMatch(secondary()!, eng.debouncedQuery().trim())}
596
+ </span>
597
+ </Show>
598
+ </span>
599
+ }
600
+ >
601
+ <span class="flex-1 text-emerald-300">
602
+ New {props.noun} "<span class="font-medium">{(opt as { create: true; name: string }).name}</span>"
603
+ </span>
604
+ </Show>
605
+ </button>
606
+ );
607
+ }}
608
+ </For>
609
+ </div>
610
+ </div>
611
+ </Portal>
612
+ </Show>
613
+ </div>
614
+ );
615
+ }