2018-04-01 21:43:23 -05:00
|
|
|
<template>
|
|
|
|
<div>
|
|
|
|
<input
|
|
|
|
ref="textInput"
|
|
|
|
type="text"
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
|
|
:id="id"
|
|
|
|
:name="name"
|
|
|
|
:placeholder="placeholder"
|
|
|
|
:value="rawValue"
|
|
|
|
:class="finalInputClass"
|
|
|
|
|
2018-04-01 22:32:13 -05:00
|
|
|
@click="clickHandler"
|
2018-04-01 21:43:23 -05:00
|
|
|
@blur="blurHandler"
|
|
|
|
@input="inputHandler"
|
|
|
|
@keydown="keydownHandler"
|
|
|
|
/>
|
|
|
|
<div v-show="isListOpen" class="list">
|
|
|
|
<ul>
|
|
|
|
<li v-for="(opt, idx) in options" :key="optionKey(opt)" :class="optionClass(idx)" @mousemove="optionMousemove(idx)" @click="optionClick(opt)">
|
|
|
|
<b class="opt_value">{{ optionValue(opt) }}</b>
|
|
|
|
<span v-if="optionLabel(opt) !== null" class="opt_label" v-html="optionLabel(opt)"></span>
|
|
|
|
</li>
|
|
|
|
</ul>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
|
2024-09-29 13:35:49 -05:00
|
|
|
<script setup>
|
2018-04-01 21:43:23 -05:00
|
|
|
|
2024-09-29 13:35:49 -05:00
|
|
|
import { computed, ref, watch } from "vue";
|
2018-04-01 21:43:23 -05:00
|
|
|
import debounce from 'lodash/debounce';
|
|
|
|
|
2024-09-29 13:35:49 -05:00
|
|
|
const emit = defineEmits(["update:modelValue", "inputClick", "optionSelected"]);
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
modelValue: String,
|
|
|
|
id: String,
|
|
|
|
placeholder: String,
|
|
|
|
name: String,
|
|
|
|
inputClass: {
|
|
|
|
type: [String, Object, Array],
|
|
|
|
required: false,
|
|
|
|
default: null
|
2018-04-01 21:43:23 -05:00
|
|
|
},
|
2024-09-29 13:35:49 -05:00
|
|
|
minLength: {
|
|
|
|
type: Number,
|
|
|
|
default: 0
|
|
|
|
},
|
|
|
|
debounce: {
|
|
|
|
type: Number,
|
|
|
|
required: false,
|
|
|
|
default: 250
|
2018-04-01 21:43:23 -05:00
|
|
|
},
|
|
|
|
|
2024-09-29 13:35:49 -05:00
|
|
|
valueAttribute: String,
|
|
|
|
labelAttribute: String,
|
|
|
|
keyAttribute: String,
|
|
|
|
|
|
|
|
onGetOptions: Function,
|
|
|
|
searchOptions: Array
|
|
|
|
});
|
|
|
|
|
|
|
|
const options = ref([]);
|
|
|
|
const rawValue = ref("");
|
|
|
|
const isListOpen = ref(false);
|
|
|
|
const activeListIndex = ref(0);
|
|
|
|
|
|
|
|
const finalInputClass = computed(() => {
|
|
|
|
let cls = ['input'];
|
|
|
|
if (props.inputClass === null) {
|
|
|
|
return cls;
|
|
|
|
} else if (Array.isArray(props.inputClass)) {
|
|
|
|
return cls.concat(props.inputClass);
|
|
|
|
} else {
|
|
|
|
cls.push(props.inputClass);
|
|
|
|
return cls;
|
|
|
|
}
|
|
|
|
});
|
2018-04-01 21:43:23 -05:00
|
|
|
|
2024-09-29 13:35:49 -05:00
|
|
|
watch(
|
|
|
|
() => props.modelValue,
|
|
|
|
(newValue) => { rawValue.value = newValue; },
|
|
|
|
{ immediate: true }
|
|
|
|
);
|
2018-04-01 21:43:23 -05:00
|
|
|
|
2024-09-29 13:35:49 -05:00
|
|
|
function optionClass(idx) {
|
|
|
|
return activeListIndex.value === idx ? 'option active' : 'option';
|
|
|
|
}
|
2018-04-01 21:43:23 -05:00
|
|
|
|
2024-09-29 13:35:49 -05:00
|
|
|
function optionClick(opt) {
|
|
|
|
selectOption(opt);
|
|
|
|
}
|
|
|
|
|
|
|
|
function optionKey(opt) {
|
|
|
|
if (props.keyAttribute) {
|
|
|
|
return opt[props.keyAttribute]
|
|
|
|
} else if (props.valueAttribute) {
|
|
|
|
return opt[props.valueAttribute];
|
|
|
|
} else {
|
|
|
|
return opt.toString();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function optionValue(opt) {
|
|
|
|
if (props.valueAttribute) {
|
|
|
|
return opt[props.valueAttribute];
|
|
|
|
} else {
|
|
|
|
return opt.toString();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function optionLabel(opt) {
|
|
|
|
if (props.labelAttribute) {
|
|
|
|
return opt[props.labelAttribute];
|
|
|
|
} else {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function optionMousemove(idx) {
|
|
|
|
activeListIndex.value = idx;
|
|
|
|
}
|
|
|
|
|
|
|
|
function clickHandler(evt) {
|
|
|
|
emit('inputClick', evt);
|
|
|
|
}
|
|
|
|
|
|
|
|
function blurHandler(evt) {
|
|
|
|
// blur fires before click. If the blur was fired because the user clicked a list item, immediately hiding the list here
|
|
|
|
// would prevent the click event from firing
|
|
|
|
setTimeout(() => {
|
|
|
|
isListOpen.value = false;
|
|
|
|
},250);
|
|
|
|
}
|
|
|
|
|
|
|
|
function inputHandler(evt) {
|
|
|
|
const newValue = evt.target.value;
|
|
|
|
|
|
|
|
if (rawValue.value !== newValue) {
|
|
|
|
|
|
|
|
rawValue.value = newValue;
|
2018-04-01 21:43:23 -05:00
|
|
|
|
2024-09-29 13:35:49 -05:00
|
|
|
emit("update:modelValue", newValue);
|
|
|
|
|
|
|
|
if (newValue.length >= Math.max(1, props.minLength)) {
|
|
|
|
this.updateOptions(newValue);
|
|
|
|
} else {
|
|
|
|
isListOpen.value = false;
|
2018-04-01 21:43:23 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-09-29 13:35:49 -05:00
|
|
|
function keydownHandler(evt) {
|
|
|
|
if (isListOpen.value === false)
|
|
|
|
return;
|
|
|
|
|
|
|
|
switch (evt.key) {
|
|
|
|
case "ArrowUp":
|
|
|
|
evt.preventDefault();
|
|
|
|
activeListIndex.value = Math.max(0, activeListIndex.value - 1);
|
|
|
|
break;
|
|
|
|
case "ArrowDown":
|
|
|
|
evt.preventDefault();
|
|
|
|
activeListIndex.value = Math.min(options.value.length - 1, activeListIndex.value + 1);
|
|
|
|
break;
|
|
|
|
case "Enter":
|
|
|
|
evt.preventDefault();
|
|
|
|
selectOption(options.value[activeListIndex.value]);
|
|
|
|
break;
|
|
|
|
case "Escape":
|
|
|
|
evt.preventDefault();
|
|
|
|
isListOpen.value = false;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function selectOption(opt) {
|
|
|
|
rawValue.value = optionValue(opt);
|
|
|
|
emit("update:modelValue", rawValue.value);
|
|
|
|
emit("optionSelected", opt);
|
|
|
|
isListOpen.value = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const updateOptions = debounce(function(value) {
|
|
|
|
let p = null;
|
|
|
|
if (props.searchOptions) {
|
|
|
|
const reg = new RegExp("^" + value, "i");
|
|
|
|
const matcher = o => reg.test(optionValue(o));
|
|
|
|
p = Promise.resolve(props.searchOptions.filter(matcher));
|
|
|
|
} else {
|
|
|
|
p = props.onGetOptions(value)
|
|
|
|
}
|
|
|
|
|
|
|
|
p.then(opts => {
|
|
|
|
options.value = opts;
|
|
|
|
isListOpen.value = opts.length > 0;
|
|
|
|
activeListIndex.value = 0;
|
|
|
|
})
|
|
|
|
}, props.debounce);
|
|
|
|
|
2018-04-01 21:43:23 -05:00
|
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
2024-09-29 09:44:40 -05:00
|
|
|
@use "bulma/sass/utilities" as bulma;
|
2018-04-01 21:43:23 -05:00
|
|
|
|
|
|
|
$labelLineHeight: 0.8rem;
|
|
|
|
|
|
|
|
input.input {
|
|
|
|
&::placeholder {
|
2024-09-29 09:44:40 -05:00
|
|
|
color: bulma.$grey-darker;
|
2018-04-01 21:43:23 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.list {
|
|
|
|
position: relative;
|
|
|
|
z-index: 150;
|
|
|
|
|
|
|
|
ul {
|
|
|
|
background-color: white;
|
|
|
|
position: absolute;
|
|
|
|
width: 100%;
|
|
|
|
border: 1px solid black;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
li.option {
|
|
|
|
|
|
|
|
padding: 4px;
|
|
|
|
margin-bottom: 2px;
|
|
|
|
|
|
|
|
//transition: background-color 0.25s;
|
|
|
|
|
|
|
|
&.active {
|
|
|
|
color: white;
|
2024-09-29 09:44:40 -05:00
|
|
|
background-color: bulma.$turquoise;
|
2018-04-01 21:43:23 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
.opt_value {
|
|
|
|
}
|
|
|
|
|
|
|
|
.opt_label {
|
|
|
|
display: block;
|
|
|
|
overflow: hidden;
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
font-size: 0.8rem;
|
|
|
|
line-height: $labelLineHeight;
|
|
|
|
max-height: $labelLineHeight * 2;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
</style>
|