@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.
|
|
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
|
-
//
|
|
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
|
|
103
|
-
export type {
|
|
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
|
-
}
|