582 lines
15 KiB
Vue
582 lines
15 KiB
Vue
<template>
|
|
<div
|
|
ref="multiselectRoot"
|
|
class="multiselect"
|
|
:class="{'has-search-results': searchResultsVisible}"
|
|
tabindex="-1"
|
|
@focus="focus"
|
|
>
|
|
<div
|
|
class="control"
|
|
:class="{'is-loading': loading || localLoading}"
|
|
>
|
|
<div
|
|
class="input-wrapper input"
|
|
:class="{'has-multiple': hasMultiple, 'has-removal-button': removalAvailable}"
|
|
>
|
|
<slot
|
|
v-if="Array.isArray(internalValue)"
|
|
name="items"
|
|
:items="internalValue"
|
|
:remove="remove"
|
|
>
|
|
<template v-for="(item, key) in internalValue">
|
|
<slot
|
|
name="tag"
|
|
:item="item"
|
|
>
|
|
<span
|
|
:key="`item${key}`"
|
|
class="tag ml-2 mt-2"
|
|
>
|
|
{{ label !== '' ? item[label] : item }}
|
|
<BaseButton
|
|
class="delete is-small"
|
|
@click="() => remove(item)"
|
|
/>
|
|
</span>
|
|
</slot>
|
|
</template>
|
|
</slot>
|
|
|
|
<input
|
|
:id="id"
|
|
ref="searchInput"
|
|
v-model="query"
|
|
type="text"
|
|
class="input"
|
|
:name="name"
|
|
:placeholder="placeholder"
|
|
:autocomplete="autocompleteEnabled ? undefined : 'off'"
|
|
:spellcheck="autocompleteEnabled ? undefined : 'false'"
|
|
@keyup="search"
|
|
@keyup.enter.exact.prevent="() => createOrSelectOnEnter()"
|
|
@keydown.down.exact.prevent="() => preSelect(0)"
|
|
@focus="handleFocus"
|
|
>
|
|
<BaseButton
|
|
v-if="removalAvailable"
|
|
class="removal-button"
|
|
@click="resetSelectedValue"
|
|
>
|
|
<Icon icon="times" />
|
|
</BaseButton>
|
|
</div>
|
|
</div>
|
|
|
|
<CustomTransition name="fade">
|
|
<div
|
|
v-if="searchResultsVisible"
|
|
class="search-results"
|
|
:class="{'search-results-inline': inline}"
|
|
>
|
|
<BaseButton
|
|
v-for="(data, index) in filteredSearchResults"
|
|
:key="index"
|
|
:ref="(el) => setResult(el, index)"
|
|
class="search-result-button is-fullwidth"
|
|
@keydown.up.prevent="() => preSelect(index - 1)"
|
|
@keydown.down.prevent="() => preSelect(index + 1)"
|
|
@click.prevent.stop="() => select(data)"
|
|
>
|
|
<span>
|
|
<slot
|
|
name="searchResult"
|
|
:option="data"
|
|
>
|
|
<span class="search-result">{{ label !== '' ? data[label] : data }}</span>
|
|
</slot>
|
|
</span>
|
|
<span class="hint-text">
|
|
{{ selectPlaceholder }}
|
|
</span>
|
|
</BaseButton>
|
|
|
|
<BaseButton
|
|
v-if="creatableAvailable"
|
|
:ref="(el) => setResult(el, filteredSearchResults.length)"
|
|
class="search-result-button is-fullwidth"
|
|
@keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)"
|
|
@keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)"
|
|
@keyup.enter.prevent="create"
|
|
@click.prevent.stop="create"
|
|
>
|
|
<span>
|
|
<slot
|
|
name="searchResult"
|
|
:option="query"
|
|
>
|
|
<span class="search-result">
|
|
{{ query }}
|
|
</span>
|
|
</slot>
|
|
</span>
|
|
<span class="hint-text">
|
|
{{ createPlaceholder }}
|
|
</span>
|
|
</BaseButton>
|
|
</div>
|
|
</CustomTransition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, onBeforeUnmount, onMounted, ref, toRefs, watch, type ComponentPublicInstance} from 'vue'
|
|
import {useI18n} from 'vue-i18n'
|
|
|
|
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
|
|
|
import BaseButton from '@/components/base/BaseButton.vue'
|
|
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
/** The object with the value, updated every time an entry is selected */
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
modelValue: { [id: string]: any } | null
|
|
/** When true, shows a loading spinner */
|
|
loading?: boolean
|
|
/** The placeholder of the search input */
|
|
placeholder?: string
|
|
/** The search results where the @search listener needs to put the results into */
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
searchResults?: { [id: string]: any }[]
|
|
/** The name of the property of the searched object to show the user. If empty the component will show all raw data of an entry */
|
|
label?: string
|
|
/** The id attribute of the input element */
|
|
id?: string
|
|
/** The name attribute of the input element */
|
|
name?: string
|
|
/** If true, will provide an 'add this as a new value' entry which fires an @create event when clicking on it. */
|
|
creatable?: boolean
|
|
/** The text shown next to the new value option. */
|
|
createPlaceholder?: string
|
|
/** The text shown next to an option. */
|
|
selectPlaceholder: string
|
|
/** If true, allows for selecting multiple items. v-model will be an array with all selected values in that case. */
|
|
multiple?: boolean
|
|
/** If true, displays the search results inline instead of using a dropdown. */
|
|
inline?: boolean
|
|
/** If true, shows search results when no query is specified. */
|
|
showEmpty?: boolean
|
|
/** The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke. */
|
|
searchDelay?: number
|
|
/** If true, closes the dropdown after an entry is selected */
|
|
closeAfterSelect?: boolean
|
|
/** If false, the search input will get the autocomplete="off" attributes attached to it. */
|
|
autocompleteEnabled?: boolean
|
|
}>(), {
|
|
modelValue: null,
|
|
loading: false,
|
|
placeholder: '',
|
|
searchResults: () => [],
|
|
label: '',
|
|
creatable: false,
|
|
createPlaceholder: () => useI18n().t('input.multiselect.createPlaceholder'),
|
|
selectPlaceholder: () => useI18n().t('input.multiselect.selectPlaceholder'),
|
|
multiple: false,
|
|
inline: false,
|
|
showEmpty: false,
|
|
searchDelay: 200,
|
|
closeAfterSelect: true,
|
|
autocompleteEnabled: true,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
'update:modelValue': [value: { [key: string]: any }[] | null],
|
|
/**
|
|
* Triggered every time the search query input changes
|
|
*/
|
|
'search': [query: string],
|
|
/**
|
|
* Triggered every time an option from the search results is selected. Also triggers a change in v-model.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
'select': [value: { [key: string]: any }],
|
|
/**
|
|
* If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
|
|
*/
|
|
'create': [query: string],
|
|
/**
|
|
* If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
'remove': [value: { [key: string]: any }],
|
|
}>()
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function elementInResults(elem: string | any, label: string, query: string): boolean {
|
|
// Don't make create available if we have an exact match in our search results.
|
|
if (label !== '') {
|
|
return elem[label] === query
|
|
}
|
|
|
|
return elem === query
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const query = ref<string | { [key: string]: any }>('')
|
|
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
|
const localLoading = ref(false)
|
|
const showSearchResults = ref(false)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const internalValue = ref<string | { [key: string]: any } | any[] | null>(null)
|
|
|
|
onMounted(() => document.addEventListener('click', hideSearchResultsHandler))
|
|
onBeforeUnmount(() => document.removeEventListener('click', hideSearchResultsHandler))
|
|
|
|
const {modelValue, searchResults} = toRefs(props)
|
|
|
|
watch(
|
|
modelValue,
|
|
(value) => setSelectedObject(value),
|
|
{
|
|
immediate: true,
|
|
deep: true,
|
|
},
|
|
)
|
|
|
|
const searchResultsVisible = computed(() => {
|
|
if (query.value === '' && !props.showEmpty) {
|
|
return false
|
|
}
|
|
|
|
return showSearchResults.value && (
|
|
(filteredSearchResults.value.length > 0) ||
|
|
(props.creatable && query.value !== '')
|
|
)
|
|
})
|
|
|
|
const creatableAvailable = computed(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const hasResult = filteredSearchResults.value.some((elem: any) => elementInResults(elem, props.label, query.value))
|
|
const hasQueryAlreadyAdded = Array.isArray(internalValue.value) && internalValue.value.some(elem => elementInResults(elem, props.label, query.value))
|
|
|
|
return props.creatable
|
|
&& query.value !== ''
|
|
&& !(hasResult || hasQueryAlreadyAdded)
|
|
})
|
|
|
|
const filteredSearchResults = computed(() => {
|
|
const currentInternal = internalValue.value
|
|
if (props.multiple && currentInternal !== null && Array.isArray(currentInternal)) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return searchResults.value.filter((item: any) => !currentInternal.some(e => e === item))
|
|
}
|
|
|
|
return searchResults.value
|
|
})
|
|
|
|
const hasMultiple = computed(() => {
|
|
return props.multiple && Array.isArray(internalValue.value) && internalValue.value.length > 0
|
|
})
|
|
|
|
const removalAvailable = computed(() => !props.multiple && internalValue.value !== null && query.value !== '' && !(props.loading || localLoading.value))
|
|
function resetSelectedValue() {
|
|
select(null)
|
|
}
|
|
|
|
const searchInput = ref<HTMLInputElement | null>(null)
|
|
|
|
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
|
|
function search() {
|
|
|
|
// Updating the query with a binding does not work on mobile for some reason,
|
|
// getting the value manual does.
|
|
query.value = searchInput.value?.value || ''
|
|
|
|
if (searchTimeout.value !== null) {
|
|
clearTimeout(searchTimeout.value)
|
|
searchTimeout.value = null
|
|
}
|
|
|
|
localLoading.value = true
|
|
|
|
searchTimeout.value = setTimeout(() => {
|
|
emit('search', query.value)
|
|
setTimeout(() => {
|
|
localLoading.value = false
|
|
}, 100) // The duration of the loading timeout of the services
|
|
showSearchResults.value = true
|
|
}, props.searchDelay)
|
|
}
|
|
|
|
const multiselectRoot = ref<HTMLElement | null>(null)
|
|
|
|
function hideSearchResultsHandler(e: MouseEvent) {
|
|
closeWhenClickedOutside(e, multiselectRoot.value, closeSearchResults)
|
|
}
|
|
|
|
function closeSearchResults() {
|
|
showSearchResults.value = false
|
|
}
|
|
|
|
function handleFocus() {
|
|
// We need the timeout to avoid the hideSearchResultsHandler hiding the search results right after the input
|
|
// is focused. That would lead to flickering pre-loaded search results and hiding them right after showing.
|
|
setTimeout(() => {
|
|
showSearchResults.value = true
|
|
}, 10)
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function select(object: { [key: string]: any } | null) {
|
|
if (props.multiple) {
|
|
if (internalValue.value === null) {
|
|
internalValue.value = []
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(internalValue.value as any[]).push(object)
|
|
} else {
|
|
internalValue.value = object
|
|
}
|
|
|
|
emit('update:modelValue', internalValue.value)
|
|
emit('select', object)
|
|
setSelectedObject(object)
|
|
if (props.closeAfterSelect && filteredSearchResults.value.length > 0 && !creatableAvailable.value) {
|
|
closeSearchResults()
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function setSelectedObject(object: string | { [id: string]: any } | null, resetOnly = false) {
|
|
internalValue.value = object
|
|
|
|
// We assume we're getting an array when multiple is enabled and can therefore leave the query
|
|
// value etc as it is
|
|
if (props.multiple) {
|
|
query.value = ''
|
|
return
|
|
}
|
|
|
|
if (object === null) {
|
|
query.value = ''
|
|
return
|
|
}
|
|
|
|
if (resetOnly) {
|
|
return
|
|
}
|
|
|
|
query.value = props.label !== '' ? object[props.label] : object
|
|
}
|
|
|
|
const results = ref<(Element | ComponentPublicInstance)[]>([])
|
|
|
|
function setResult(el: Element | ComponentPublicInstance | null, index: number) {
|
|
if (el === null) {
|
|
delete results.value[index]
|
|
} else {
|
|
results.value[index] = el
|
|
}
|
|
}
|
|
|
|
function preSelect(index: number) {
|
|
if (index < 0) {
|
|
searchInput.value?.focus()
|
|
return
|
|
}
|
|
|
|
const elems = results.value[index]
|
|
if (typeof elems === 'undefined' || elems.length === 0) {
|
|
return
|
|
}
|
|
|
|
if (Array.isArray(elems)) {
|
|
elems[0].focus()
|
|
return
|
|
}
|
|
|
|
elems.focus()
|
|
}
|
|
|
|
function create() {
|
|
if (query.value === '') {
|
|
return
|
|
}
|
|
|
|
emit('create', query.value)
|
|
setSelectedObject(query.value, true)
|
|
closeSearchResults()
|
|
}
|
|
|
|
function createOrSelectOnEnter() {
|
|
if (!creatableAvailable.value && searchResults.value.length === 1) {
|
|
select(searchResults.value[0])
|
|
return
|
|
}
|
|
|
|
if (!creatableAvailable.value) {
|
|
// Check if there's an exact match for our search term
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const exactMatch = filteredSearchResults.value.find((elem: any) => elementInResults(elem, props.label, query.value))
|
|
if (exactMatch) {
|
|
select(exactMatch)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
create()
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function remove(item: any) {
|
|
for (const ind in internalValue.value) {
|
|
if (internalValue.value[ind] === item) {
|
|
internalValue.value.splice(ind, 1)
|
|
break
|
|
}
|
|
}
|
|
|
|
emit('update:modelValue', internalValue.value)
|
|
emit('remove', item)
|
|
}
|
|
|
|
function focus() {
|
|
searchInput.value?.focus()
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.multiselect {
|
|
width: 100%;
|
|
position: relative;
|
|
|
|
.control.is-loading::after {
|
|
top: .75rem;
|
|
}
|
|
}
|
|
|
|
.input-wrapper {
|
|
padding: 0;
|
|
background: var(--white);
|
|
border-color: var(--grey-200);
|
|
flex-wrap: wrap;
|
|
height: auto;
|
|
|
|
&:hover {
|
|
border-color: var(--grey-300) !important;
|
|
}
|
|
|
|
.input {
|
|
display: flex;
|
|
max-width: 100%;
|
|
width: 100%;
|
|
align-items: center;
|
|
border: none !important;
|
|
background: transparent;
|
|
height: auto;
|
|
|
|
&::placeholder {
|
|
font-style: normal !important;
|
|
}
|
|
}
|
|
|
|
&.has-multiple .input {
|
|
max-width: 250px;
|
|
|
|
input {
|
|
padding-left: 0;
|
|
}
|
|
}
|
|
|
|
&:focus-within {
|
|
border-color: var(--primary) !important;
|
|
background: var(--white) !important;
|
|
}
|
|
|
|
// doesn't seem to be used. maybe inside the slot?
|
|
.loader {
|
|
margin: 0 .5rem;
|
|
}
|
|
}
|
|
|
|
.has-search-results .input-wrapper {
|
|
border-radius: $radius $radius 0 0;
|
|
border-color: var(--primary) !important;
|
|
background: var(--white) !important;
|
|
|
|
&, &:focus-within {
|
|
border-bottom-color: var(--grey-200) !important;
|
|
}
|
|
}
|
|
|
|
.search-results {
|
|
background: var(--white);
|
|
border-radius: 0 0 $radius $radius;
|
|
border: 1px solid var(--primary);
|
|
border-top: none;
|
|
|
|
max-height: 50vh;
|
|
overflow-x: auto;
|
|
position: absolute;
|
|
z-index: 100;
|
|
max-width: 100%;
|
|
min-width: 100%;
|
|
}
|
|
|
|
.search-results-inline {
|
|
position: static;
|
|
}
|
|
|
|
.search-result-button {
|
|
background: transparent;
|
|
text-align: left;
|
|
box-shadow: none;
|
|
border-radius: 0;
|
|
text-transform: none;
|
|
font-family: $family-sans-serif;
|
|
font-weight: normal;
|
|
padding: .5rem;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--grey-800);
|
|
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
overflow: hidden;
|
|
|
|
&:focus,
|
|
&:hover {
|
|
background: var(--grey-100);
|
|
box-shadow: none !important;
|
|
|
|
.hint-text {
|
|
color: var(--text);
|
|
}
|
|
}
|
|
|
|
&:active {
|
|
background: var(--grey-200);
|
|
}
|
|
}
|
|
|
|
.search-result {
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
padding: .5rem .75rem;
|
|
}
|
|
|
|
|
|
.hint-text {
|
|
font-size: .75rem;
|
|
color: transparent;
|
|
transition: color $transition;
|
|
padding-left: .5rem;
|
|
}
|
|
|
|
.has-removal-button {
|
|
position: relative;
|
|
}
|
|
|
|
.removal-button {
|
|
position: absolute;
|
|
right: .5rem;
|
|
color: var(--danger);
|
|
}
|
|
</style> |