fix: refactor attendee input to use Combobox component for improved UX
This commit is contained in:
parent
09ff459751
commit
46a7a9c495
@ -1,78 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<!-- Combobox Input -->
|
||||||
class="flex items-center justify-between text-ink-gray-7 [&>div]:w-full"
|
<div class="flex items-center w-full text-ink-gray-8 [&>div]:w-full">
|
||||||
>
|
<ComboboxRoot
|
||||||
<Popover v-model:show="showOptions">
|
:model-value="tempSelection"
|
||||||
<template #target="{ togglePopover }">
|
:open="showOptions"
|
||||||
<TextInput
|
@update:open="(o) => (showOptions = o)"
|
||||||
|
@update:modelValue="onSelect"
|
||||||
|
:ignore-filter="true"
|
||||||
|
>
|
||||||
|
<ComboboxAnchor
|
||||||
|
class="flex w-full text-base items-center gap-1 rounded border border-outline-gray-2 bg-surface-white hover:border-outline-gray-3 focus:border-outline-gray-4 focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 px-2 py-1"
|
||||||
|
:class="[size === 'sm' ? 'h-7' : 'h-8 ', inputClass]"
|
||||||
|
@click="showOptions = true"
|
||||||
|
>
|
||||||
|
<ComboboxInput
|
||||||
ref="search"
|
ref="search"
|
||||||
type="text"
|
autocomplete="off"
|
||||||
:size="size"
|
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full focus:outline-none focus:ring-0 focus:border-0"
|
||||||
class="w-full"
|
|
||||||
variant="outline"
|
|
||||||
v-model="query"
|
|
||||||
:debounce="300"
|
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
@click="togglePopover"
|
:value="query"
|
||||||
@keydown="onKeydown"
|
@input="onInput"
|
||||||
|
@keydown.enter.prevent="handleEnter"
|
||||||
|
@keydown.escape.stop="showOptions = false"
|
||||||
|
/>
|
||||||
|
<FeatherIcon
|
||||||
|
name="chevron-down"
|
||||||
|
class="h-4 text-ink-gray-5 cursor-pointer"
|
||||||
|
@click.stop="showOptions = !showOptions"
|
||||||
|
/>
|
||||||
|
</ComboboxAnchor>
|
||||||
|
<ComboboxPortal>
|
||||||
|
<ComboboxContent
|
||||||
|
class="z-10 mt-1 min-w-48 w-full max-w-md bg-surface-modal overflow-hidden rounded-lg shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||||
|
position="popper"
|
||||||
|
:align="'start'"
|
||||||
|
@openAutoFocus.prevent
|
||||||
|
@closeAutoFocus.prevent
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
|
||||||
<FeatherIcon
|
<ComboboxEmpty
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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 }"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</ul>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
||||||
>
|
>
|
||||||
<FeatherIcon v-if="fetchContacts" name="search" class="h-4" />
|
<FeatherIcon v-if="fetchContacts" name="search" class="h-4" />
|
||||||
{{
|
{{ emptyStateText }}
|
||||||
fetchContacts
|
</ComboboxEmpty>
|
||||||
? __('No results found')
|
<ComboboxItem
|
||||||
: __('Type an email address to add attendee')
|
v-for="option in options"
|
||||||
}}
|
:key="option.value"
|
||||||
</div>
|
:value="option.value"
|
||||||
</div>
|
class="text-base leading-none text-ink-gray-7 rounded flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
|
||||||
</div>
|
@mousedown.prevent="onSelect(option.value, option)"
|
||||||
</template>
|
>
|
||||||
</Popover>
|
<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>
|
||||||
|
</ComboboxItem>
|
||||||
|
</ComboboxViewport>
|
||||||
|
</ComboboxContent>
|
||||||
|
</ComboboxPortal>
|
||||||
|
</ComboboxRoot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Attendees -->
|
||||||
<div
|
<div
|
||||||
v-if="values.length"
|
v-if="values.length"
|
||||||
class="flex flex-col gap-2 mt-2 max-h-[165px] overflow-y-auto"
|
class="flex flex-col gap-2 mt-2 max-h-[165px] overflow-y-auto"
|
||||||
@ -105,8 +97,18 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { createResource, TextInput, Popover } from 'frappe-ui'
|
import { createResource } from 'frappe-ui'
|
||||||
import { ref, computed, nextTick, watch } from 'vue'
|
import {
|
||||||
|
ComboboxRoot,
|
||||||
|
ComboboxAnchor,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxPortal,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxViewport,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxEmpty,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { ref, computed, nextTick } from 'vue'
|
||||||
import { watchDebounced } from '@vueuse/core'
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -154,7 +156,7 @@ const query = ref('')
|
|||||||
const text = ref('')
|
const text = ref('')
|
||||||
const showOptions = ref(false)
|
const showOptions = ref(false)
|
||||||
const optionsRef = ref(null)
|
const optionsRef = ref(null)
|
||||||
const highlightIndex = ref(-1)
|
const tempSelection = ref(null)
|
||||||
|
|
||||||
const metaByEmail = computed(() => {
|
const metaByEmail = computed(() => {
|
||||||
const out = {}
|
const out = {}
|
||||||
@ -225,6 +227,12 @@ const options = computed(() => {
|
|||||||
return searchedContacts || []
|
return searchedContacts || []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emptyStateText = computed(() =>
|
||||||
|
props.fetchContacts
|
||||||
|
? __('No results found')
|
||||||
|
: __('Type an email address to add attendee'),
|
||||||
|
)
|
||||||
|
|
||||||
function reload(val) {
|
function reload(val) {
|
||||||
if (!props.fetchContacts) return
|
if (!props.fetchContacts) return
|
||||||
|
|
||||||
@ -234,34 +242,38 @@ function reload(val) {
|
|||||||
filterOptions.reload()
|
filterOptions.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
function onSelect(val, fullOption = null) {
|
||||||
() => options.value,
|
if (!val) return
|
||||||
() => {
|
const optionObj = fullOption ||
|
||||||
highlightIndex.value = options.value.length ? 0 : -1
|
options.value.find((o) => o.value === val) || {
|
||||||
},
|
name: 'new',
|
||||||
)
|
label: val,
|
||||||
|
value: val,
|
||||||
function selectOption(option) {
|
}
|
||||||
if (!option) return
|
addValue(optionObj)
|
||||||
addValue(option)
|
if (!error.value) {
|
||||||
!error.value && (query.value = '')
|
query.value = ''
|
||||||
showOptions.value = false
|
tempSelection.value = null
|
||||||
|
showOptions.value = false
|
||||||
|
nextTick(() => setFocus())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e) {
|
function handleEnter() {
|
||||||
if (e.key === 'Enter') {
|
if (query.value) {
|
||||||
if (highlightIndex.value >= 0 && options.value[highlightIndex.value]) {
|
onSelect(query.value, {
|
||||||
selectOption(options.value[highlightIndex.value])
|
name: 'new',
|
||||||
} else if (query.value) {
|
label: query.value,
|
||||||
// Add entered email directly
|
value: query.value,
|
||||||
selectOption({ name: 'new', label: query.value, value: query.value })
|
})
|
||||||
}
|
|
||||||
e.preventDefault()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
showOptions.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onInput(e) {
|
||||||
|
query.value = e.target.value
|
||||||
|
showOptions.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const addValue = (option) => {
|
const addValue = (option) => {
|
||||||
// Safeguard for falsy option
|
// Safeguard for falsy option
|
||||||
if (!option || !option.value) return
|
if (!option || !option.value) return
|
||||||
@ -315,7 +327,7 @@ const removeValue = (email) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setFocus() {
|
function setFocus() {
|
||||||
search.value.$el.focus()
|
search.value?.focus?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ setFocus })
|
defineExpose({ setFocus })
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user