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>
<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">
<template #target="{ togglePopover }">
<TextInput
ref="search"
type="text"
size="md"
class="w-full"
variant="outline"
v-model="query"
:debounce="300"
:placeholder="placeholder"
@click="togglePopover"
@keydown.delete.capture.stop="removeLastValue"
<Popover v-model:show="showOptions">
<template #target="{ togglePopover }">
<TextInput
ref="search"
type="text"
:size="size"
class="w-full"
variant="outline"
v-model="query"
:debounce="300"
:placeholder="placeholder"
@click="togglePopover"
@keydown="onKeydown"
>
<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>
<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"
<ul
v-if="options.length"
role="listbox"
class="p-1.5 max-h-[12rem] overflow-y-auto"
>
<ComboboxOptions
class="p-1.5 max-h-[12rem] overflow-y-auto"
static
<li
v-for="(option, idx) in options"
: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
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')
}}
<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>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-3': active },
]"
>
<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>
</li>
</ul>
<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>
</template>
</Popover>
</Combobox>
</div>
</template>
</Popover>
</div>
<div
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
ref="emails"
@ -93,7 +86,6 @@
theme="gray"
class="rounded-full w-fit"
:tooltip="getTooltip(att.email)"
@keydown.delete.capture.stop="removeLastValue"
>
<template #prefix>
<UserAvatar :user="att.email" class="-ml-1 !size-5.5" />
@ -112,13 +104,10 @@
</template>
<script setup>
import { Combobox, ComboboxOptions, ComboboxOption } from '@headlessui/vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Popover from '@/components/frappe-ui/Popover.vue'
import { createResource, TextInput } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue'
import { createResource, TextInput, Popover } from 'frappe-ui'
import { ref, computed, nextTick, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue'
const props = defineProps({
validate: {
@ -129,6 +118,10 @@ const props = defineProps({
type: String,
default: 'subtle',
},
size: {
type: String,
default: 'sm',
},
placeholder: {
type: String,
default: 'Add attendee',
@ -160,6 +153,8 @@ const info = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const optionsRef = ref(null)
const highlightIndex = ref(-1)
const metaByEmail = computed(() => {
const out = {}
@ -179,17 +174,6 @@ function getTooltip(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(
query,
(val) => {
@ -250,6 +234,34 @@ function reload(val) {
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) => {
// Safeguard for falsy option
if (!option || !option.value) return
@ -285,34 +297,23 @@ const addValue = (option) => {
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) => {
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() {
search.value.$el.focus()
}

View File

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