I made a vue component (my first ever!) that aims to show suggestions for options as you type:
const AutoCompleteComponent = {
data() {
return {
open: false,
current: 0,
/** @type {string[]} **/
suggestions: ["first", "ble", "hello"],
fieldWidth: 0,
}
},
mounted() {
this.fieldWidth = this.$refs.inputField.clientWidth;
},
methods: {
focus() {
console.log("Focus activated");
this.open = true;
},
blur() {
console.log("Focus deactivated")
// when I do this, the suggestions dissappear the frame before they are
// clicked, causing the suggestionClick to not be called
this.open = false;
},
//For highlighting element
isActive(index) {
return index === this.current;
},
//When the user changes input
change() {
this.loadSuggestions();
//console.log("change()");
if (this.open == false) {
this.open = true;
this.current = 0;
}
},
//When one of the suggestion is clicked
suggestionClick(index) {
this.currentText = this.matches[index];
console.log("Clicked suggestion: ", index, this.matches[index]);
this.open = false;
},
},
computed: {
/**
* Filtering the suggestion based on the input
* @this {ReturnType<AutoCompleteComponent["data"]>}
*/
matches() {
console.log("computed.matches() str=", this.currentText, " suggestions=", this.suggestions);
return this.suggestions.filter((str) => {
const withoutAccents = str.toLowerCase();
return withoutAccents.indexOf(this.currentText.toLowerCase()) >= 0;
});
},
//The flag
openSuggestion() {
return this.currentText !== "" &&
this.matches.length != 0 &&
this.open === true;
},
copiedWidth() {
return this.fieldWidth + "px";
}
},
template: "#vue-auto-complete-template"
};
This is it’s HTML template:
<template id="vue-auto-complete-template">
<div v-bind:class="{'open':openSuggestion}" class="auto-complete-field">
<div class="field-wrapper">
<input class="form-control" type="text" v-model="currentText" @keydown.enter='enter' @keydown.down='down'
@keydown.up='up' @input='change' @focus="focus" @blur="blur" ref="inputField" />
</div>
<div class="suggestions-wrapper">
<ul class="field-suggestions" :style="{ width: copiedWidth }" >
<li v-for="(suggestion, suggestion_index) in matches" v-bind:class="{'active': isActive(suggestion_index)}"
@click="suggestionClick(suggestion_index)">
{{ suggestion }}
</li>
</ul>
</div>
</div>
</template>
So then I create it like this:
<AutoCompleteComponent class="some class names"></AutoCompleteComponent>
To make it appear under the field, the following CSS is applied:
.auto-complete-field {
display:inline-block;
}
.auto-complete-field .suggestions-wrapper {
display:block;
position: relative;
}
.auto-complete-field.open ul {
display:initial;
}
.auto-complete-field ul {
list-style:none;
padding:0;
margin:0;
display: none;
position: absolute;
top:0px;
left: 0px;
border-bottom: 1px solid black;
}
.auto-complete-field ul li {
background-color: white;
border: 1px solid black;
border-bottom: none;
}
Now the problem is if you look onto the blur()
function, it sets open
to false which in turn hides the suggestions ul.field-suggestions
using the active
class name.
Because of the order in which events are handled, blur
event on the field hides the .auto-complete-field.open ul
before the click event is created, causing it to instead be invoked on whatever was under it.
Quick and dirty remedy to this would be setTimeout(()=>{this.open=false}, 100)
. I think for this to work, the timeout must actually be two render frames at least. It didn’t work as a microtask nor RequestAnimationFrame
. I don’t want to use timeout, especially a big one, because it can cause GUI flickering with fast clicks.
I am looking for a more solid solution to this. I’d hope Vue has something for this. Plain JS solution to this is usually reimplementing blur event by listening on multiple events on Window, and checking where they occured. I’d rather avoid that.