295 lines
7.4 KiB
Vue
Executable File
295 lines
7.4 KiB
Vue
Executable File
<template>
|
|
<div
|
|
:data-testid="dataTestid"
|
|
:dusk="dataTestid"
|
|
:class="{ 'opacity-75': disabled }"
|
|
v-on-clickaway="close"
|
|
>
|
|
<div class="relative">
|
|
<div
|
|
ref="input"
|
|
@click="open"
|
|
@focus="open"
|
|
@keydown.down.prevent="open"
|
|
@keydown.up.prevent="open"
|
|
:class="{
|
|
focus: show,
|
|
'border-danger': error,
|
|
'form-select': shouldShowDropdownArrow,
|
|
disabled,
|
|
}"
|
|
class="flex items-center form-control form-input form-input-bordered pr-6"
|
|
:tabindex="show ? -1 : 0"
|
|
>
|
|
<div
|
|
v-if="shouldShowDropdownArrow && !disabled"
|
|
class="search-input-trigger absolute pin select-box"
|
|
/>
|
|
|
|
<slot name="default">
|
|
<div class="text-70">{{ __('Click to choose') }}</div>
|
|
</slot>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
@click.stop="clear"
|
|
v-if="!shouldShowDropdownArrow && !disabled"
|
|
tabindex="-1"
|
|
class="absolute p-2 inline-block"
|
|
style="right: 4px; top: 6px"
|
|
>
|
|
<svg
|
|
class="block fill-current icon h-2 w-2"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="278.046 126.846 235.908 235.908"
|
|
>
|
|
<path
|
|
d="M506.784 134.017c-9.56-9.56-25.06-9.56-34.62 0L396 210.18l-76.164-76.164c-9.56-9.56-25.06-9.56-34.62 0-9.56 9.56-9.56 25.06 0 34.62L361.38 244.8l-76.164 76.165c-9.56 9.56-9.56 25.06 0 34.62 9.56 9.56 25.06 9.56 34.62 0L396 279.42l76.164 76.165c9.56 9.56 25.06 9.56 34.62 0 9.56-9.56 9.56-25.06 0-34.62L430.62 244.8l76.164-76.163c9.56-9.56 9.56-25.06 0-34.62z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
v-if="show"
|
|
ref="dropdown"
|
|
class="form-input px-0 border border-60 absolute pin-t pin-l my-1 overflow-hidden"
|
|
:style="{ width: inputWidth + 'px', zIndex: 2000 }"
|
|
>
|
|
<div class="p-2 bg-grey-300">
|
|
<input
|
|
:disabled="disabled"
|
|
v-model="search"
|
|
@input="handleInput"
|
|
ref="search"
|
|
@keydown.enter.prevent="chooseSelected"
|
|
@keydown.down.prevent="move(1)"
|
|
@keydown.up.prevent="move(-1)"
|
|
class="outline-none search-input-input w-full px-2 py-1.5 text-sm leading-normal bg-white rounded"
|
|
tabindex="-1"
|
|
type="text"
|
|
:placeholder="__('Search')"
|
|
spellcheck="false"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
ref="container"
|
|
class="search-input-options relative overflow-y-scroll scrolling-touch text-sm"
|
|
tabindex="-1"
|
|
style="max-height: 155px"
|
|
>
|
|
<div
|
|
v-for="(option, index) in data"
|
|
:dusk="dataTestid + '-result-' + index"
|
|
:key="getTrackedByKey(option)"
|
|
:ref="index === selected ? 'selected' : null"
|
|
@click="choose(option)"
|
|
class="px-4 py-2 cursor-pointer"
|
|
:class="{
|
|
[`search-input-item-${index}`]: true,
|
|
'hover:bg-30': index !== selected,
|
|
'bg-primary text-white': index === selected,
|
|
}"
|
|
>
|
|
<slot
|
|
name="option"
|
|
:option="option"
|
|
:selected="index === selected"
|
|
></slot>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import _ from 'lodash'
|
|
import Vue from 'vue'
|
|
import Popper from 'popper.js'
|
|
import { mixin as clickaway } from 'vue-clickaway'
|
|
|
|
export default {
|
|
mixins: [clickaway],
|
|
inheritAttrs: false,
|
|
props: {
|
|
dataTestid: {},
|
|
disabled: { default: false },
|
|
value: {},
|
|
data: {},
|
|
trackBy: {},
|
|
error: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
boundary: {},
|
|
debounce: {
|
|
type: Number,
|
|
default: 500,
|
|
},
|
|
clearable: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
},
|
|
|
|
data: () => ({
|
|
debouncer: null,
|
|
show: false,
|
|
search: '',
|
|
selected: 0,
|
|
popper: null,
|
|
inputWidth: null,
|
|
}),
|
|
|
|
watch: {
|
|
search(search) {
|
|
this.selected = 0
|
|
this.$refs.container.scrollTop = 0
|
|
},
|
|
show(show) {
|
|
if (show) {
|
|
let selected = _.findIndex(this.data, [
|
|
this.trackBy,
|
|
_.get(this.value, this.trackBy),
|
|
])
|
|
if (selected !== -1) this.selected = selected
|
|
this.inputWidth = this.$refs.input.offsetWidth
|
|
|
|
Vue.nextTick(() => {
|
|
const vm = this
|
|
|
|
this.popper = new Popper(this.$refs.input, this.$refs.dropdown, {
|
|
placement: 'bottom-start',
|
|
onCreate() {
|
|
vm.$refs.container.scrollTop = vm.$refs.container.scrollHeight
|
|
vm.updateScrollPosition()
|
|
vm.$refs.search.focus()
|
|
},
|
|
modifiers: {
|
|
preventOverflow: {
|
|
boundariesElement: this.boundary
|
|
? this.boundary
|
|
: 'scrollParent',
|
|
},
|
|
},
|
|
})
|
|
})
|
|
} else {
|
|
if (this.popper) this.popper.destroy()
|
|
}
|
|
},
|
|
},
|
|
|
|
created() {
|
|
this.debouncer = _.debounce(callback => callback(), this.debounce)
|
|
},
|
|
|
|
mounted() {
|
|
document.addEventListener('keydown', this.handleEscape)
|
|
},
|
|
|
|
beforeDestroy() {
|
|
document.removeEventListener('keydown', this.handleEscape)
|
|
},
|
|
|
|
methods: {
|
|
handleEscape(e) {
|
|
// 'tab' or 'escape'
|
|
if (this.show && (e.keyCode == 9 || e.keyCode == 27)) {
|
|
setTimeout(() => this.close(), 50)
|
|
}
|
|
},
|
|
|
|
getTrackedByKey(option) {
|
|
return _.get(option, this.trackBy)
|
|
},
|
|
|
|
open() {
|
|
if (!this.disabled) {
|
|
this.show = true
|
|
this.search = ''
|
|
}
|
|
},
|
|
|
|
close() {
|
|
this.show = false
|
|
},
|
|
|
|
clear() {
|
|
if (!this.disabled) {
|
|
this.selected = null
|
|
this.$emit('clear', null)
|
|
}
|
|
},
|
|
|
|
move(offset) {
|
|
let newIndex = this.selected + offset
|
|
|
|
if (newIndex >= 0 && newIndex < this.data.length) {
|
|
this.selected = newIndex
|
|
this.updateScrollPosition()
|
|
}
|
|
},
|
|
|
|
updateScrollPosition() {
|
|
Vue.nextTick(() => {
|
|
if (this.$refs.selected) {
|
|
if (
|
|
this.$refs.selected[0].offsetTop >
|
|
this.$refs.container.scrollTop +
|
|
this.$refs.container.clientHeight -
|
|
this.$refs.selected[0].clientHeight
|
|
) {
|
|
this.$refs.container.scrollTop =
|
|
this.$refs.selected[0].offsetTop +
|
|
this.$refs.selected[0].clientHeight -
|
|
this.$refs.container.clientHeight
|
|
}
|
|
|
|
if (
|
|
this.$refs.selected[0].offsetTop < this.$refs.container.scrollTop
|
|
) {
|
|
this.$refs.container.scrollTop = this.$refs.selected[0].offsetTop
|
|
}
|
|
}
|
|
})
|
|
},
|
|
|
|
chooseSelected() {
|
|
if (this.data[this.selected] !== undefined) {
|
|
this.$emit('selected', this.data[this.selected])
|
|
this.$refs.input.focus()
|
|
Vue.nextTick(() => this.close())
|
|
}
|
|
},
|
|
|
|
choose(option) {
|
|
this.selected = _.findIndex(this.data, [
|
|
this.trackBy,
|
|
_.get(option, this.trackBy),
|
|
])
|
|
this.$emit('selected', option)
|
|
this.$refs.input.focus()
|
|
Vue.nextTick(() => this.close())
|
|
},
|
|
|
|
/**
|
|
* Handle the input event of the search box
|
|
*/
|
|
handleInput(e) {
|
|
this.debouncer(() => {
|
|
this.$emit('input', e.target.value)
|
|
})
|
|
},
|
|
},
|
|
|
|
computed: {
|
|
shouldShowDropdownArrow() {
|
|
return this.value == '' || this.value == null || !this.clearable
|
|
},
|
|
},
|
|
}
|
|
</script>
|