fix: refactor Attendee component for improved search functionality and option selection
This commit is contained in:
parent
da7ee0926f
commit
52d99ebf20
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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="
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user