@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.
@@ -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
+ }
@@ -0,0 +1,25 @@
1
+ // Domain option shapes shared across the KahitSan plugins.
2
+ //
3
+ // These previously lived inside the ClientPicker / PayeePicker preset
4
+ // components. Those presets were removed in favour of using the generic
5
+ // ComboBox directly (consumers wire their own search/onCreate), but the
6
+ // option TYPES are still imported widely (transactions, counter, payees), so
7
+ // they keep a stable home here decoupled from any component.
8
+
9
+ export type PayeeKind = "vendor" | "customer" | "both";
10
+
11
+ export interface PayeeOption {
12
+ id: number;
13
+ name: string;
14
+ kind: PayeeKind;
15
+ default_subcategory?: string | null;
16
+ notes?: string | null;
17
+ is_active?: boolean;
18
+ }
19
+
20
+ export interface ClientOption {
21
+ id: number;
22
+ name_raw: string;
23
+ email?: string | null;
24
+ phone?: string | null;
25
+ }
package/src/index.ts CHANGED
@@ -95,16 +95,20 @@ export type { MentionTextareaProps } from "./components/composite/MentionTextare
95
95
  export { default as MarkdownNotes } from "./components/composite/MarkdownNotes";
96
96
  export type { MarkdownNotesProps } from "./components/composite/MarkdownNotes";
97
97
 
98
- // The generic searchable-combobox engine. ClientPicker / PayeePicker are thin
99
- // presets over it; new pickers can use EntityPicker directly.
100
- export { default as EntityPicker } from "./components/composite/EntityPicker";
101
- export type { EntityPickerProps } from "./components/composite/EntityPicker";
102
-
103
- export { default as ClientPicker } from "./components/composite/ClientPicker";
104
- export type { ClientOption } from "./components/composite/ClientPicker";
105
-
106
- export { default as PayeePicker } from "./components/composite/PayeePicker";
107
- export type { PayeeOption, PayeeKind } from "./components/composite/PayeePicker";
98
+ // The generic searchable-combobox engine the ONE picker the library ships.
99
+ // Build a payee / client / anything picker by supplying search + onCreate +
100
+ // idOf/labelOf/secondaryOf/icon/noun. (The former ClientPicker / PayeePicker
101
+ // presets were removed; consumers wire the endpoint themselves.)
102
+ export { default as ComboBox } from "./components/composite/ComboBox";
103
+ export type {
104
+ ComboBoxProps,
105
+ ComboBoxSingleProps,
106
+ ComboBoxMultiProps,
107
+ } from "./components/composite/ComboBox";
108
+
109
+ // Shared domain option shapes for the common pickers, decoupled from any
110
+ // component (still imported across transactions / counter / payees).
111
+ export type { ClientOption, PayeeOption, PayeeKind } from "./components/composite/picker-types";
108
112
 
109
113
  export { default as VoucherPicker, calculateDiscount } from "./components/composite/VoucherPicker";
110
114
  export type { VoucherOption } from "./components/composite/VoucherPicker";
@@ -1,86 +0,0 @@
1
- // ClientPicker — the client preset of EntityPicker. A searchable combobox for a
2
- // "billed-to" / client field, backed by the sibling clients plugin's
3
- // /api/clients. All the popup mechanics live in EntityPicker; this file only
4
- // wires the clients endpoint, option shape (name_raw + email/phone), icon, and
5
- // the post-create callback.
6
- //
7
- // Public API is unchanged from the standalone version, so existing callers keep
8
- // working. Degrades gracefully when the clients plugin isn't deployed.
9
-
10
- import { type JSX } from "solid-js";
11
- import UserRound from "lucide-solid/icons/user-round";
12
- import EntityPicker from "./EntityPicker";
13
-
14
- export interface ClientOption {
15
- id: number;
16
- name_raw: string;
17
- email?: string | null;
18
- phone?: string | null;
19
- }
20
-
21
- interface ClientPickerProps {
22
- selected: ClientOption | null;
23
- onChange: (next: ClientOption | null) => void;
24
- onCreate?: (created: ClientOption) => void;
25
- disabled?: boolean;
26
- defaultOpen?: boolean;
27
- }
28
-
29
- async function searchClients(query: string): Promise<ClientOption[]> {
30
- const params = new URLSearchParams({ status: "active", limit: "10" });
31
- if (query) params.set("search", query);
32
- const r = await fetch(`/api/clients?${params.toString()}`, { credentials: "include" });
33
- if (!r.ok) {
34
- throw new Error(
35
- r.status === 403
36
- ? "Permission denied"
37
- : r.status === 404
38
- ? "Clients module isn't available — type a name instead"
39
- : "Failed to load",
40
- );
41
- }
42
- const json = await r.json();
43
- return (json.data || []) as ClientOption[];
44
- }
45
-
46
- async function createClient(name: string): Promise<ClientOption> {
47
- const res = await fetch("/api/clients", {
48
- method: "POST",
49
- credentials: "include",
50
- headers: { "Content-Type": "application/json" },
51
- body: JSON.stringify({ name_raw: name }),
52
- });
53
- if (!res.ok && res.status !== 200) {
54
- const body = await res.json().catch(() => ({ error: "Failed to create client" }));
55
- throw new Error(body.error || "Failed to create client");
56
- }
57
- return (await res.json()) as ClientOption;
58
- }
59
-
60
- function clientSecondary(c: ClientOption): string | null {
61
- return [c.email, c.phone].filter(Boolean).join(" · ") || null;
62
- }
63
-
64
- export default function ClientPicker(props: ClientPickerProps): JSX.Element {
65
- return (
66
- <EntityPicker<ClientOption>
67
- selected={props.selected}
68
- onChange={props.onChange}
69
- search={searchClients}
70
- onCreate={async (name) => {
71
- const created = await createClient(name);
72
- props.onCreate?.(created);
73
- return created;
74
- }}
75
- idOf={(c) => c.id}
76
- labelOf={(c) => c.name_raw}
77
- secondaryOf={clientSecondary}
78
- icon={UserRound}
79
- noun="client"
80
- placeholder="Walk-in"
81
- disabled={props.disabled}
82
- defaultOpen={props.defaultOpen}
83
- testIdPrefix="client-picker"
84
- />
85
- );
86
- }
@@ -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`. Thin presets (ClientPicker, PayeePicker) pass
10
- // those in, 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
- }