@kahitsan/ksui 0.9.0 → 0.10.1

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