374 lines
11 KiB
Vue
374 lines
11 KiB
Vue
<template>
|
|
<div
|
|
ref="select"
|
|
class="v-select relative"
|
|
:class="[{ 'w-0': multiple, 'min-w-full': multiple }]"
|
|
>
|
|
<div
|
|
class="inline-block w-full flex overflow-hidden"
|
|
:style="inputStyle"
|
|
:class="[
|
|
theme.SelectInput.input,
|
|
theme.SelectInput.borderRadius,
|
|
{ '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed dark:!bg-gray-600 !bg-gray-200': disabled },
|
|
inputClass
|
|
]"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-haspopup="listbox"
|
|
aria-expanded="true"
|
|
aria-labelledby="listbox-label"
|
|
class="cursor-pointer w-full flex-grow relative"
|
|
:class="[
|
|
theme.SelectInput.spacing.horizontal,
|
|
theme.SelectInput.spacing.vertical
|
|
]"
|
|
@click="toggleDropdown"
|
|
>
|
|
<div
|
|
class="flex items-center"
|
|
:class="[
|
|
theme.SelectInput.minHeight
|
|
]"
|
|
>
|
|
<transition
|
|
name="fade"
|
|
mode="out-in"
|
|
>
|
|
<Loader
|
|
v-if="loading"
|
|
key="loader"
|
|
class="h-6 w-6 text-nt-blue mx-auto"
|
|
/>
|
|
<div
|
|
v-else-if="modelValue"
|
|
key="value"
|
|
class="flex"
|
|
>
|
|
<slot
|
|
name="selected"
|
|
:option="modelValue"
|
|
:toggle="select"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-else
|
|
key="placeholder"
|
|
>
|
|
<slot name="placeholder">
|
|
<div
|
|
class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3"
|
|
:class="[
|
|
{ 'py-1': multiple && !loading },
|
|
theme.SelectInput.fontSize
|
|
]"
|
|
>
|
|
{{ placeholder }}
|
|
</div>
|
|
</slot>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
|
<Icon
|
|
name="heroicons:chevron-up-down-16-solid"
|
|
class="h-5 w-5 text-gray-500"
|
|
/>
|
|
</span>
|
|
</button>
|
|
<button
|
|
v-if="clearable && !isEmpty"
|
|
class="hover:bg-gray-50 dark:hover:bg-gray-900 border-l px-2"
|
|
:class="[theme.SelectInput.spacing.vertical]"
|
|
@click.prevent="clear()"
|
|
>
|
|
<Icon
|
|
name="heroicons:x-mark-20-solid"
|
|
class="w-5 h-5 text-gray-500"
|
|
width="2em"
|
|
dynamic
|
|
/>
|
|
</button>
|
|
</div>
|
|
<collapsible
|
|
v-model="isOpen"
|
|
class="absolute mt-1 bg-white overflow-auto dark:bg-notion-dark-light shadow-xl z-30"
|
|
:class="[dropdownClass,theme.SelectInput.dropdown, theme.SelectInput.borderRadius]"
|
|
@click-away="onClickAway"
|
|
>
|
|
<ul
|
|
tabindex="-1"
|
|
role="listbox"
|
|
class="leading-6 shadow-xs overflow-auto focus:outline-none sm:text-sm sm:leading-5 relative"
|
|
:class="[
|
|
{ 'max-h-42': !isSearchable, 'max-h-48': isSearchable },
|
|
theme.SelectInput.fontSize
|
|
]"
|
|
>
|
|
<div
|
|
v-if="isSearchable"
|
|
class="sticky top-0 z-10 flex border-b border-gray-300"
|
|
>
|
|
<input
|
|
v-model="searchTerm"
|
|
type="text"
|
|
class="flex-grow pl-3 pr-7 py-2 w-full focus:outline-none dark:text-white"
|
|
placeholder="Search"
|
|
>
|
|
<div
|
|
v-if="!searchTerm"
|
|
class="flex absolute right-0 inset-y-0 items-center px-2 justify-center pointer-events-none"
|
|
>
|
|
<Icon
|
|
name="heroicons:magnifying-glass-solid"
|
|
class="h-5 w-5 text-gray-500 dark:text-gray-400"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-else
|
|
role="button"
|
|
class="flex absolute right-0 inset-y-0 items-center px-2 justify-center"
|
|
@click="searchTerm = ''"
|
|
>
|
|
<Icon
|
|
name="heroicons:backspace"
|
|
class="h-5 w-5 text-gray-500 dark:text-gray-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="loading"
|
|
class="w-full py-2 flex justify-center"
|
|
>
|
|
<Loader class="h-6 w-6 text-nt-blue mx-auto" />
|
|
</div>
|
|
<div
|
|
v-if="filteredOptions.length > 0"
|
|
class="p-1"
|
|
>
|
|
<li
|
|
v-for="item in filteredOptions"
|
|
:key="item[optionKey]"
|
|
role="option"
|
|
:style="optionStyle"
|
|
:class="[
|
|
dropdownClass,
|
|
theme.SelectInput.option,
|
|
theme.SelectInput.spacing.horizontal,
|
|
theme.SelectInput.spacing.vertical,
|
|
{ 'pr-9': multiple},
|
|
]"
|
|
class="text-gray-900 select-none relative cursor-pointer group hover:bg-gray-100 dark:hover:bg-gray-900 rounded focus:outline-none"
|
|
@click="select(item)"
|
|
>
|
|
<slot
|
|
name="option"
|
|
:option="item"
|
|
:selected="isSelected(item)"
|
|
/>
|
|
</li>
|
|
</div>
|
|
<p
|
|
v-else-if="!loading && !(allowCreation && searchTerm)"
|
|
class="w-full text-gray-500 text-center py-2"
|
|
>
|
|
{{ (allowCreation ? 'Type something to add an option' : 'No option available') }}.
|
|
</p>
|
|
<div
|
|
v-if="allowCreation && searchTerm"
|
|
class="border-t border-gray-300 p-1"
|
|
>
|
|
<li
|
|
role="option"
|
|
:style="optionStyle"
|
|
:class="[{ 'px-3 pr-9': multiple, 'px-3': !multiple },dropdownClass,theme.SelectInput.option]"
|
|
class="text-gray-900 select-none relative py-2 cursor-pointer group hover:bg-gray-100 dark:hover:bg-gray-900 rounded focus:outline-none"
|
|
@click="createOption(searchTerm)"
|
|
>
|
|
Create <span class="px-2 bg-gray-100 border border-gray-300 rounded group-hover-text-black">{{
|
|
searchTerm
|
|
}}</span>
|
|
</li>
|
|
</div>
|
|
</ul>
|
|
</collapsible>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import Collapsible from '~/components/global/transitions/Collapsible.vue'
|
|
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
|
import debounce from 'debounce'
|
|
import Fuse from 'fuse.js'
|
|
|
|
export default {
|
|
name: 'VSelect',
|
|
components: {Collapsible},
|
|
directives: {},
|
|
props: {
|
|
data: Array,
|
|
modelValue: {default: null, type: [String, Number, Array, Object]},
|
|
inputClass: {type: String, default: null},
|
|
dropdownClass: {type: String, default: 'w-full'},
|
|
loading: {type: Boolean, default: false},
|
|
required: {type: Boolean, default: false},
|
|
multiple: {type: Boolean, default: false},
|
|
searchable: {type: Boolean, default: false},
|
|
clearable: {type: Boolean, default: false},
|
|
hasError: {type: Boolean, default: false},
|
|
remote: {type: Function, default: null},
|
|
searchKeys: {type: Array, default: () => ['name']},
|
|
optionKey: {type: String, default: 'id'},
|
|
emitKey: {type: String, default: null},
|
|
color: {type: String, default: '#3B82F6'},
|
|
placeholder: {type: String, default: null},
|
|
uppercaseLabels: {type: Boolean, default: true},
|
|
theme: {
|
|
type: Object, default: () => {
|
|
const theme = inject("theme", null)
|
|
if (theme) {
|
|
return theme.value
|
|
}
|
|
return CachedDefaultTheme.getInstance()
|
|
}
|
|
},
|
|
allowCreation: {type: Boolean, default: false},
|
|
disabled: {type: Boolean, default: false}
|
|
},
|
|
emits: ['update:modelValue', 'update-options'],
|
|
data() {
|
|
return {
|
|
isOpen: false,
|
|
searchTerm: '',
|
|
defaultValue: this.modelValue ?? null
|
|
}
|
|
},
|
|
computed: {
|
|
optionStyle() {
|
|
return {
|
|
'--bg-form-color': this.color
|
|
}
|
|
},
|
|
inputStyle() {
|
|
return {
|
|
'--tw-ring-color': this.color
|
|
}
|
|
},
|
|
debouncedRemote() {
|
|
if (this.remote) {
|
|
return debounce(this.remote, 300)
|
|
}
|
|
return null
|
|
},
|
|
filteredOptions() {
|
|
if (!this.data) return []
|
|
if (!this.searchable || this.remote || this.searchTerm === '') {
|
|
return this.data
|
|
}
|
|
|
|
// Fuse search
|
|
const fuse = new Fuse(this.data, {
|
|
keys: this.searchKeys,
|
|
includeScore: true
|
|
})
|
|
return fuse.search(this.searchTerm).filter((res) => res.score < 0.5).map((res) => {
|
|
return res.item
|
|
})
|
|
},
|
|
isSearchable() {
|
|
return this.searchable || this.remote !== null || this.allowCreation
|
|
},
|
|
isEmpty() {
|
|
return this.multiple ? !this.modelValue || this.modelValue.length === 0 : !this.modelValue
|
|
}
|
|
},
|
|
watch: {
|
|
searchTerm(val) {
|
|
if (!this.debouncedRemote) return
|
|
if ((this.remote && val) || (val === '' && !this.modelValue) || (val === '' && this.isOpen)) {
|
|
return this.debouncedRemote(val)
|
|
}
|
|
}
|
|
},
|
|
methods: {
|
|
onClickAway(event) {
|
|
// Check that event target isn't children of dropdown
|
|
if (this.$refs.select && !this.$refs.select.contains(event.target)) {
|
|
this.isOpen = false
|
|
}
|
|
},
|
|
isSelected(value) {
|
|
if (!this.modelValue) return false
|
|
|
|
if (this.emitKey && value[this.emitKey]) {
|
|
value = value[this.emitKey]
|
|
}
|
|
|
|
if (this.multiple) {
|
|
return this.modelValue.includes(value)
|
|
}
|
|
return this.modelValue === value
|
|
},
|
|
toggleDropdown() {
|
|
if (this.disabled) {
|
|
this.isOpen = false
|
|
} else {
|
|
this.isOpen = !this.isOpen
|
|
}
|
|
if (!this.isOpen) {
|
|
this.searchTerm = ''
|
|
}
|
|
},
|
|
select(value) {
|
|
if (!this.multiple) {
|
|
// Close after select
|
|
this.toggleDropdown()
|
|
}
|
|
|
|
if (this.emitKey) {
|
|
value = value[this.emitKey]
|
|
}
|
|
|
|
if (this.multiple) {
|
|
const emitValue = Array.isArray(this.modelValue) ? [...this.modelValue] : []
|
|
|
|
if (this.isSelected(value)) {
|
|
this.$emit('update:modelValue', emitValue.filter((item) => {
|
|
if (this.emitKey) {
|
|
return item !== value
|
|
}
|
|
return item[this.optionKey] !== value && item[this.optionKey] !== value[this.optionKey]
|
|
}))
|
|
return
|
|
}
|
|
|
|
emitValue.push(value)
|
|
this.$emit('update:modelValue', emitValue)
|
|
} else {
|
|
if (this.modelValue === value) {
|
|
this.$emit('update:modelValue', this.defaultValue ?? null)
|
|
} else {
|
|
this.$emit('update:modelValue', value)
|
|
}
|
|
}
|
|
},
|
|
clear() {
|
|
this.$emit('update:modelValue', this.multiple ? [] : null)
|
|
},
|
|
createOption(newOption) {
|
|
if (newOption) {
|
|
const newItem = {
|
|
name: newOption,
|
|
value: newOption,
|
|
id: newOption
|
|
}
|
|
this.$emit('update-options', newItem)
|
|
this.select(newItem)
|
|
this.searchTerm = ''
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|