parsley/app/javascript/components/AppAutocomplete.vue

254 lines
5.5 KiB
Vue
Raw Normal View History

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>