@kahitsan/ksui 0.3.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/LICENSE +21 -0
- package/README.md +45 -0
- package/host-ui.d.ts +145 -0
- package/package.json +44 -0
- package/src/components/AccountAvatar.tsx +169 -0
- package/src/components/AddAttachmentTile.tsx +96 -0
- package/src/components/CameraCapture.tsx +144 -0
- package/src/components/ClientPicker.tsx +358 -0
- package/src/components/ExistingAttachmentTile.tsx +94 -0
- package/src/components/MarkdownNotes.tsx +466 -0
- package/src/components/MentionTextarea.tsx +490 -0
- package/src/components/PaymentAccountPicker.tsx +312 -0
- package/src/components/VoucherPicker.tsx +369 -0
- package/src/index.ts +62 -0
- package/src/lib/account-icons.ts +106 -0
- package/src/lib/account-logo-url.ts +12 -0
- package/src/lib/accounts-index.tsx +105 -0
- package/src/lib/attachments.ts +20 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// Source: KahitSan/kserp src/components/MentionTextarea.tsx (vendored into the plugin remote).
|
|
2
|
+
//
|
|
3
|
+
// contenteditable rich-text input with an @-trigger client autocomplete that
|
|
4
|
+
// renders selected mentions as chips. Canonical value is the @[Name](client:ID)
|
|
5
|
+
// token string. Cross-plugin: searches the SIBLING clients plugin at
|
|
6
|
+
// /api/clients; when it 404s the popup just shows no results and the field
|
|
7
|
+
// still works as a plain notes editor.
|
|
8
|
+
|
|
9
|
+
import { Portal } from "solid-js/web";
|
|
10
|
+
import { createEffect, createSignal, createUniqueId, For, onCleanup, onMount, Show, type JSX } from "solid-js";
|
|
11
|
+
import UserRound from "lucide-solid/icons/user-round";
|
|
12
|
+
import Loader2 from "lucide-solid/icons/loader-2";
|
|
13
|
+
|
|
14
|
+
const POPUP_MAX_HEIGHT = 240;
|
|
15
|
+
const POPUP_MIN_WIDTH = 280;
|
|
16
|
+
const SEARCH_DEBOUNCE_MS = 150;
|
|
17
|
+
const FETCH_LIMIT = 8;
|
|
18
|
+
|
|
19
|
+
interface ClientHit {
|
|
20
|
+
id: number;
|
|
21
|
+
name_raw: string;
|
|
22
|
+
email?: string | null;
|
|
23
|
+
phone?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MentionTextareaProps {
|
|
27
|
+
value: string;
|
|
28
|
+
setValue: (next: string) => void;
|
|
29
|
+
placeholder?: string;
|
|
30
|
+
rows?: number;
|
|
31
|
+
class?: string;
|
|
32
|
+
ariaLabel?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
onBlur?: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CHIP_BASE_CLASSES =
|
|
38
|
+
"inline-flex items-baseline align-baseline rounded bg-amber-500/15 text-amber-300 px-1.5 py-px text-[0.9em] mx-px";
|
|
39
|
+
const CHIP_UNRESOLVED_CLASSES =
|
|
40
|
+
"inline-flex items-baseline align-baseline rounded bg-zinc-700/40 text-zinc-400 px-1.5 py-px text-[0.9em] mx-px";
|
|
41
|
+
|
|
42
|
+
function buildMentionChip(name: string, idStr: string): HTMLSpanElement {
|
|
43
|
+
const span = document.createElement("span");
|
|
44
|
+
span.setAttribute("data-mention-id", idStr);
|
|
45
|
+
span.setAttribute("data-mention-name", name);
|
|
46
|
+
span.setAttribute("contenteditable", "false");
|
|
47
|
+
span.className = idStr ? CHIP_BASE_CLASSES : CHIP_UNRESOLVED_CLASSES;
|
|
48
|
+
span.textContent = `@${name}`;
|
|
49
|
+
return span;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hydrateInto(root: HTMLElement, value: string): void {
|
|
53
|
+
while (root.firstChild) root.removeChild(root.firstChild);
|
|
54
|
+
const tokenRe = /@\[([^\]\n]+)\](?:\(client:(\d+)\))?/g;
|
|
55
|
+
let last = 0;
|
|
56
|
+
let m: RegExpExecArray | null;
|
|
57
|
+
while ((m = tokenRe.exec(value)) !== null) {
|
|
58
|
+
appendTextWithBreaks(root, value.slice(last, m.index));
|
|
59
|
+
root.appendChild(buildMentionChip(m[1], m[2] ?? ""));
|
|
60
|
+
last = m.index + m[0].length;
|
|
61
|
+
}
|
|
62
|
+
appendTextWithBreaks(root, value.slice(last));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function appendTextWithBreaks(root: HTMLElement, text: string): void {
|
|
66
|
+
if (!text) return;
|
|
67
|
+
const lines = text.split("\n");
|
|
68
|
+
for (let i = 0; i < lines.length; i++) {
|
|
69
|
+
if (i > 0) root.appendChild(document.createElement("br"));
|
|
70
|
+
if (lines[i]) root.appendChild(document.createTextNode(lines[i]));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function serialize(root: HTMLElement): string {
|
|
75
|
+
let out = "";
|
|
76
|
+
const walk = (node: Node) => {
|
|
77
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
78
|
+
out += node.textContent ?? "";
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
82
|
+
const el = node as HTMLElement;
|
|
83
|
+
if (el.tagName === "BR") {
|
|
84
|
+
out += "\n";
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const mid = el.getAttribute("data-mention-id");
|
|
88
|
+
const mname = el.getAttribute("data-mention-name");
|
|
89
|
+
if (mid != null && mname != null) {
|
|
90
|
+
out += mid === "" ? `@[${mname}]` : `@[${mname}](client:${mid})`;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (el.tagName === "DIV" && out && !out.endsWith("\n")) out += "\n";
|
|
94
|
+
for (const c of Array.from(el.childNodes)) walk(c);
|
|
95
|
+
};
|
|
96
|
+
for (const c of Array.from(root.childNodes)) walk(c);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function findTrigger(
|
|
101
|
+
root: HTMLElement,
|
|
102
|
+
): { anchor: { node: Text; offset: number }; query: string } | null {
|
|
103
|
+
const sel = window.getSelection();
|
|
104
|
+
if (!sel || sel.rangeCount === 0) return null;
|
|
105
|
+
const r = sel.getRangeAt(0);
|
|
106
|
+
if (!r.collapsed) return null;
|
|
107
|
+
if (!root.contains(r.startContainer)) return null;
|
|
108
|
+
if (r.startContainer.nodeType !== Node.TEXT_NODE) return null;
|
|
109
|
+
const node = r.startContainer as Text;
|
|
110
|
+
const offset = r.startOffset;
|
|
111
|
+
const text = node.data;
|
|
112
|
+
for (let i = offset - 1; i >= 0; i--) {
|
|
113
|
+
const ch = text[i];
|
|
114
|
+
if (ch === "@") {
|
|
115
|
+
const before = i === 0 ? "" : text[i - 1];
|
|
116
|
+
if (before !== "" && !/\s/.test(before)) return null;
|
|
117
|
+
return { anchor: { node, offset: i }, query: text.slice(i + 1, offset) };
|
|
118
|
+
}
|
|
119
|
+
if (/[\s\])[]/.test(ch)) return null;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default function MentionTextarea(props: MentionTextareaProps): JSX.Element {
|
|
125
|
+
const listboxId = createUniqueId();
|
|
126
|
+
const optionId = (clientId: number) => `${listboxId}-option-${clientId}`;
|
|
127
|
+
const [open, setOpen] = createSignal(false);
|
|
128
|
+
const [query, setQuery] = createSignal("");
|
|
129
|
+
const [debouncedQuery, setDebouncedQuery] = createSignal("");
|
|
130
|
+
const [results, setResults] = createSignal<ClientHit[]>([]);
|
|
131
|
+
const [activeIdx, setActiveIdx] = createSignal(0);
|
|
132
|
+
const [loading, setLoading] = createSignal(false);
|
|
133
|
+
const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
|
|
134
|
+
const [isEmpty, setIsEmpty] = createSignal(true);
|
|
135
|
+
|
|
136
|
+
let editorRef: HTMLDivElement | undefined;
|
|
137
|
+
let popupRef: HTMLDivElement | undefined;
|
|
138
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
139
|
+
let activeFetchToken = 0;
|
|
140
|
+
let isHydrating = false;
|
|
141
|
+
let isComposing = false;
|
|
142
|
+
|
|
143
|
+
const close = () => {
|
|
144
|
+
setOpen(false);
|
|
145
|
+
setQuery("");
|
|
146
|
+
setDebouncedQuery("");
|
|
147
|
+
setResults([]);
|
|
148
|
+
setActiveIdx(0);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const updatePosition = () => {
|
|
152
|
+
if (!editorRef) return;
|
|
153
|
+
const sel = window.getSelection();
|
|
154
|
+
let anchorRect: DOMRect;
|
|
155
|
+
if (sel && sel.rangeCount > 0 && editorRef.contains(sel.getRangeAt(0).startContainer)) {
|
|
156
|
+
const r = sel.getRangeAt(0).cloneRange();
|
|
157
|
+
r.collapse(true);
|
|
158
|
+
const rects = r.getClientRects();
|
|
159
|
+
anchorRect = rects.length > 0 ? rects[0] : (editorRef.getBoundingClientRect() as DOMRect);
|
|
160
|
+
} else {
|
|
161
|
+
anchorRect = editorRef.getBoundingClientRect();
|
|
162
|
+
}
|
|
163
|
+
const editorRect = editorRef.getBoundingClientRect();
|
|
164
|
+
const vpHeight = window.innerHeight;
|
|
165
|
+
const vpWidth = window.innerWidth;
|
|
166
|
+
const width = Math.max(POPUP_MIN_WIDTH, Math.min(editorRect.width, 360));
|
|
167
|
+
const spaceBelow = vpHeight - anchorRect.bottom;
|
|
168
|
+
const spaceAbove = anchorRect.top;
|
|
169
|
+
const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
|
|
170
|
+
const top = flipUp ? Math.max(8, anchorRect.top - POPUP_MAX_HEIGHT - 4) : anchorRect.bottom + 4;
|
|
171
|
+
const left = Math.min(Math.max(8, anchorRect.left), vpWidth - width - 8);
|
|
172
|
+
setPopupStyle({
|
|
173
|
+
position: "fixed",
|
|
174
|
+
top: `${top}px`,
|
|
175
|
+
left: `${left}px`,
|
|
176
|
+
width: `${width}px`,
|
|
177
|
+
"max-height": `${POPUP_MAX_HEIGHT}px`,
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
createEffect(() => {
|
|
182
|
+
const q = query();
|
|
183
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
184
|
+
debounceTimer = setTimeout(() => setDebouncedQuery(q), SEARCH_DEBOUNCE_MS);
|
|
185
|
+
onCleanup(() => {
|
|
186
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
createEffect(() => {
|
|
191
|
+
if (!open()) return;
|
|
192
|
+
const q = debouncedQuery().trim();
|
|
193
|
+
const token = ++activeFetchToken;
|
|
194
|
+
setLoading(true);
|
|
195
|
+
const params = new URLSearchParams({ status: "active", limit: String(FETCH_LIMIT) });
|
|
196
|
+
if (q) params.set("search", q);
|
|
197
|
+
fetch(`/api/clients?${params.toString()}`, { credentials: "include" })
|
|
198
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
|
199
|
+
.then((json) => {
|
|
200
|
+
if (token !== activeFetchToken) return;
|
|
201
|
+
const hits = (json.data || []) as ClientHit[];
|
|
202
|
+
setResults(hits);
|
|
203
|
+
setActiveIdx((i) => Math.min(i, Math.max(0, hits.length - 1)));
|
|
204
|
+
})
|
|
205
|
+
.catch(() => {
|
|
206
|
+
if (token !== activeFetchToken) return;
|
|
207
|
+
setResults([]);
|
|
208
|
+
})
|
|
209
|
+
.finally(() => {
|
|
210
|
+
if (token !== activeFetchToken) return;
|
|
211
|
+
setLoading(false);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
createEffect(() => {
|
|
216
|
+
if (!open()) return;
|
|
217
|
+
updatePosition();
|
|
218
|
+
const onDocClick = (e: MouseEvent) => {
|
|
219
|
+
const t = e.target as Node;
|
|
220
|
+
if (editorRef?.contains(t)) return;
|
|
221
|
+
if (popupRef?.contains(t)) return;
|
|
222
|
+
close();
|
|
223
|
+
};
|
|
224
|
+
const onReflow = () => updatePosition();
|
|
225
|
+
document.addEventListener("mousedown", onDocClick);
|
|
226
|
+
window.addEventListener("resize", onReflow);
|
|
227
|
+
window.addEventListener("scroll", onReflow, true);
|
|
228
|
+
onCleanup(() => {
|
|
229
|
+
document.removeEventListener("mousedown", onDocClick);
|
|
230
|
+
window.removeEventListener("resize", onReflow);
|
|
231
|
+
window.removeEventListener("scroll", onReflow, true);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
createEffect(() => {
|
|
236
|
+
const v = props.value;
|
|
237
|
+
if (!editorRef) return;
|
|
238
|
+
if (isHydrating) return;
|
|
239
|
+
if (serialize(editorRef) === v) {
|
|
240
|
+
setIsEmpty(v.length === 0);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
isHydrating = true;
|
|
244
|
+
hydrateInto(editorRef, v);
|
|
245
|
+
isHydrating = false;
|
|
246
|
+
setIsEmpty(v.length === 0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
onMount(() => {
|
|
250
|
+
if (!editorRef) return;
|
|
251
|
+
hydrateInto(editorRef, props.value);
|
|
252
|
+
setIsEmpty(props.value.length === 0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const emitFromDom = () => {
|
|
256
|
+
if (!editorRef || isHydrating) return;
|
|
257
|
+
const next = serialize(editorRef);
|
|
258
|
+
setIsEmpty(next.length === 0);
|
|
259
|
+
props.setValue(next);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const refreshTrigger = () => {
|
|
263
|
+
if (!editorRef) return;
|
|
264
|
+
if (isComposing) return;
|
|
265
|
+
const t = findTrigger(editorRef);
|
|
266
|
+
if (!t) {
|
|
267
|
+
if (open()) close();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
setQuery(t.query);
|
|
271
|
+
if (!open()) {
|
|
272
|
+
setOpen(true);
|
|
273
|
+
setActiveIdx(0);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const onInput: JSX.EventHandler<HTMLDivElement, InputEvent> = () => {
|
|
278
|
+
if (isHydrating) return;
|
|
279
|
+
emitFromDom();
|
|
280
|
+
refreshTrigger();
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const onPaste: JSX.EventHandler<HTMLDivElement, ClipboardEvent> = (e) => {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
const text = e.clipboardData?.getData("text/plain") ?? "";
|
|
286
|
+
if (!text) return;
|
|
287
|
+
document.execCommand("insertText", false, text);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const insertSelected = (hit: ClientHit) => {
|
|
291
|
+
if (!editorRef) {
|
|
292
|
+
close();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const t = findTrigger(editorRef);
|
|
296
|
+
if (!t) {
|
|
297
|
+
close();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const sel = window.getSelection();
|
|
301
|
+
if (!sel || sel.rangeCount === 0) {
|
|
302
|
+
close();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const caretRange = sel.getRangeAt(0);
|
|
306
|
+
const replaceRange = document.createRange();
|
|
307
|
+
replaceRange.setStart(t.anchor.node, t.anchor.offset);
|
|
308
|
+
replaceRange.setEnd(caretRange.endContainer, caretRange.endOffset);
|
|
309
|
+
replaceRange.deleteContents();
|
|
310
|
+
const chip = buildMentionChip(hit.name_raw, String(hit.id));
|
|
311
|
+
const trailing = document.createTextNode(" ");
|
|
312
|
+
replaceRange.insertNode(trailing);
|
|
313
|
+
replaceRange.insertNode(chip);
|
|
314
|
+
const after = document.createRange();
|
|
315
|
+
after.setStartAfter(trailing);
|
|
316
|
+
after.collapse(true);
|
|
317
|
+
sel.removeAllRanges();
|
|
318
|
+
sel.addRange(after);
|
|
319
|
+
close();
|
|
320
|
+
emitFromDom();
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const onKeyDown: JSX.EventHandler<HTMLDivElement, KeyboardEvent> = (e) => {
|
|
324
|
+
if (!open()) return;
|
|
325
|
+
const items = results();
|
|
326
|
+
if (e.key === "ArrowDown") {
|
|
327
|
+
if (items.length === 0) return;
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
setActiveIdx((i) => (i + 1) % items.length);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (e.key === "ArrowUp") {
|
|
333
|
+
if (items.length === 0) return;
|
|
334
|
+
e.preventDefault();
|
|
335
|
+
setActiveIdx((i) => (i - 1 + items.length) % items.length);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (e.key === "Enter" || e.key === "Tab") {
|
|
339
|
+
if (items.length === 0) {
|
|
340
|
+
e.preventDefault();
|
|
341
|
+
close();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const hit = items[activeIdx()];
|
|
345
|
+
if (hit) {
|
|
346
|
+
e.preventDefault();
|
|
347
|
+
insertSelected(hit);
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (e.key === "Escape") {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
e.stopPropagation();
|
|
354
|
+
close();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const onKeyUp: JSX.EventHandler<HTMLDivElement, KeyboardEvent> = (e) => {
|
|
360
|
+
if (e.key.startsWith("Arrow") || e.key === "Home" || e.key === "End") {
|
|
361
|
+
refreshTrigger();
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const onMouseUp: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
|
|
366
|
+
refreshTrigger();
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const onCompositionStart = () => {
|
|
370
|
+
isComposing = true;
|
|
371
|
+
};
|
|
372
|
+
const onCompositionEnd = () => {
|
|
373
|
+
isComposing = false;
|
|
374
|
+
emitFromDom();
|
|
375
|
+
refreshTrigger();
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const minHeightStyle = () => {
|
|
379
|
+
const rows = Math.max(1, Math.min(props.rows ?? 2, 12));
|
|
380
|
+
return { "min-height": `${rows * 1.5}em` };
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<>
|
|
385
|
+
<div class="relative">
|
|
386
|
+
<div
|
|
387
|
+
ref={editorRef}
|
|
388
|
+
data-testid="mention-textarea"
|
|
389
|
+
contenteditable={!props.disabled}
|
|
390
|
+
tabindex={props.disabled ? -1 : 0}
|
|
391
|
+
role="combobox"
|
|
392
|
+
aria-haspopup="listbox"
|
|
393
|
+
aria-expanded={open()}
|
|
394
|
+
aria-controls={open() ? listboxId : undefined}
|
|
395
|
+
aria-autocomplete="list"
|
|
396
|
+
aria-label={props.ariaLabel}
|
|
397
|
+
aria-activedescendant={
|
|
398
|
+
open() && results()[activeIdx()] ? optionId(results()[activeIdx()].id) : undefined
|
|
399
|
+
}
|
|
400
|
+
onInput={onInput}
|
|
401
|
+
onPaste={onPaste}
|
|
402
|
+
onKeyDown={onKeyDown}
|
|
403
|
+
onKeyUp={onKeyUp}
|
|
404
|
+
onMouseUp={onMouseUp}
|
|
405
|
+
onCompositionStart={onCompositionStart}
|
|
406
|
+
onCompositionEnd={onCompositionEnd}
|
|
407
|
+
onBlur={() => {
|
|
408
|
+
setTimeout(() => {
|
|
409
|
+
if (!popupRef?.matches(":hover")) {
|
|
410
|
+
close();
|
|
411
|
+
props.onBlur?.();
|
|
412
|
+
}
|
|
413
|
+
}, 120);
|
|
414
|
+
}}
|
|
415
|
+
class={`${props.class ?? ""} whitespace-pre-wrap break-words outline-none`}
|
|
416
|
+
style={minHeightStyle()}
|
|
417
|
+
/>
|
|
418
|
+
<Show when={isEmpty() && props.placeholder}>
|
|
419
|
+
<div
|
|
420
|
+
aria-hidden="true"
|
|
421
|
+
class={`${props.class ?? ""} pointer-events-none absolute inset-0 text-sm !text-zinc-500`}
|
|
422
|
+
>
|
|
423
|
+
{props.placeholder}
|
|
424
|
+
</div>
|
|
425
|
+
</Show>
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
<Show when={open()}>
|
|
429
|
+
<Portal>
|
|
430
|
+
<div
|
|
431
|
+
ref={popupRef}
|
|
432
|
+
data-testid="mention-popup"
|
|
433
|
+
class="z-[120] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
|
|
434
|
+
style={popupStyle()}
|
|
435
|
+
>
|
|
436
|
+
<Show when={loading() && results().length === 0}>
|
|
437
|
+
<div class="px-3 py-3 text-xs text-zinc-500 flex items-center gap-2">
|
|
438
|
+
<Loader2 size={12} class="animate-spin" />
|
|
439
|
+
Searching clients…
|
|
440
|
+
</div>
|
|
441
|
+
</Show>
|
|
442
|
+
<Show when={!loading() && results().length === 0}>
|
|
443
|
+
<div class="px-3 py-3 text-xs text-zinc-500" data-testid="mention-popup-empty">
|
|
444
|
+
{query() ? `No clients match "${query()}"` : "Type to search clients…"}
|
|
445
|
+
</div>
|
|
446
|
+
</Show>
|
|
447
|
+
<Show when={results().length > 0}>
|
|
448
|
+
<ul
|
|
449
|
+
id={listboxId}
|
|
450
|
+
role="listbox"
|
|
451
|
+
aria-label="Client mentions"
|
|
452
|
+
class="m-0 p-0 list-none overflow-y-auto"
|
|
453
|
+
style={{ "max-height": "240px" }}
|
|
454
|
+
>
|
|
455
|
+
<For each={results()}>
|
|
456
|
+
{(c, idx) => (
|
|
457
|
+
<li id={optionId(c.id)} role="option" aria-selected={idx() === activeIdx()}>
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
data-testid={`mention-result-${c.id}`}
|
|
461
|
+
onMouseDown={(e) => {
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
}}
|
|
464
|
+
onMouseEnter={() => setActiveIdx(idx())}
|
|
465
|
+
onClick={() => insertSelected(c)}
|
|
466
|
+
class={`w-full text-left px-3 py-2 transition-colors flex items-start gap-2 cursor-pointer ${
|
|
467
|
+
idx() === activeIdx() ? "bg-amber-500/15" : "hover:bg-amber-500/10"
|
|
468
|
+
}`}
|
|
469
|
+
>
|
|
470
|
+
<UserRound size={14} class="text-zinc-500 shrink-0 mt-0.5" />
|
|
471
|
+
<span class="flex-1 min-w-0">
|
|
472
|
+
<span class="block text-sm text-zinc-100 truncate">{c.name_raw}</span>
|
|
473
|
+
<Show when={c.email || c.phone}>
|
|
474
|
+
<span class="block text-[11px] text-zinc-500 truncate">
|
|
475
|
+
{[c.email, c.phone].filter(Boolean).join(" · ")}
|
|
476
|
+
</span>
|
|
477
|
+
</Show>
|
|
478
|
+
</span>
|
|
479
|
+
</button>
|
|
480
|
+
</li>
|
|
481
|
+
)}
|
|
482
|
+
</For>
|
|
483
|
+
</ul>
|
|
484
|
+
</Show>
|
|
485
|
+
</div>
|
|
486
|
+
</Portal>
|
|
487
|
+
</Show>
|
|
488
|
+
</>
|
|
489
|
+
);
|
|
490
|
+
}
|