Reputation: 53307
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.
Upvotes: 1
Views: 44
Reputation: 123
You should try @mousedown
instead of @click
?
I reproduced your code on my vue3 project and he works (blur()
is not called until the completion of the completion after clicking on the suggested item)
The @mousedown
event fires before the blur.
<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) }"
@mousedown="suggestionClick(suggestion_index)"
>
{{ suggestion }}
</li>
</ul>
</div>
Finally, let me know if I've misunderstood your question.
----- Edit Content -----
The way the source code for V-AutoComplete
implements this effect seems to utilize onFocusIn()
in V-Menu
. By adding an EventListener to focusin, it can be realized to listen to whether the clicked element is the element in the Menu to decide whether to keep the focus state or not.
This is part of the source code in V-Menu and This is V-Menu source code link
async function onFocusIn (e: FocusEvent) {
const before = e.relatedTarget as HTMLElement | null
const after = e.target as HTMLElement | null
await nextTick()
if (
isActive.value &&
before !== after &&
overlay.value?.contentEl &&
// We're the topmost menu
overlay.value?.globalTop &&
// It isn't the document or the menu body
![document, overlay.value.contentEl].includes(after!) &&
// It isn't inside the menu body
!overlay.value.contentEl.contains(after)
) {
const focusable = focusableChildren(overlay.value.contentEl)
focusable[0]?.focus()
}
}
watch(isActive, val => {
if (val) {
parent?.register()
if (IN_BROWSER) {
document.addEventListener('focusin', onFocusIn, { once: true })
}
} else {
parent?.unregister()
if (IN_BROWSER) {
document.removeEventListener('focusin', onFocusIn)
}
}
}, { immediate: true })
Here's a solution I rewrote based on this source code (using Vue3 + TS)
<template>
<div class="min-h-100vh w-100% flex items-center justify-center">
<div :class="{ open: openSuggestion }" class="auto-complete-field" ref="autocomplete">
<div class="field-wrapper">
<input
class="form-control"
type="text"
v-model="currentText"
@keydown.enter.prevent="enter"
@keydown.down.prevent="down"
@keydown.up.prevent="up"
@input="change"
@focus="focus"
ref="inputField"
/>
</div>
<div class="suggestions-wrapper" v-if="openSuggestion">
<ul class="field-suggestions" :style="{ width: copiedWidth }">
<li
v-for="(suggestion, suggestion_index) in matches"
:key="suggestion_index"
:class="{ active: isActive(suggestion_index) }"
@click="suggestionClick(suggestion_index)"
>
{{ suggestion }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
const open = ref(false);
const currentText = ref("");
const current = ref(0);
const suggestions = ref(["first", "ble", "hello"]);
const fieldWidth = ref(0);
const inputField = ref<HTMLElement | null>(null);
const autocomplete = ref<HTMLElement | null>(null);
function focus() {
open.value = true;
}
function isActive(index: number) {
return index === current.value;
}
function change() {
if (!open.value) {
open.value = true;
current.value = 0;
}
}
function suggestionClick(index: number) {
currentText.value = matches.value[index];
open.value = false;
}
const matches = computed(() => {
return suggestions.value.filter((str) =>
str.toLowerCase().includes(currentText.value.toLowerCase())
);
});
const openSuggestion = computed(() => {
return currentText.value !== "" && matches.value.length > 0 && open.value;
});
const copiedWidth = computed(() => {
return fieldWidth.value + "px";
});
// Close when listening for external clicks
function handleClickOutside(event: Event) {
if (autocomplete.value && !autocomplete.value.contains(event.target as Node)) {
open.value = false;
}
}
onMounted(() => {
fieldWidth.value = inputField.value?.clientWidth ?? 0;
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
I hope this helps.
Upvotes: 1