Skip to content

Commit 6f25560

Browse files
authored
feat: add AppMultiCombobox component for multi-select dropdown
- Implemented a reusable multi-select combobox component (`AppMultiCombobox.vue`) - Supports search, tag-style selected items, and keyboard navigation - Emits `update:modelValue` with array of selected options - Styled consistently with existing `AppCombobox` component - Enables selecting multiple recipients in message compose form
1 parent f54aa47 commit 6f25560

File tree

1 file changed

+223
-0
lines changed

1 file changed

+223
-0
lines changed
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<template>
2+
<div ref="wrapperRef" class="relative w-64">
3+
<!-- Button to open dropdown -->
4+
<AppButton
5+
class="mt-1 flex w-full flex-wrap justify-between rounded-md border-0 bg-skin-neutral-1 px-3 py-2 text-left text-skin-neutral-12 shadow-sm ring-1 ring-inset ring-skin-neutral-7 focus:ring-2 focus:ring-inset focus:ring-skin-neutral-7 sm:text-sm sm:leading-6"
6+
aria-haspopup="true"
7+
:aria-expanded="isOpen"
8+
@click="toggleState"
9+
>
10+
<!-- Selected tags -->
11+
<div class="flex flex-wrap gap-1">
12+
<span
13+
v-for="(item, index) in modelValue"
14+
:key="item.value"
15+
class="flex items-center rounded bg-skin-neutral-3 px-2 py-1 text-xs text-skin-neutral-12"
16+
>
17+
{{ item.label }}
18+
<i
19+
class="ri-close-line ml-1 cursor-pointer hover:text-red-500"
20+
@click.stop="remove(index)"
21+
/>
22+
</span>
23+
24+
<span v-if="!modelValue.length" class="text-skin-neutral-9">
25+
{{ comboLabel }}
26+
</span>
27+
</div>
28+
29+
<span class="ml-auto">
30+
<i class="ri-arrow-down-line hover:text-skin-neutral-9"></i>
31+
</span>
32+
</AppButton>
33+
34+
<!-- Dropdown -->
35+
<transition name="slide-fade">
36+
<div v-show="isOpen" class="absolute z-50 mt-1 w-full">
37+
<!-- Search -->
38+
<div v-show="useSearch" class="bg-white p-1 shadow">
39+
<label :for="getElementId()" class="sr-only">Search</label>
40+
<div class="relative">
41+
<div
42+
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
43+
>
44+
<i class="ri-search-line" aria-hidden="true"></i>
45+
</div>
46+
<AppInputText
47+
:id="getElementId()"
48+
ref="searchInputRef"
49+
v-model="searchOptionText"
50+
role="searchbox"
51+
aria-autocomplete="list"
52+
type="text"
53+
class="pl-10"
54+
:placeholder="searchPlaceholder"
55+
@keypress.enter="validateOptionHighlighted"
56+
@keydown="handleArrowKeys"
57+
@keydown.esc="toggleState"
58+
/>
59+
</div>
60+
</div>
61+
62+
<!-- Options -->
63+
<ul
64+
class="max-h-60 overflow-y-auto bg-white p-1 shadow"
65+
role="listbox"
66+
>
67+
<li
68+
v-for="(option, index) in filteredOptions"
69+
:key="option.value"
70+
role="option"
71+
class="flex items-center gap-2 px-4 py-2 text-sm hover:cursor-pointer hover:bg-skin-neutral-3 hover:text-skin-neutral-12"
72+
:class="{
73+
'bg-skin-neutral-3 text-skin-neutral-12':
74+
index === highlightedIndex
75+
}"
76+
@click="toggleSelect(option)"
77+
>
78+
<input
79+
type="checkbox"
80+
class="rounded border-gray-300"
81+
:checked="isSelected(option)"
82+
@change="toggleSelect(option)"
83+
/>
84+
{{ option.label }}
85+
</li>
86+
</ul>
87+
</div>
88+
</transition>
89+
</div>
90+
</template>
91+
92+
<script setup>
93+
import { ref, computed, onMounted, watch } from 'vue'
94+
import slug from '@resources/js/Utils/slug.js'
95+
import useClickOutside from '@resources/js/Composables/useClickOutside'
96+
97+
const props = defineProps({
98+
modelValue: {
99+
type: Array,
100+
required: true,
101+
default: () => []
102+
},
103+
comboLabel: {
104+
type: String,
105+
default: 'Select options'
106+
},
107+
useSearch: {
108+
type: Boolean,
109+
default: true
110+
},
111+
searchPlaceholder: {
112+
type: String,
113+
default: 'Search'
114+
},
115+
options: {
116+
type: Array,
117+
default: () => []
118+
}
119+
})
120+
121+
const emit = defineEmits(['update:modelValue'])
122+
123+
const wrapperRef = ref(null)
124+
const { isClickOutside } = useClickOutside(wrapperRef)
125+
126+
watch(isClickOutside, (val) => {
127+
if (val) {
128+
isOpen.value = false
129+
}
130+
})
131+
132+
onMounted(() => {
133+
isOpen.value && (highlightedIndex.value = 0)
134+
})
135+
136+
const getElementId = () => {
137+
return slug(props.comboLabel)
138+
}
139+
140+
const isOpen = ref(false)
141+
const searchInputRef = ref(null)
142+
143+
const toggleState = () => {
144+
isOpen.value = !isOpen.value
145+
highlightedIndex.value = 0
146+
window.setTimeout(() => {
147+
if (isOpen.value) {
148+
searchOptionText.value = ''
149+
searchInputRef.value.focusInput()
150+
}
151+
}, 100)
152+
}
153+
154+
const searchOptionText = ref('')
155+
156+
const filteredOptions = computed(() => {
157+
if (searchOptionText.value) {
158+
return props.options.filter((option) =>
159+
option.label
160+
.toLowerCase()
161+
.includes(searchOptionText.value.toLowerCase())
162+
)
163+
} else {
164+
return props.options
165+
}
166+
})
167+
168+
const highlightedIndex = ref(0)
169+
170+
const handleArrowKeys = (event) => {
171+
switch (event.key) {
172+
case 'ArrowUp':
173+
if (highlightedIndex.value > 0) {
174+
highlightedIndex.value--
175+
}
176+
break
177+
case 'ArrowDown':
178+
if (highlightedIndex.value < filteredOptions.value.length - 1) {
179+
highlightedIndex.value++
180+
}
181+
break
182+
}
183+
}
184+
185+
const validateOptionHighlighted = () => {
186+
if (filteredOptions.value[highlightedIndex.value]) {
187+
toggleSelect(filteredOptions.value[highlightedIndex.value])
188+
}
189+
}
190+
191+
const isSelected = (option) => {
192+
return props.modelValue.some((item) => item.value === option.value)
193+
}
194+
195+
const toggleSelect = (option) => {
196+
let newValue
197+
if (isSelected(option)) {
198+
newValue = props.modelValue.filter(
199+
(item) => item.value !== option.value
200+
)
201+
} else {
202+
newValue = [...props.modelValue, option]
203+
}
204+
emit('update:modelValue', newValue)
205+
}
206+
207+
const remove = (index) => {
208+
const newValue = [...props.modelValue]
209+
newValue.splice(index, 1)
210+
emit('update:modelValue', newValue)
211+
}
212+
</script>
213+
214+
<style scoped>
215+
.slide-fade-enter-active,
216+
.slide-fade-leave-active {
217+
@apply transition-all duration-200 ease-in;
218+
}
219+
.slide-fade-enter-from,
220+
.slide-fade-leave-to {
221+
@apply -translate-y-2 opacity-0;
222+
}
223+
</style>

0 commit comments

Comments
 (0)