@kahitsan/ksui 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/composite/ComboBox.tsx +615 -0
- package/src/components/composite/picker-engine.ts +158 -0
- package/src/components/composite/picker-types.ts +25 -0
- package/src/index.ts +14 -10
- package/src/components/composite/ClientPicker.tsx +0 -86
- package/src/components/composite/EntityPicker.tsx +0 -363
- package/src/components/composite/PayeePicker.tsx +0 -91
|
@@ -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
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
export {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
}
|