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,89 +1,82 @@
<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="size"
size="md" 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="onKeydown"
@keydown.delete.capture.stop="removeLastValue" >
<template #suffix>
<FeatherIcon
name="chevron-down"
class="h-4 text-ink-gray-5"
@click.stop="togglePopover()"
/>
</template>
</TextInput>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
> >
<template #suffix> <ul
<FeatherIcon v-if="options.length"
name="chevron-down" role="listbox"
class="h-4 text-ink-gray-5" class="p-1.5 max-h-[12rem] overflow-y-auto"
@click.stop="togglePopover()"
/>
</template>
</TextInput>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
> >
<ComboboxOptions <li
class="p-1.5 max-h-[12rem] overflow-y-auto" v-for="(option, idx) in options"
static :key="option.value"
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 }"
> >
<div <UserAvatar class="mr-2" :user="option.value" size="lg" />
v-if="!options.length" <div class="flex flex-col gap-1 p-1 text-ink-gray-8">
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5" <div class="text-base font-medium">
> {{ option.label }}
<FeatherIcon </div>
v-if="fetchContacts" <div class="text-sm text-ink-gray-5">
name="search" {{ option.value }}
class="h-4" </div>
/>
{{
fetchContacts
? __('No results found')
: __('Type an email address to add attendee')
}}
</div> </div>
<ComboboxOption </li>
v-for="option in options" </ul>
:key="option.value" <div
:value="option" v-else
v-slot="{ active }" class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
> >
<li <FeatherIcon v-if="fetchContacts" name="search" class="h-4" />
:class="[ {{
'flex cursor-pointer items-center rounded px-2 py-1 text-base', fetchContacts
{ 'bg-surface-gray-3': active }, ? __('No results found')
]" : __('Type an email address to add attendee')
> }}
<UserAvatar class="mr-2" :user="option.value" size="lg" />
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ option.label }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div> </div>
</div> </div>
</template> </div>
</Popover> </template>
</Combobox> </Popover>
</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="