This repository has been archived on 2024-02-08. You can view files and clone it, but cannot push or open issues or pull requests.
frontend/src/components/input/multiselect.vue

355 lines
8.6 KiB
Vue

<template>
<div
class="multiselect"
:class="{'has-search-results': searchResultsVisible}"
ref="multiselectRoot"
tabindex="-1"
@focus="focus"
>
<div class="control" :class="{'is-loading': loading || localLoading}">
<div class="input-wrapper input" :class="{'has-multiple': multiple && Array.isArray(internalValue) && internalValue.length > 0}">
<template v-if="Array.isArray(internalValue)">
<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 }}
<a @click="() => remove(item)" class="delete is-small"></a>
</span>
</slot>
</template>
</template>
<input
type="text"
class="input"
v-model="query"
@keyup="search"
@keyup.enter.exact.prevent="() => createOrSelectOnEnter()"
:placeholder="placeholder"
@keydown.down.exact.prevent="() => preSelect(0)"
ref="searchInput"
@focus="() => showSearchResults = true"
/>
</div>
</div>
<transition name="fade">
<div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible">
<button
class="is-fullwidth"
v-for="(data, key) in filteredSearchResults"
:key="key"
:ref="`result-${key}`"
@keydown.up.prevent="() => preSelect(key - 1)"
@keydown.down.prevent="() => preSelect(key + 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>
</button>
<button
v-if="creatableAvailable"
class="is-fullwidth"
:ref="`result-${filteredSearchResults.length}`"
@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>
</button>
</div>
</transition>
</div>
</template>
<script>
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
/**
* Available events:
* @search: Triggered every time the search query input changes
* @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
* @create: If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
* @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
*/
export default {
name: 'multiselect',
data() {
return {
query: '',
searchTimeout: null,
localLoading: false,
showSearchResults: false,
internalValue: null,
}
},
props: {
// When true, shows a loading spinner
loading: {
type: Boolean,
default() {
return false
}
},
// The placeholder of the search input
placeholder: {
type: String,
default() {
return ''
}
},
// The search results where the @search listener needs to put the results into
searchResults: {
type: Array,
default() {
return []
}
},
// 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: {
type: String,
default() {
return ''
}
},
// The object with the value, updated every time an entry is selected.
value: {
default() {
return null
}
},
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
creatable: {
type: Boolean,
default() {
return false
},
},
// The text shown next to the new value option.
createPlaceholder: {
type: String,
default() {
return 'Create new'
},
},
// The text shown next to an option.
selectPlaceholder: {
type: String,
default() {
return 'Click or press enter to select'
},
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
multiple: {
type: Boolean,
default() {
return false
},
},
// If true, displays the search results inline instead of using a dropdown.
inline: {
type: Boolean,
default() {
return false
},
},
// If true, shows search results when no query is specified.
showEmpty: {
type: Boolean,
default() {
return true
},
},
// The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
searchDelay: {
type: Number,
default() {
return 200
},
},
},
mounted() {
document.addEventListener('click', this.hideSearchResultsHandler)
this.setSelectedObject(this.value)
},
beforeDestroy() {
document.removeEventListener('click', this.hideSearchResultsHandler)
},
watch: {
value(newVal) {
this.setSelectedObject(newVal)
},
},
computed: {
searchResultsVisible() {
if (this.query === '' && !this.showEmpty) {
return false
}
return this.showSearchResults && (
(this.filteredSearchResults.length > 0) ||
(this.creatable && this.query !== '')
)
},
creatableAvailable() {
return this.creatable && this.query !== '' && !this.filteredSearchResults.some(elem => {
// Don't make create available if we have an exact match in our search results.
if (this.label !== '') {
return elem[this.label] === this.query
}
return elem === this.query
})
},
filteredSearchResults() {
if (this.multiple && this.internalValue !== null) {
return this.searchResults.filter(item => !this.internalValue.some(e => e === item))
}
return this.searchResults
},
},
methods: {
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
search() {
// Updating the query with a binding does not work on mobile for some reason,
// getting the value manual does.
this.query = this.$refs.searchInput.value
if (this.searchTimeout !== null) {
clearTimeout(this.searchTimeout)
this.searchTimeout = null
}
this.localLoading = true
this.searchTimeout = setTimeout(() => {
this.$emit('search', this.query)
setTimeout(() => {
this.localLoading = false
}, 100) // The duration of the loading timeout of the services
this.showSearchResults = true
}, this.searchDelay)
},
hideSearchResultsHandler(e) {
closeWhenClickedOutside(e, this.$refs.multiselectRoot, this.closeSearchResults)
},
closeSearchResults() {
this.showSearchResults = false
},
select(object) {
if (this.multiple) {
if (this.internalValue === null) {
this.internalValue = []
}
this.internalValue.push(object)
} else {
this.internalValue = object
}
this.$emit('input', this.internalValue)
this.$emit('select', object)
this.setSelectedObject(object)
this.closeSearchResults()
},
setSelectedObject(object, resetOnly = false) {
this.$set(this, 'internalValue', object)
// We assume we're getting an array when multiple is enabled and can therefore leave the query
// value etc as it is
if (this.multiple) {
this.query = ''
return
}
if (object === null) {
this.query = ''
return
}
if (resetOnly) {
return
}
this.query = this.label !== '' ? object[this.label] : object
},
preSelect(index) {
if (index < 0) {
this.$refs.searchInput.focus()
return
}
const elems = this.$refs[`result-${index}`]
if (typeof elems === 'undefined' || elems.length === 0) {
return
}
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems.focus()
},
create() {
if (this.query === '') {
return
}
this.$emit('create', this.query)
this.setSelectedObject(this.query, true)
this.closeSearchResults()
},
createOrSelectOnEnter() {
if (!this.creatableAvailable && this.searchResults.length === 1) {
this.select(this.searchResults[0])
return
}
if (!this.creatableAvailable) {
return
}
this.create()
},
remove(item) {
for (const ind in this.internalValue) {
if (this.internalValue[ind] === item) {
this.internalValue.splice(ind, 1)
break
}
}
this.$emit('input', this.internalValue)
this.$emit('remove', item)
},
focus() {
this.$refs.searchInput.focus()
},
},
}
</script>