I am building a search input that fetchs data from my API and lists it in a dropdown list.
Here is the behavior I want my component to have:
- If I start typing and my API founds data, it opens the dropdown menu and lists it.
- If I click on one of the elements from the list, it is set as 'activeItem' and the dropdown list closes
- Else, I can click out of the component (input and dropdown list) and the dropdown list closes
- Else, no dropdown list appears and my input works like a regular text input
My issue has to do with Event Bubbling.
- My list items (from API) have a @click input that set the clicked element as the 'activeItem'.
- My input has both @focusin and @focusout events, that allow me to display or hide the dropdown list.
I can't click the elements in the dropdown list as the @focusout event from the input is being triggered first and closes the list.
import ...
export default {
components: {
...
},
props: {
...
},
data() {
return {
results: [],
activeItem: null,
isFocus: false,
}
},
watch: {
modelValue: _.debounce(function (newSearchText) {
... API Call
}, 350)
},
computed: {
computedLabel() {
return this.required ? this.label '<span >*</span>' : this.label;
},
value: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
}
}
},
methods: {
setActiveItem(item) {
this.activeItem = item;
this.$emit('selectItem', this.activeItem);
},
resetActiveItem() {
this.activeItem = null;
this.isFocus = false;
this.results = [];
this.$emit('selectItem', null);
},
},
emits: [
'selectItem',
'update:modelValue',
],
}
</script>
<template>
<div >
<label
v-if="label.length"
v-html="computedLabel"
></label>
<div :>
<div v-if="!activeItem">
<div >
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<!-- The input that triggers the API call -->
<input
placeholder="Search for anything..."
type="text"
@input="$emit('update:modelValue', $event.target.value)"
@focusin="isFocus = true"
@focusout="isFocus = false"
>
</div>
<!-- The Dropdown list -->
<Card
v-if="isFocus && results.length"
>
<div >
<ul role="list" >
<!-- API results are displayed here -->
<li
v-for="(result, index) in results"
:key="index"
@click="setActiveItem(result)" <!-- The event I can't trigger -->
>
<div >
<div >
<img
:src="result.image ?? this.$page.props.page.defaultImage.url"
:alt="result.title"
/>
</div>
<div >
<p
:
>
{{ result.title }}
</p>
<p >
{{ result.description }}
</p>
</div>
<div v-if="result.action">
<Link
:href="result.action?.url"
target="_blank"
>
{{ result.action?.text }}
</Link>
</div>
</div>
</li>
</ul>
</div>
</Card>
</div>
<!-- Display the active element, can be ignored for this example -->
<div v-else>
<article >
<div >
<div >
<img
:src="activeItem.image ?? this.$page.props.page.defaultImage.url"
:alt="activeItem.title"
/>
</div>
<div >
<p >
{{ activeItem.title }}
</p>
<p >
{{ activeItem.description }}
</p>
</div>
<div >
<AppButton @click.stop="resetActiveItem();" @focusout.stop>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</AppButton>
</div>
</div>
</article>
</div>
</div>
</div>
</template>
Here is a look at the input:
With API results (can't click the elements):
When no data is found:
I tried:
handleFocusOut(e) {
console.log(e.relatedTarget, e.target, e.currentTarget)
// No matter where I click:
// e.relatedTarget = null
// e.target = <input id="search" search" search"
placeholder="Search for anything..."
type="text"
@input="$emit('update:modelValue', $event.target.value)"
@focusin="isFocus = true"
@focusout="handleFocusOut($event)"
>
The solution:
relatedTarget will be null if the element you click on is not focusable. by adding the tabindex attribute it should make the element focusable and allow it to be set as relatedTarget. if you actually happen to be clicking on some container or overlay element make sure the element being clicked on has that tabindex="0" added to it so you can maintain isFocus = true
Thanks to @yoduh for the solution
CodePudding user response:
The root issue looks to be how the dropdown list is being removed from the DOM as soon as the input loses focus because of the v-if
on it.
<Card
v-if="isFocus && results.length"
>
This is ok to have, but you'll need to work around it by coming up with a solution that keeps isFocus
true whether the focus is on the input or the dropdown. I would suggest your input's @focusout
to execute a method that only sets isFocus = false
if the focus event's relatedTarget is not any of the dropdown items (can be determined via classname or other attribute). One roadblock to implementing this is that some elements aren't natively focusable, like <li>
items, so they won't be set as the relatedTarget, but you can make them focusable by adding the tabindex attribute. Putting it all together should look something like this:
<input
type="text"
@input="$emit('update:modelValue', $event.target.value)"
@focusin="isFocus = true"
@focusout="loseFocus($event)"
/>
...
<li
v-for="(result, index) in results"
:key="index"
tabindex="0"
@click="setActiveItem(result)"
>
loseFocus(event) {
if (event.relatedTarget?.className !== 'listResult') {
this.isFocus = false;
}
}
setActiveItem(item) {
this.activeItem = item;
this.isFocus = false;
this.$emit('selectItem', this.activeItem);
}