opnform-host-nginx/client/components/forms/components/VSelect.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>