1<template>
2 <div class="relative inline-block text-start">
3 <div class="flex">
4 <Button
5 variant="outline"
6 :class="{ 'ring-2 ring-outline-gray-2': isOpen }"
7 @click="toggleDropdown"
8 >
9 <div
10 class="flex items-center min-h-[20px]"
11 :class="{ 'gap-2': hasSelectedImages && selectedCount > 0 }"
12 >
13 <template v-if="selectedCount > 0">
14 <!-- Stacked Avatars -->
15 <div class="flex -space-x-2 rtl:space-x-reverse isolate">
16 <div
17 v-for="(option, index) in selectedOptions.slice(0, 3)"
18 :key="`avatar-${option.value}`"
19 class="relative flex"
20 :style="{ zIndex: 10 - index }"
21 >
22 <Avatar
23 v-if="option.image"
24 :image="option.image"
25 :label="option.label"
26 class="border-2 border-[var(--surface-base)] flex-shrink-0"
27 size="sm"
28 />
29 </div>
30 </div>
31 <!-- Count Text -->
32 <span class="text-ink-gray-7">
33 {{
34 selectedCount === 1
35 ? selectedOptions[0]?.label
36 : `${selectedCount} ${selectionText}`
37 }}
38 </span>
39 </template>
40 <span v-else class="text-ink-gray-6">{{ placeholder }}</span>
41 </div>
42 <template #suffix>
43 <LucideChevronDown
44 class="ms-2 h-4 w-4 transition-transform duration-200"
45 :class="{ 'rotate-180': isOpen }"
46 />
47 </template>
48 </Button>
49 </div>
50
51 <div
52 v-if="isOpen"
53 class="absolute z-50 mt-2 w-64 divide-y divide-outline-elevation-2 rounded-6 bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none start-0 origin-top-left"
54 >
55 <!-- Header -->
56 <div class="py-1.5 px-1.5">
57 <div
58 class="flex h-7 items-center text-sm-medium text-ink-gray-6 justify-between"
59 >
60 <input
61 ref="inputRef"
62 v-model="filterText"
63 :placeholder="label"
64 class="px-2 flex-1 bg-transparent border-none outline-none text-sm focus:border-none focus:ring-0 text-ink-gray-6 placeholder-ink-gray-4"
65 @click.stop
66 @keydown="handleInputKeydown"
67 />
68 <Button
69 variant="ghost"
70 size="sm"
71 v-if="selectedCount > 0"
72 @click="clearAll"
73 >
74 <template #icon>
75 <LucideX class="size-4" />
76 </template>
77 </Button>
78 </div>
79 </div>
80
81 <!-- Options List -->
82 <div class="px-1.5 pb-1.5 max-h-64 overflow-y-auto">
83 <!-- Individual Options (ungrouped) -->
84 <div class="pt-1.5" v-if="filteredIndividualOptions.length > 0">
85 <button
86 v-for="option in filteredIndividualOptions"
87 :key="`individual-${option.value}`"
88 class="group flex h-7 w-full items-center rounded-4 px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
89 :class="{ 'bg-surface-gray-2': isHighlighted(option) }"
90 @click="toggleOption(option)"
91 >
92 <Checkbox
93 :modelValue="props.modelValue.includes(option.value)"
94 class="me-4 flex-shrink-0"
95 />
96
97 <component v-if="option.icon" :is="renderIcon(option.icon)" />
98
99 <Avatar
100 v-else-if="hasAnyVisualElements"
101 :image="option.image"
102 :label="option.label"
103 class="me-2 flex-shrink-0"
104 size="sm"
105 />
106
107 <span class="text-ink-gray-7 flex-1 text-start truncate">
108 {{ option.label }}
109 </span>
110
111 <span
112 v-if="(option.count ?? 0) > 0"
113 class="text-xs text-ink-gray-5 ms-auto"
114 >
115 {{ option.count }}
116 </span>
117 </button>
118 </div>
119
120 <!-- Grouped Options -->
121 <div v-for="group in filteredGroups" :key="`group-${group.group}`">
122 <!-- Group Header -->
123 <div
124 class="flex h-7 pt-1.5 sticky top-0 bg-surface-base items-center px-2 text-sm-medium text-ink-gray-6"
125 >
126 {{ group.group }}
127 </div>
128
129 <!-- Group Options -->
130 <button
131 v-for="option in group.items"
132 :key="`grouped-${option.value}`"
133 class="group flex h-7 w-full items-center rounded-4 px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
134 :class="{ 'bg-surface-gray-2': isHighlighted(option) }"
135 @click="toggleOption(option)"
136 >
137 <Checkbox
138 :modelValue="props.modelValue.includes(option.value)"
139 class="me-4 flex-shrink-0"
140 />
141
142 <component v-if="option.icon" :is="renderIcon(option.icon)" />
143
144 <Avatar
145 v-else-if="option.image"
146 :image="option.image"
147 :label="option.label"
148 class="me-2 flex-shrink-0"
149 size="sm"
150 />
151
152 <!-- Spacer for alignment when other options have visual elements -->
153 <div
154 v-else-if="hasAnyVisualElements"
155 class="me-2 w-6 h-6 flex-shrink-0"
156 ></div>
157
158 <span class="text-ink-gray-7 flex-1 text-start truncate">
159 {{ option.label }}
160 </span>
161
162 <span
163 v-if="(option.count ?? 0) > 0"
164 class="text-xs text-ink-gray-5 ms-auto"
165 >
166 {{ option.count }}
167 </span>
168 </button>
169 </div>
170
171 <!-- No options message -->
172 <div
173 v-if="filteredOptions.length === 0"
174 class="px-2 py-4 text-center text-sm text-ink-gray-5"
175 >
176 No options found
177 </div>
178 </div>
179 </div>
180
181 <!-- Overlay to close dropdown -->
182 <div v-if="isOpen" class="fixed inset-0 z-40" @click="closeDropdown"></div>
183 </div>
184</template>
185
186<script setup lang="ts">
187import { Avatar, Button, Checkbox } from "frappe-ui";
188import {
189 computed,
190 h,
191 nextTick,
192 ref,
193 useTemplateRef,
194 watch,
195 type Component,
196} from "vue";
197import LucideChevronDown from "~icons/lucide/chevron-down";
198import LucideX from "~icons/lucide/x";
199
200// Type Definitions
201interface MultiSelectOption {
202 value: string;
203 label: string;
204 count?: number;
205 image?: string;
206 icon?: string | Component;
207}
208
209interface MultiSelectGroup {
210 group: string;
211 items: MultiSelectOption[];
212}
213
214// Support both flat options and grouped options in the same prop
215type MultiSelectData = MultiSelectOption[] | MultiSelectGroup[];
216
217interface Props {
218 options: MultiSelectData;
219 modelValue: string[];
220 placeholder?: string;
221 label?: string;
222 selectionText?: string;
223}
224
225const props = withDefaults(defineProps<Props>(), {
226 placeholder: "Select options...",
227 label: "Options",
228 selectionText: "items",
229 options: () => [],
230});
231
232const emit = defineEmits<{
233 "update:modelValue": [value: string[]];
234}>();
235
236// Reactive State
237const isOpen = ref(false);
238const filterText = ref("");
239const highlightedIndex = ref(0);
240const inputRef = useTemplateRef<HTMLInputElement>("inputRef");
241
242// Core Computed Properties
243const selectedCount = computed(() => props.modelValue.length);
244
245// Detect if the options are grouped or flat
246const isGrouped = computed(() => {
247 return (
248 props.options.length > 0 &&
249 "group" in props.options[0] &&
250 "items" in props.options[0]
251 );
252});
253
254// Extract individual options and groups from the unified options prop
255const individualOptions = computed(() => {
256 if (isGrouped.value) return [];
257 return props.options as MultiSelectOption[];
258});
259
260const groups = computed(() => {
261 if (!isGrouped.value) return [];
262 return props.options as MultiSelectGroup[];
263});
264
265// Flatten all options for easier processing
266const allOptions = computed(() => {
267 if (isGrouped.value) {
268 return (props.options as MultiSelectGroup[]).flatMap(
269 (group) => group.items
270 );
271 }
272 return props.options as MultiSelectOption[];
273});
274
275const selectedOptions = computed(() =>
276 allOptions.value.filter((option) => props.modelValue.includes(option.value))
277);
278
279// UI Helper Computed Properties
280const hasSelectedImages = computed(() =>
281 selectedOptions.value.some((option) => option.image)
282);
283const hasAnyVisualElements = computed(() => {
284 return allOptions.value.some((option) => option.image || option.icon);
285});
286
287// Filtering Logic
288const filteredGroups = computed(() => {
289 if (!isGrouped.value) return [];
290
291 const searchText = filterText.value.toLowerCase();
292
293 return groups.value
294 .map((group) => ({
295 ...group,
296 items: group.items.filter((option) => {
297 // Show all items if group name matches the search
298 const groupMatches = group.group.toLowerCase().includes(searchText);
299 // Show individual items if their label matches the search
300 const itemMatches = option.label.toLowerCase().includes(searchText);
301
302 return groupMatches || itemMatches;
303 }),
304 }))
305 .filter((group) => group.items.length > 0);
306});
307
308const filteredIndividualOptions = computed(() =>
309 individualOptions.value.filter((option) =>
310 option.label.toLowerCase().includes(filterText.value.toLowerCase())
311 )
312);
313
314// All filtered options in a flat array for keyboard navigation
315const filteredOptions = computed(() => {
316 const individual = filteredIndividualOptions.value;
317 const grouped = filteredGroups.value.flatMap((group) => group.items);
318 return [...individual, ...grouped];
319});
320
321const highlightedOption = computed(
322 () => filteredOptions.value[highlightedIndex.value] || null
323);
324
325// Main Interaction Methods
326function toggleDropdown() {
327 isOpen.value = !isOpen.value;
328 if (isOpen.value) {
329 highlightedIndex.value = 0; // Reset to first item when opening
330 nextTick(() => {
331 inputRef.value?.focus();
332 });
333 }
334}
335
336function closeDropdown() {
337 isOpen.value = false;
338}
339
340function toggleOption(option: MultiSelectOption) {
341 const newValue = [...props.modelValue];
342 const index = newValue.indexOf(option.value);
343
344 if (index > -1) {
345 newValue.splice(index, 1);
346 } else {
347 newValue.push(option.value);
348 }
349
350 emit("update:modelValue", newValue);
351}
352
353function clearAll() {
354 filterText.value = "";
355 emit("update:modelValue", []);
356}
357
358// Keyboard Navigation
359function handleInputKeydown(event: KeyboardEvent) {
360 if (!isOpen.value) return;
361
362 if (event.key === "Escape") {
363 event.preventDefault();
364 closeDropdown();
365 } else if (event.key === "Enter" && highlightedOption.value) {
366 event.preventDefault();
367 event.stopPropagation();
368 toggleOption(highlightedOption.value);
369 } else if (event.key === "ArrowDown") {
370 event.preventDefault();
371 highlightedIndex.value = Math.min(
372 highlightedIndex.value + 1,
373 filteredOptions.value.length - 1
374 );
375 } else if (event.key === "ArrowUp") {
376 event.preventDefault();
377 highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
378 }
379}
380
381// Utility Functions
382function renderIcon(icon: string | any) {
383 if (!icon) return null;
384
385 const iconContent =
386 typeof icon === "string"
387 ? h("span", { class: "text-base" }, icon)
388 : h(icon, { class: "w-4 h-4" });
389
390 return h(
391 "span",
392 {
393 class:
394 "flex-shrink-0 w-4 h-4 inline-flex items-center justify-center me-2",
395 },
396 [iconContent]
397 );
398}
399
400function isHighlighted(option: MultiSelectOption) {
401 return highlightedOption.value?.value === option.value;
402}
403
404// Watchers
405// Reset highlighted index when filter changes
406watch(filterText, () => {
407 highlightedIndex.value = 0;
408});
409</script>
410