1<template>
2 <div>
3 <div ref="pills" class="flex flex-wrap gap-1">
4 <Button
5 v-for="value in values"
6 :key="value"
7 :label="value"
8 theme="gray"
9 variant="subtle"
10 :tooltip="copyOnClick ? __('Click to copy') : undefined"
11 :class="[
12 {
13 'rounded-4 !bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
14 variant === 'subtle',
15 },
16 copyOnClick
17 ? 'cursor-pointer transition-transform active:scale-[0.98]'
18 : '',
19 ]"
20 @click="copyOnClick ? copyToClipboard(value) : null"
21 @keydown.delete.capture.stop="removeLastValue"
22 >
23 <template #suffix>
24 <LucideX class="size-3.5" @click.stop="removeValue(value)" />
25 </template>
26 </Button>
27 <div class="flex-1 min-w-32">
28 <ComboboxRoot
29 :model-value="tempSelection"
30 :open="showOptions"
31 @update:open="(o) => (showOptions = o)"
32 @update:modelValue="onSelect"
33 :ignore-filter="true"
34 highlight-on-hover
35 >
36 <ComboboxAnchor
37 class="flex h-7 max-w-full w-auto items-center gap-2 rounded-4 px-2 py-1 border border-transparent"
38 :class="[
39 variant == 'ghost'
40 ? 'bg-surface-base hover:bg-surface-base'
41 : 'bg-surface-gray-2 hover:bg-surface-gray-3',
42 inputClass,
43 ]"
44 >
45 <ComboboxInput
46 ref="search"
47 :value="query"
48 autocomplete="off"
49 class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full focus:outline-none focus:ring-0 focus:border-0"
50 :placeholder="placeholder"
51 @focus="showOptions = true"
52 @input="onInput"
53 @keydown.delete.capture.stop="removeLastValue"
54 @keydown="onKeydown"
55 @paste="onPaste"
56 />
57 </ComboboxAnchor>
58 <ComboboxPortal>
59 <ComboboxContent
60 class="z-[100] mt-1 min-w-48 w-auto max-w-96 bg-surface-elevation-2 overflow-hidden rounded-6 shadow-2xl ring-1 ring-black ring-opacity-5"
61 position="popper"
62 :align="'start'"
63 @openAutoFocus.prevent
64 @closeAutoFocus.prevent
65 >
66 <ComboboxViewport class="max-h-60 overflow-auto p-1.5">
67 <ComboboxEmpty
68 class="flex gap-2 rounded-4 px-2 py-1 text-base text-ink-gray-5"
69 >
70 <LucideSearch class="size-4" />
71 {{ __(emptyPlaceholder) }}
72 </ComboboxEmpty>
73 <ComboboxItem
74 v-for="option in options"
75 :key="option.value"
76 :value="option.value"
77 class="text-base leading-none text-ink-gray-7 rounded-4 flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
78 @mousedown.prevent="onSelect(option.value)"
79 >
80 <UserAvatar
81 class="mr-0.5 shrink-0"
82 :name="getUsernameLabel(option.label)"
83 size="lg"
84 />
85 <div class="flex min-w-0 flex-col gap-1 p-1 text-ink-gray-8">
86 <div class="truncate text-base-medium">
87 {{ getUsernameLabel(option.label) }}
88 </div>
89 <div class="truncate text-sm text-ink-gray-5">
90 {{ option.isCustom ? customEmailLabel : option.value }}
91 </div>
92 </div>
93 </ComboboxItem>
94 </ComboboxViewport>
95 </ComboboxContent>
96 </ComboboxPortal>
97 </ComboboxRoot>
98 </div>
99 </div>
100 <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
101 <div
102 v-if="info"
103 class="whitespace-pre-line text-sm text-ink-blue-6 mt-2 pl-2"
104 >
105 {{ info }}
106 </div>
107 </div>
108</template>
109
110<script setup>
111import LucideSearch from "~icons/lucide/search";
112import LucideX from "~icons/lucide/x";
113import UserAvatar from "@/components/UserAvatar.vue";
114import { useUserStore } from "@/stores/user";
115import { copyToClipboard } from "@opendesk/shared/utils";
116import { watchDebounced } from "@vueuse/core";
117import { createResource } from "frappe-ui";
118import {
119 ComboboxAnchor,
120 ComboboxContent,
121 ComboboxEmpty,
122 ComboboxInput,
123 ComboboxItem,
124 ComboboxPortal,
125 ComboboxRoot,
126 ComboboxViewport,
127} from "reka-ui";
128import { computed, nextTick, onMounted, ref, watch } from "vue";
129
130const props = defineProps({
131 // "contact" searches OD contacts; "agents" picks from the user store.
132 scope: { type: String, default: "contact" },
133 validate: { type: Function, default: null },
134 emptyPlaceholder: { type: String, default: __("No results found") },
135 variant: { type: String, default: "subtle" },
136 placeholder: { type: String, default: "" },
137 inputClass: { type: String, default: "" },
138 existingUsers: { type: Array, default: () => [] },
139 allowCustomEmail: { type: Boolean, default: false },
140 additionalFilters: { type: Array, default: () => [] },
141 copyOnClick: { type: Boolean, default: false },
142 customEmailLabel: { type: String, default: __("Invite via email") },
143});
144
145const errorMessage = (value) => __("{0} is an Invalid Email Address", [value]);
146
147// v-model values
148const values = defineModel();
149
150// Common state
151const pills = ref(null);
152const search = ref(null);
153const error = ref(null);
154const info = ref(null);
155const query = ref("");
156const showOptions = ref(false);
157const tempSelection = ref(null);
158
159const { users } = useUserStore();
160
161// --- Contacts resource (used when scope = "contact") ---
162const filterContacts = createResource({
163 url: "opendesk.api.contact.search_contacts",
164 method: "GET",
165 params: contactSearchParams(""),
166 transform: (data) =>
167 data.map(({ full_name, email_id, name }) => ({
168 label: full_name || email_id || name,
169 value: email_id || name,
170 email: email_id,
171 name,
172 })),
173});
174
175watchDebounced(
176 query,
177 (newVal) => {
178 if (props.scope === "agents") return;
179 filterContacts.update({ params: contactSearchParams(newVal) });
180 filterContacts.reload();
181 },
182 { debounce: 250, immediate: true }
183);
184
185function contactSearchParams(txt) {
186 const params = { txt: txt || "" };
187 if (props.additionalFilters?.length) {
188 params.additional_filters = JSON.stringify(props.additionalFilters);
189 }
190 return params;
191}
192
193// --- Options ---
194const options = computed(() => {
195 if (props.scope === "agents") {
196 let list = (users?.data || []).map((u) => ({
197 label: u.full_name || u.name,
198 value: u.name,
199 }));
200 if (values.value?.length) {
201 list = list.filter((o) => !values.value.includes(o.value));
202 }
203 if (props.existingUsers?.length) {
204 list = list.filter((o) => !props.existingUsers.includes(o.value));
205 }
206 return list.filter(matchesQuery);
207 }
208
209 // contacts mode; the search is debounced, so narrow the last results to the
210 // typed text, or a quick Enter picks a contact that no longer matches
211 let list = (filterContacts.data || []).filter(matchesQuery);
212 if (values.value?.length) {
213 list = list.filter((o) => !values.value.includes(o.value));
214 }
215 if (props.existingUsers?.length) {
216 list = list.filter((o) => !isExistingUser(o.email));
217 }
218 // offer the typed text itself when nothing matches, like the email To field
219 if (
220 props.allowCustomEmail &&
221 query.value &&
222 !list.length &&
223 !isExistingUser(query.value)
224 ) {
225 list = [{ label: query.value, value: query.value, isCustom: true }];
226 }
227 return list;
228});
229
230function matchesQuery(option) {
231 const q = query.value.toLowerCase();
232 return [option.label, option.value, option.name].some((field) =>
233 field?.toLowerCase().includes(q)
234 );
235}
236
237function isExistingUser(email) {
238 if (!email) return false;
239 return props.existingUsers?.some(
240 (user) => user?.toLowerCase?.() === email.toLowerCase()
241 );
242}
243
244function addValue(input) {
245 if (!input) return;
246 error.value = null;
247 info.value = null;
248 const parts = splitRecipients(input);
249 for (const email of parts) {
250 if (values.value?.includes(email)) continue;
251 const inList = options.value.some((o) => o.value === email && !o.isCustom);
252 if (!inList && !canAddCustomValue(email)) break;
253 if (!values.value) values.value = [email];
254 else values.value.push(email);
255 }
256}
257
258function canAddCustomValue(email) {
259 if (!props.allowCustomEmail) {
260 error.value =
261 props.scope === "agents"
262 ? __("{0} is not an existing user", [email])
263 : __("{0} is not an existing contact", [email]);
264 query.value = email;
265 return false;
266 }
267 if (isExistingUser(email)) {
268 error.value = __("{0} is already added", [email]);
269 query.value = email;
270 return false;
271 }
272 if (props.validate && !props.validate(email)) {
273 error.value = errorMessage(email);
274 query.value = email;
275 return false;
276 }
277 return true;
278}
279
280// Split on commas/semicolons/newlines while keeping a quoted display name
281// intact, so '"Doe, John" <a@b.com>' stays a single token.
282function splitRecipients(input) {
283 const parts = [];
284 let current = "";
285 let inQuotes = false;
286 for (const char of input) {
287 if (char === '"') {
288 inQuotes = !inQuotes;
289 current += char;
290 } else if ((char === "," || char === ";" || char === "\n") && !inQuotes) {
291 if (current.trim()) parts.push(current.trim());
292 current = "";
293 } else {
294 current += char;
295 }
296 }
297 if (current.trim()) parts.push(current.trim());
298 return parts;
299}
300
301// Reduce a "Name <email>" value to just the display name for rendering.
302function getUsernameLabel(value) {
303 if (!value) return "";
304 return value.split("<")[0].replace(/"/g, "").trim();
305}
306
307function removeValue(value) {
308 values.value = values.value.filter((v) => v !== value);
309}
310
311// Read the pills from the DOM: a Button with a tooltip renders extra root
312// nodes, so its `$el` is not the <button>.
313function lastPill() {
314 const buttons = pills.value?.querySelectorAll(":scope > button");
315 return buttons?.[buttons.length - 1] ?? null;
316}
317
318function removeLastValue() {
319 if (query.value) return;
320 const pill = lastPill();
321 if (document.activeElement === pill) {
322 values.value.pop();
323 nextTick(() => {
324 if (values.value.length) {
325 lastPill()?.focus();
326 } else {
327 setFocus();
328 }
329 });
330 } else {
331 pill?.focus();
332 }
333}
334
335// TODO: use single contact component. This refocus also fires when focus has
336// already moved on, so a click elsewhere within 200ms is pulled back here.
337function setFocus() {
338 setTimeout(() => {
339 search.value?.$el?.focus();
340 }, 200);
341}
342
343function onInput(e) {
344 query.value = e.target.value;
345 error.value = null;
346 info.value = null;
347 showOptions.value = true;
348}
349
350function onSelect(val) {
351 if (!val) return;
352 addValue(val);
353 if (error.value) {
354 showOptions.value = false;
355 return;
356 }
357 query.value = "";
358 tempSelection.value = null;
359 showOptions.value = true;
360 nextTick(() => setFocus());
361}
362
363// Commit the current query as a pill on Enter, comma or space.
364function onKeydown(e) {
365 const isCommitKey = e.key === "Enter" || e.key === "," || e.key === " ";
366 if (!isCommitKey || !query.value.trim()) return;
367 e.preventDefault();
368 onSelect(query.value);
369}
370
371// Split a pasted, comma/space/newline-separated list into multiple pills.
372function onPaste(e) {
373 const text = e.clipboardData?.getData("text") ?? "";
374 if (!/[\s,;]/.test(text)) return;
375 e.preventDefault();
376 addValue(text);
377 if (!error.value) {
378 query.value = "";
379 tempSelection.value = null;
380 nextTick(() => setFocus());
381 }
382}
383
384watch(options, (list) => {
385 tempSelection.value = list[0]?.value ?? null;
386});
387
388onMounted(() => {
389 // reka-ui hardcodes autocomplete="off", which Chrome ignores for fields it
390 // reads as email/address. "new-password" is the value Chrome won't autofill,
391 // so its native address dropdown stops covering our options. (No password UI:
392 // the input is type=text and not inside a form.)
393 search.value?.$el?.setAttribute("autocomplete", "new-password");
394});
395
396defineExpose({ setFocus });
397</script>
398