mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 20:19:34 +01:00
449 lines
12 KiB
Vue
449 lines
12 KiB
Vue
<template>
|
|
<div ref="container">
|
|
<input :value="value" :required="required" class="absolute inset-0 w-px opacity-0 cursor-default">
|
|
|
|
<slot :toggle="toggle" :open="open">
|
|
<TwButton
|
|
icon="solid/selector"
|
|
icon-class="text-tw-gray-400"
|
|
trailing
|
|
:size="size"
|
|
:variant="variant"
|
|
base-class="w-full cursor-default focus:outline-none disabled:cursor-not-allowed disabled:opacity-75"
|
|
:disabled="disabled || !options || !options.length"
|
|
@click.native="!disabled && options && options.length && toggle()"
|
|
>
|
|
<div v-if="selectedOptions && selectedOptions.length" class="inline-flex w-full px-3 py-2 -my-2 -ml-3 truncate">
|
|
<span v-for="(selectedOption, index) of selectedOptions" :key="index" class="inline-flex items-center pr-2">
|
|
<slot name="label" :option="selectedOption">
|
|
<span class="text-tw-gray-700">{{ selectedOption[textAttribute] }}</span>
|
|
</slot>
|
|
</span>
|
|
</div>
|
|
<div v-else class="inline-flex w-full text-tw-gray-400">
|
|
{{ placeholder || '' }}
|
|
</div>
|
|
</TwButton>
|
|
</slot>
|
|
|
|
<transition
|
|
enter-class=""
|
|
enter-active-class=""
|
|
enter-to-class=""
|
|
leave-class="opacity-100"
|
|
leave-active-class="transition duration-100 ease-in"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div v-show="open" ref="tooltip" class="z-10 overflow-hidden bg-white rounded-md shadow-lg dark:bg-gray-800 ring-1 ring-gray-200 dark:ring-gray-700" :class="dropdownClass">
|
|
<div v-if="searchable" class="w-full border-b border-gray-200 dark:border-gray-700">
|
|
<TwInput
|
|
ref="search"
|
|
v-model="q"
|
|
type="search"
|
|
:name="`select-search-${name}`"
|
|
block
|
|
autocomplete="off"
|
|
appearance="none"
|
|
:placeholder="placeholderSearch"
|
|
/>
|
|
</div>
|
|
<ul
|
|
ref="options"
|
|
tabindex="-1"
|
|
role="listbox"
|
|
class="overflow-y-auto max-h-60 sm:text-sm focus:outline-none"
|
|
>
|
|
<li
|
|
v-if="showNewOption"
|
|
ref="option-new"
|
|
role="option"
|
|
class="relative pl-3 pr-12 cursor-default select-none group hover:text-white hover:bg-primary-600"
|
|
:class="{
|
|
'bg-primary-600 text-white': active === -1,
|
|
'text-tw-gray-900': active !== -1,
|
|
'py-2': dropdownSize === 'md',
|
|
'py-1 text-sm': dropdownSize === 'sm'
|
|
}"
|
|
@mouseover="active = -1"
|
|
@click="active === -1 && newOption()"
|
|
>
|
|
<slot name="newOption" :optionName="q">
|
|
<span class="block truncate">Add new option: "{{ q }}"</span>
|
|
</slot>
|
|
</li>
|
|
<li
|
|
v-for="(option, index) in filteredNormalizedOptions"
|
|
:key="index"
|
|
:ref="`option-${index}`"
|
|
role="option"
|
|
class="relative pl-3 pr-12 cursor-default select-none group hover:text-white hover:bg-primary-600"
|
|
:class="{
|
|
'font-semibold': isOptionSelected(option),
|
|
'bg-primary-600 text-white': active === index,
|
|
'text-tw-gray-900': active !== index,
|
|
'py-2': dropdownSize === 'md',
|
|
'py-1 text-sm': dropdownSize === 'sm'
|
|
}"
|
|
@mouseover="active = index"
|
|
@click.prevent="active === index && selectOption(option)"
|
|
>
|
|
<slot name="option" :option="option">
|
|
<span class="block truncate">{{ option[textAttribute] }}</span>
|
|
</slot>
|
|
<span class="absolute inset-y-0 right-0 flex items-center pr-3">
|
|
<Icon
|
|
v-if="isOptionSelected(option)"
|
|
name="solid/check"
|
|
class=" group-hover:text-white"
|
|
:class="{
|
|
'text-white': active === index,
|
|
'text-primary-600': active !== index,
|
|
'h-5 w-5': dropdownSize === 'md',
|
|
'h-4 w-4': dropdownSize === 'sm'
|
|
}"
|
|
/>
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import get from 'lodash/get'
|
|
import { createPopper } from '@popperjs/core'
|
|
// import { directive as onClickaway } from 'vue-clickaway'
|
|
|
|
import Icon from '../elements/Icon'
|
|
|
|
export default {
|
|
components: {
|
|
Icon
|
|
},
|
|
// directives: {
|
|
// onClickaway
|
|
// },
|
|
shortcuts: {
|
|
disabled () {
|
|
return !this.open
|
|
},
|
|
up: 'prev',
|
|
down: 'next',
|
|
enter: 'enter',
|
|
esc: {
|
|
handler: 'close',
|
|
stop: true,
|
|
prevent: true
|
|
}
|
|
},
|
|
props: {
|
|
value: {
|
|
type: [String, Number, Object, Array],
|
|
default: ''
|
|
},
|
|
name: {
|
|
type: String,
|
|
required: true
|
|
},
|
|
multiple: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
options: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
textAttribute: {
|
|
type: String,
|
|
default: 'text'
|
|
},
|
|
valueAttribute: {
|
|
type: String,
|
|
default: 'value'
|
|
},
|
|
searchAttributes: {
|
|
type: Array,
|
|
default: null
|
|
},
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
placeholder: {
|
|
type: String,
|
|
default: null
|
|
},
|
|
placeholderSearch: {
|
|
type: String,
|
|
default: 'Search...'
|
|
},
|
|
searchable: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
required: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
newEnabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
size: {
|
|
type: String,
|
|
default: 'md',
|
|
validator (value) {
|
|
return ['xxs', 'xs', 'sm', 'md', 'lg', 'xl'].includes(value)
|
|
}
|
|
},
|
|
dropdownClass: {
|
|
type: String,
|
|
default: 'w-full'
|
|
},
|
|
dropdownSize: {
|
|
type: String,
|
|
default: 'md',
|
|
validator (value) {
|
|
return ['sm', 'md'].includes(value)
|
|
}
|
|
},
|
|
variant: {
|
|
type: String,
|
|
default: 'gray'
|
|
},
|
|
strategy: {
|
|
type: String,
|
|
default: 'absolute'
|
|
},
|
|
placement: {
|
|
type: String,
|
|
default: 'bottom-start'
|
|
},
|
|
unselectable: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
},
|
|
data () {
|
|
return {
|
|
open: false,
|
|
active: 0,
|
|
q: '',
|
|
instance: null
|
|
}
|
|
},
|
|
computed: {
|
|
showNewOption () {
|
|
return this.newEnabled && this.q && !this.filteredNormalizedOptions.find(option => option[this.textAttribute].toLowerCase() === this.q.toLowerCase())
|
|
},
|
|
selectedOptions () {
|
|
if (this.multiple) {
|
|
return this.value.map(value => this.normalizedOptions.find(option => option[this.valueAttribute] === value)).filter(Boolean)
|
|
} else {
|
|
return [this.normalizedOptions.find(option => option[this.valueAttribute] === this.value)].filter(Boolean)
|
|
}
|
|
},
|
|
normalizedOptions () {
|
|
return this.options.map(option => this.normalizeOption(option))
|
|
},
|
|
filteredNormalizedOptions () {
|
|
let filteredNormalizedOptions = this.normalizedOptions
|
|
|
|
if (!this.q) {
|
|
return filteredNormalizedOptions
|
|
}
|
|
|
|
try {
|
|
filteredNormalizedOptions = this.normalizedOptions.filter((option) => {
|
|
return (this.searchAttributes?.length ? this.searchAttributes : [this.textAttribute]).some((searchAttribute) => {
|
|
return option[searchAttribute] && option[searchAttribute].search(new RegExp(this.q, 'i')) !== -1
|
|
})
|
|
})
|
|
} catch (e) {}
|
|
|
|
return filteredNormalizedOptions
|
|
}
|
|
},
|
|
watch: {
|
|
disabled (value) {
|
|
if (value && open) { this.close() }
|
|
},
|
|
open (value) {
|
|
this.$emit('open', value)
|
|
|
|
if (!value) {
|
|
return
|
|
}
|
|
|
|
if (this.searchable) {
|
|
this.$nextTick(() => {
|
|
this.$refs.search.$refs.input.focus()
|
|
this.$refs.search.$refs.input.select()
|
|
})
|
|
}
|
|
|
|
if (this.multiple) {
|
|
if (this.value.length) {
|
|
this.active = this.filteredNormalizedOptions.findIndex(option => this.value.includes(option[this.valueAttribute]))
|
|
}
|
|
} else if (this.value) {
|
|
this.active = this.filteredNormalizedOptions.findIndex(option => option[this.valueAttribute] === this.value)
|
|
}
|
|
|
|
if (this.instance) {
|
|
this.instance.destroy()
|
|
this.instance = null
|
|
}
|
|
|
|
this.instance = createPopper(this.$refs.container, this.$refs.tooltip, {
|
|
strategy: this.strategy,
|
|
placement: this.placement,
|
|
modifiers: [
|
|
{
|
|
name: 'offset',
|
|
options: {
|
|
offset: [0, 8]
|
|
}
|
|
},
|
|
{
|
|
name: 'computeStyles',
|
|
options: {
|
|
gpuAcceleration: false,
|
|
adaptive: false
|
|
}
|
|
},
|
|
{
|
|
name: 'preventOverflow',
|
|
options: {
|
|
padding: 8
|
|
}
|
|
}
|
|
]
|
|
})
|
|
|
|
this.$nextTick(() => {
|
|
this.scrollIntoView()
|
|
})
|
|
},
|
|
filteredNormalizedOptions () {
|
|
this.updateActive()
|
|
},
|
|
q () {
|
|
this.updateActive()
|
|
}
|
|
},
|
|
beforeDestroy () {
|
|
if (this.instance) {
|
|
this.instance.destroy()
|
|
this.instance = null
|
|
}
|
|
},
|
|
methods: {
|
|
toggle () {
|
|
this.open = !this.open
|
|
},
|
|
close () {
|
|
this.open = false
|
|
},
|
|
newOption () {
|
|
this.$emit('new', this.q)
|
|
},
|
|
isOptionSelected (option) {
|
|
if (this.multiple) {
|
|
return this.value && this.value.find(it => it === option[this.valueAttribute])
|
|
}
|
|
|
|
return this.value && this.value === option[this.valueAttribute]
|
|
},
|
|
selectOption (option) {
|
|
if (this.multiple) {
|
|
const value = [...this.value]
|
|
const index = value.findIndex(it => it === option[this.valueAttribute])
|
|
if (index > -1) {
|
|
value.splice(index, 1)
|
|
} else {
|
|
value.push(option[this.valueAttribute])
|
|
}
|
|
this.$emit('input', value)
|
|
} else {
|
|
if (this.isOptionSelected(option)) {
|
|
if (this.unselectable) {
|
|
this.$emit('input', null)
|
|
}
|
|
} else {
|
|
this.$emit('input', option[this.valueAttribute])
|
|
}
|
|
this.open = false
|
|
}
|
|
},
|
|
guessOptionValue (option) {
|
|
return get(option, this.valueAttribute, get(option, this.textAttribute))
|
|
},
|
|
guessOptionText (option) {
|
|
return get(option, this.textAttribute, get(option, this.valueAttribute))
|
|
},
|
|
normalizeOption (option) {
|
|
if (['string', 'number', 'boolean'].includes(typeof option)) {
|
|
return {
|
|
[this.valueAttribute]: option,
|
|
[this.textAttribute]: option
|
|
}
|
|
}
|
|
|
|
return {
|
|
...option,
|
|
[this.valueAttribute]: this.guessOptionValue(option),
|
|
[this.textAttribute]: this.guessOptionText(option)
|
|
}
|
|
},
|
|
prev () {
|
|
if (this.active - 1 >= (this.showNewOption ? -1 : 0)) {
|
|
this.active--
|
|
}
|
|
|
|
this.scrollIntoView()
|
|
},
|
|
next () {
|
|
if (this.active + 1 <= (this.filteredNormalizedOptions.length - 1)) {
|
|
this.active++
|
|
}
|
|
|
|
this.scrollIntoView()
|
|
},
|
|
enter () {
|
|
if (this.active === -1) {
|
|
if (this.showNewOption) {
|
|
this.newOption()
|
|
}
|
|
return
|
|
}
|
|
|
|
const option = this.filteredNormalizedOptions[this.active]
|
|
if (!option) {
|
|
return
|
|
}
|
|
|
|
this.selectOption(option)
|
|
},
|
|
scrollIntoView () {
|
|
let child
|
|
if (this.active === -1) {
|
|
child = this.$refs['option-new']
|
|
} else {
|
|
child = this.$refs[`option-${this.active}`][0]
|
|
}
|
|
|
|
if (!child) {
|
|
return
|
|
}
|
|
|
|
child.scrollIntoView({ block: 'nearest' })
|
|
},
|
|
updateActive () {
|
|
this.active = this.showNewOption && !this.filteredNormalizedOptions.length ? -1 : 0
|
|
}
|
|
}
|
|
}
|
|
</script>
|