feat: multiselect script setup (#2458)

Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/2458
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
Dominik Pschenitschni 2022-10-02 09:58:51 +00:00 committed by konrad
parent ff1968aa36
commit 0620b8f0b3

View file

@ -39,11 +39,11 @@
<div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible"> <div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible">
<BaseButton <BaseButton
class="is-fullwidth" class="is-fullwidth"
v-for="(data, key) in filteredSearchResults" v-for="(data, index) in filteredSearchResults"
:key="key" :key="index"
:ref="`result-${key}`" :ref="(el) => setResult(el, index)"
@keydown.up.prevent="() => preSelect(key - 1)" @keydown.up.prevent="() => preSelect(index - 1)"
@keydown.down.prevent="() => preSelect(key + 1)" @keydown.down.prevent="() => preSelect(index + 1)"
@click.prevent.stop="() => select(data)" @click.prevent.stop="() => select(data)"
> >
<span> <span>
@ -59,7 +59,7 @@
<BaseButton <BaseButton
v-if="creatableAvailable" v-if="creatableAvailable"
class="is-fullwidth" class="is-fullwidth"
:ref="`result-${filteredSearchResults.length}`" :ref="(el) => setResult(el, filteredSearchResults.length)"
@keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)" @keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)"
@keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)" @keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)"
@keyup.enter.prevent="create" @keyup.enter.prevent="create"
@ -82,9 +82,10 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import {defineComponent} from 'vue' import {computed, onBeforeUnmount, onMounted, ref, toRefs, watch, type ComponentPublicInstance, type PropType} from 'vue'
import {i18n} from '@/i18n' import {useI18n} from 'vue-i18n'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside' import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
@ -98,55 +99,31 @@ function elementInResults(elem: string | any, label: string, query: string): boo
return elem === query return elem === query
} }
export default defineComponent({ const props = defineProps({
name: 'multiselect',
components: {
BaseButton,
},
data() {
return {
query: '',
searchTimeout: null,
localLoading: false,
showSearchResults: false,
internalValue: null,
}
},
props: {
// When true, shows a loading spinner // When true, shows a loading spinner
loading: { loading: {
type: Boolean, type: Boolean,
default() { default: false,
return false
},
}, },
// The placeholder of the search input // The placeholder of the search input
placeholder: { placeholder: {
type: String, type: String,
default() { default: '',
return ''
},
}, },
// The search results where the @search listener needs to put the results into // The search results where the @search listener needs to put the results into
searchResults: { searchResults: {
type: Array, type: Array as PropType<{[id: string]: any}>,
default() { default: () => [],
return []
},
}, },
// The name of the property of the searched object to show the user. // 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. // If empty the component will show all raw data of an entry.
label: { label: {
type: String, type: String,
default() { default: '',
return ''
},
}, },
// The object with the value, updated every time an entry is selected. // The object with the value, updated every time an entry is selected.
modelValue: { modelValue: {
default() { default: null,
return null
},
}, },
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it. // If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
creatable: { creatable: {
@ -157,14 +134,16 @@ export default defineComponent({
createPlaceholder: { createPlaceholder: {
type: String, type: String,
default() { default() {
return i18n.global.t('input.multiselect.createPlaceholder') const {t} = useI18n({useScope: 'global'})
return t('input.multiselect.createPlaceholder')
}, },
}, },
// The text shown next to an option. // The text shown next to an option.
selectPlaceholder: { selectPlaceholder: {
type: String, type: String,
default() { default() {
return i18n.global.t('input.multiselect.selectPlaceholder') const {t} = useI18n({useScope: 'global'})
return t('input.multiselect.selectPlaceholder')
}, },
}, },
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case. // If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
@ -185,136 +164,151 @@ export default defineComponent({
// The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke. // The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
searchDelay: { searchDelay: {
type: Number, type: Number,
default() { default: 200,
return 200
},
}, },
closeAfterSelect: { closeAfterSelect: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}, })
/** const emit = defineEmits<{
* Available events: (e: 'update:modelValue', value: null): void
* @search: Triggered every time the search query input changes // @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. (e: 'search', query: string): void
* @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. // @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
* @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items. (e: 'select', value: null): void
*/ // @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.
emits: ['update:modelValue', 'search', 'select', 'create', 'remove'], (e: 'create', query: string): void
// @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
(e: 'remove', value: null): void
}>()
mounted() { const query = ref('')
document.addEventListener('click', this.hideSearchResultsHandler) const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
}, const localLoading = ref(false)
beforeUnmount() { const showSearchResults = ref(false)
document.removeEventListener('click', this.hideSearchResultsHandler) const internalValue = ref<string | {[key: string]: any} | any[] | null>(null)
},
watch: { onMounted(() => document.addEventListener('click', hideSearchResultsHandler))
modelValue: { onBeforeUnmount(() => document.removeEventListener('click', hideSearchResultsHandler))
handler(value) {
this.setSelectedObject(value) const {modelValue, searchResults} = toRefs(props)
},
watch(
modelValue,
(value) => setSelectedObject(value),
{
immediate: true, immediate: true,
deep: true, deep: true,
}, },
}, )
computed: {
searchResultsVisible() { const searchResultsVisible = computed(() => {
if (this.query === '' && !this.showEmpty) { if (query.value === '' && !props.showEmpty) {
return false return false
} }
return this.showSearchResults && ( return showSearchResults.value && (
(this.filteredSearchResults.length > 0) || (filteredSearchResults.value.length > 0) ||
(this.creatable && this.query !== '') (props.creatable && query.value !== '')
) )
}, })
creatableAvailable() {
const hasResult = this.filteredSearchResults.some(elem => elementInResults(elem, this.label, this.query))
const hasQueryAlreadyAdded = Array.isArray(this.internalValue) && this.internalValue.some(elem => elementInResults(elem, this.label, this.query))
return this.creatable const creatableAvailable = computed(() => {
&& this.query !== '' 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) && !(hasResult || hasQueryAlreadyAdded)
}, })
filteredSearchResults() {
if (this.multiple && this.internalValue !== null && Array.isArray(this.internalValue)) { const filteredSearchResults = computed(() => {
return this.searchResults.filter(item => !this.internalValue.some(e => e === item)) const currentInternal = internalValue.value
if (props.multiple && currentInternal !== null && Array.isArray(currentInternal)) {
return searchResults.value.filter((item: any) => !currentInternal.some(e => e === item))
} }
return this.searchResults return searchResults.value
}, })
hasMultiple() {
return this.multiple && Array.isArray(this.internalValue) && this.internalValue.length > 0 const hasMultiple = computed(() => {
}, return props.multiple && Array.isArray(internalValue.value) && internalValue.value.length > 0
}, })
methods: {
const searchInput = ref<HTMLInputElement | null>(null)
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event. // Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
search() { function search() {
// Updating the query with a binding does not work on mobile for some reason, // Updating the query with a binding does not work on mobile for some reason,
// getting the value manual does. // getting the value manual does.
this.query = this.$refs.searchInput.value query.value = searchInput.value?.value || ''
if (this.searchTimeout !== null) { if (searchTimeout.value !== null) {
clearTimeout(this.searchTimeout) clearTimeout(searchTimeout.value)
this.searchTimeout = null searchTimeout.value = null
} }
this.localLoading = true localLoading.value = true
this.searchTimeout = setTimeout(() => { searchTimeout.value = setTimeout(() => {
this.$emit('search', this.query) emit('search', query.value)
setTimeout(() => { setTimeout(() => {
this.localLoading = false localLoading.value = false
}, 100) // The duration of the loading timeout of the services }, 100) // The duration of the loading timeout of the services
this.showSearchResults = true showSearchResults.value = true
}, this.searchDelay) }, props.searchDelay)
}, }
hideSearchResultsHandler(e) {
closeWhenClickedOutside(e, this.$refs.multiselectRoot, this.closeSearchResults) const multiselectRoot = ref<HTMLElement | null>(null)
}, function hideSearchResultsHandler(e: MouseEvent) {
closeSearchResults() { closeWhenClickedOutside(e, multiselectRoot.value, closeSearchResults)
this.showSearchResults = false }
},
handleFocus() { function closeSearchResults() {
showSearchResults.value = false
}
function handleFocus() {
// We need the timeout to avoid the hideSearchResultsHandler hiding the search results right after the input // 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. // is focused. That would lead to flickering pre-loaded search results and hiding them right after showing.
setTimeout(() => { setTimeout(() => {
this.showSearchResults = true showSearchResults.value = true
}, 10) }, 10)
},
select(object) {
if (this.multiple) {
if (this.internalValue === null) {
this.internalValue = []
} }
this.internalValue.push(object) function select(object: {[key: string]: any}) {
if (props.multiple) {
if (internalValue.value === null) {
internalValue.value = []
}
(internalValue.value as any[]).push(object)
} else { } else {
this.internalValue = object internalValue.value = object
} }
this.$emit('update:modelValue', this.internalValue) emit('update:modelValue', internalValue.value)
this.$emit('select', object) emit('select', object)
this.setSelectedObject(object) setSelectedObject(object)
if (this.closeAfterSelect && this.filteredSearchResults.length > 0 && !this.creatableAvailable) { if (props.closeAfterSelect && filteredSearchResults.value.length > 0 && !creatableAvailable.value) {
this.closeSearchResults() closeSearchResults()
} }
}, }
setSelectedObject(object, resetOnly = false) {
this.internalValue = object 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 // We assume we're getting an array when multiple is enabled and can therefore leave the query
// value etc as it is // value etc as it is
if (this.multiple) { if (props.multiple) {
this.query = '' query.value = ''
return return
} }
if (object === null) { if (object === null) {
this.query = '' query.value = ''
return return
} }
@ -322,15 +316,25 @@ export default defineComponent({
return return
} }
this.query = this.label !== '' ? object[this.label] : object query.value = props.label !== '' ? object[props.label] : object
}, }
preSelect(index) {
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) { if (index < 0) {
this.$refs.searchInput.focus() searchInput.value?.focus()
return return
} }
const elems = this.$refs[`result-${index}`] const elems = results.value[index]
if (typeof elems === 'undefined' || elems.length === 0) { if (typeof elems === 'undefined' || elems.length === 0) {
return return
} }
@ -341,51 +345,52 @@ export default defineComponent({
} }
elems.focus() elems.focus()
}, }
create() {
if (this.query === '') { function create() {
if (query.value === '') {
return return
} }
this.$emit('create', this.query) emit('create', query.value)
this.setSelectedObject(this.query, true) setSelectedObject(query.value, true)
this.closeSearchResults() closeSearchResults()
}, }
createOrSelectOnEnter() {
if (!this.creatableAvailable && this.searchResults.length === 1) { function createOrSelectOnEnter() {
this.select(this.searchResults[0]) if (!creatableAvailable.value && searchResults.value.length === 1) {
select(searchResults.value[0])
return return
} }
if (!this.creatableAvailable) { if (!creatableAvailable.value) {
// Check if there's an exact match for our search term // Check if there's an exact match for our search term
const exactMatch = this.filteredSearchResults.find(elem => elementInResults(elem, this.label, this.query)) const exactMatch = filteredSearchResults.value.find((elem: any) => elementInResults(elem, props.label, query.value))
if(exactMatch) { if(exactMatch) {
this.select(exactMatch) select(exactMatch)
} }
return return
} }
this.create() create()
}, }
remove(item) {
for (const ind in this.internalValue) { function remove(item: any) {
if (this.internalValue[ind] === item) { for (const ind in internalValue.value) {
this.internalValue.splice(ind, 1) if (internalValue.value[ind] === item) {
internalValue.value.splice(ind, 1)
break break
} }
} }
this.$emit('update:modelValue', this.internalValue) emit('update:modelValue', internalValue.value)
this.$emit('remove', item) emit('remove', item)
}, }
focus() {
this.$refs.searchInput.focus() function focus() {
}, searchInput.value?.focus()
}, }
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>