fix: refactor Attendee component for improved search functionality and option selection

This commit is contained in:
Shariq Ansari 2025-09-02 20:37:38 +05:30
parent da7ee0926f
commit 52d99ebf20
2 changed files with 114 additions and 112 deletions

View File

@ -1,22 +1,21 @@
<template> <template>
<div> <div>
<div <div
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7 [&>div]:w-full" class="flex items-center justify-between text-ink-gray-7 [&>div]:w-full"
> >
<Combobox v-model="selectedValue" nullable class="w-full">
<Popover v-model:show="showOptions"> <Popover v-model:show="showOptions">
<template #target="{ togglePopover }"> <template #target="{ togglePopover }">
<TextInput <TextInput
ref="search" ref="search"
type="text" type="text"
size="md" :size="size"
class="w-full" class="w-full"
variant="outline" variant="outline"
v-model="query" v-model="query"
:debounce="300" :debounce="300"
:placeholder="placeholder" :placeholder="placeholder"
@click="togglePopover" @click="togglePopover"
@keydown.delete.capture.stop="removeLastValue" @keydown="onKeydown"
> >
<template #suffix> <template #suffix>
<FeatherIcon <FeatherIcon
@ -32,36 +31,20 @@
<div <div
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none" class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
> >
<ComboboxOptions <ul
v-if="options.length"
role="listbox"
class="p-1.5 max-h-[12rem] overflow-y-auto" class="p-1.5 max-h-[12rem] overflow-y-auto"
static
>
<div
v-if="!options.length"
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon
v-if="fetchContacts"
name="search"
class="h-4"
/>
{{
fetchContacts
? __('No results found')
: __('Type an email address to add attendee')
}}
</div>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
> >
<li <li
:class="[ v-for="(option, idx) in options"
'flex cursor-pointer items-center rounded px-2 py-1 text-base', :key="option.value"
{ 'bg-surface-gray-3': active }, role="option"
]" :aria-selected="idx === highlightIndex"
@click="selectOption(option)"
@mouseenter="highlightIndex = idx"
class="flex cursor-pointer items-center rounded px-2 py-1 text-base"
:class="{ 'bg-surface-gray-3': idx === highlightIndex }"
> >
<UserAvatar class="mr-2" :user="option.value" size="lg" /> <UserAvatar class="mr-2" :user="option.value" size="lg" />
<div class="flex flex-col gap-1 p-1 text-ink-gray-8"> <div class="flex flex-col gap-1 p-1 text-ink-gray-8">
@ -73,17 +56,27 @@
</div> </div>
</div> </div>
</li> </li>
</ComboboxOption> </ul>
</ComboboxOptions> <div
v-else
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon v-if="fetchContacts" name="search" class="h-4" />
{{
fetchContacts
? __('No results found')
: __('Type an email address to add attendee')
}}
</div>
</div> </div>
</div> </div>
</template> </template>
</Popover> </Popover>
</Combobox>
</div> </div>
<div <div
v-if="values.length" v-if="values.length"
class="flex flex-col gap-2 px-4.5 py-[7px] max-h-[165px] overflow-y-auto" class="flex flex-col gap-2 mt-2 max-h-[165px] overflow-y-auto"
ref="optionsRef"
> >
<Button <Button
ref="emails" ref="emails"
@ -93,7 +86,6 @@
theme="gray" theme="gray"
class="rounded-full w-fit" class="rounded-full w-fit"
:tooltip="getTooltip(att.email)" :tooltip="getTooltip(att.email)"
@keydown.delete.capture.stop="removeLastValue"
> >
<template #prefix> <template #prefix>
<UserAvatar :user="att.email" class="-ml-1 !size-5.5" /> <UserAvatar :user="att.email" class="-ml-1 !size-5.5" />
@ -112,13 +104,10 @@
</template> </template>
<script setup> <script setup>
import { Combobox, ComboboxOptions, ComboboxOption } from '@headlessui/vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import Popover from '@/components/frappe-ui/Popover.vue' import { createResource, TextInput, Popover } from 'frappe-ui'
import { createResource, TextInput } from 'frappe-ui' import { ref, computed, nextTick, watch } from 'vue'
import { ref, computed, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core' import { watchDebounced } from '@vueuse/core'
import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue'
const props = defineProps({ const props = defineProps({
validate: { validate: {
@ -129,6 +118,10 @@ const props = defineProps({
type: String, type: String,
default: 'subtle', default: 'subtle',
}, },
size: {
type: String,
default: 'sm',
},
placeholder: { placeholder: {
type: String, type: String,
default: 'Add attendee', default: 'Add attendee',
@ -160,6 +153,8 @@ const info = ref(null)
const query = ref('') const query = ref('')
const text = ref('') const text = ref('')
const showOptions = ref(false) const showOptions = ref(false)
const optionsRef = ref(null)
const highlightIndex = ref(-1)
const metaByEmail = computed(() => { const metaByEmail = computed(() => {
const out = {} const out = {}
@ -179,17 +174,6 @@ function getTooltip(email) {
return parts.length ? parts.join(': ') : email return parts.length ? parts.join(': ') : email
} }
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
if (val) {
showOptions.value = false
}
addValue(val)
},
})
watchDebounced( watchDebounced(
query, query,
(val) => { (val) => {
@ -250,6 +234,34 @@ function reload(val) {
filterOptions.reload() filterOptions.reload()
} }
watch(
() => options.value,
() => {
highlightIndex.value = options.value.length ? 0 : -1
},
)
function selectOption(option) {
if (!option) return
addValue(option)
!error.value && (query.value = '')
showOptions.value = false
}
function onKeydown(e) {
if (e.key === 'Enter') {
if (highlightIndex.value >= 0 && options.value[highlightIndex.value]) {
selectOption(options.value[highlightIndex.value])
} else if (query.value) {
// Add entered email directly
selectOption({ name: 'new', label: query.value, value: query.value })
}
e.preventDefault()
} else if (e.key === 'Escape') {
showOptions.value = false
}
}
const addValue = (option) => { const addValue = (option) => {
// Safeguard for falsy option // Safeguard for falsy option
if (!option || !option.value) return if (!option || !option.value) return
@ -285,34 +297,23 @@ const addValue = (option) => {
current.push(entry) current.push(entry)
} }
if (!error.value) {
values.value = current values.value = current
// Scroll to the bottom so the last added value is visible
nextTick(() => {
// use requestAnimationFrame to ensure DOM paint
requestAnimationFrame(() => {
const el = optionsRef.value
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
} }
})
})
} }
const removeValue = (email) => { const removeValue = (email) => {
values.value = (values.value || []).filter((a) => a.email !== email) values.value = (values.value || []).filter((a) => a.email !== email)
} }
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() { function setFocus() {
search.value.$el.focus() search.value.$el.focus()
} }

View File

@ -294,6 +294,7 @@
</div> </div>
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" /> <div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
<Attendee <Attendee
class="px-4.5 py-[7px]"
v-model="peoples" v-model="peoples"
:validate="validateEmail" :validate="validateEmail"
:error-message=" :error-message="