Files
site/nova/resources/js/components/SearchInput.vue
2025-04-21 16:03:20 +02:00

295 lines
7.4 KiB
Vue

<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>