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:
parent
ff1968aa36
commit
0620b8f0b3
1 changed files with 293 additions and 288 deletions
|
@ -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: {
|
|
||||||
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
|
const searchInput = ref<HTMLInputElement | null>(null)
|
||||||
search() {
|
// 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,
|
// 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) {
|
function select(object: {[key: string]: any}) {
|
||||||
if (this.internalValue === null) {
|
if (props.multiple) {
|
||||||
this.internalValue = []
|
if (internalValue.value === null) {
|
||||||
|
internalValue.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
this.internalValue.push(object)
|
(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>
|
||||||
|
|
Loading…
Reference in a new issue