'Vue component - detect outside click
So, I've implemented this custom dropdown list in Vue JS (2.x) and pretty much have what I need, except once the list is open, I would like for a click outside the component (anywhere on the parent page or component) to close the list. I tried catching the blur
event of the root div
of my component, but it understandably didn't work, because div
s don't get focus and hence cannot be blurred. So at this moment, the solution seems to be - to be able to listen for a click event outside the component. Is that possible? Can a child listen for events on its parent in Vue? If or even if not, what is the best and/or the easiest way to achieve this behavior?
Here's my code in a CodeSandbox, and I am also reproducing it below for convenience - https://codesandbox.io/s/romantic-night-ot7i8
Dropdown.vue
<template>
<div class="main-container" @blur="listOpen = false">
<button class="sel-btn" @click="listOpen = !listOpen">
{{ getText() }}
</button>
<br />
<div class="list-items" v-show="listOpen">
<button
class="item"
v-for="(l, i) in list"
:key="i"
@click="btnClicked(i)"
>
{{ l }}
</button>
</div>
</div>
</template>
<script>
export default {
props: {
defaultText: {
type: String,
required: false,
},
list: {
type: Array,
required: true,
},
},
data() {
return {
selectedIndex: -1,
listOpen: false,
};
},
methods: {
getText() {
if (this.selectedIndex !== -1) return this.list[this.selectedIndex];
if (this.defaultText) return this.defaultText;
return "Please Select";
},
btnClicked(index) {
this.selectedIndex = index;
this.listOpen = false;
this.$emit("input", this.list[index]);
},
},
};
</script>
<style scoped>
.main-container {
position: relative;
display: inline-block;
min-width: 125px;
max-width: 125px;
text-align: left;
border: 0;
background: #0000;
}
.sel-btn {
display: inline-block;
width: 100%;
text-align: left;
border: 0;
background: linear-gradient(0deg, #efefef, #fff);
padding: 8px;
border: 1px solid #1111;
box-shadow: 1px 1px 1px #1115;
cursor: pointer;
overflow: hidden;
}
.sel-btn:hover {
background: linear-gradient(0deg, #cef, #fff);
box-shadow: 1px 1px 1px #6390bd;
color: #58a;
}
.sel-btn::after {
content: "\2bc6";
position: absolute;
right: 4%;
}
.list-items {
position: absolute;
width: 100%;
background: #fff;
box-shadow: 1px 1px 3px #3333;
}
.item {
display: block;
width: 100%;
text-align: left;
background: #fff;
border: 0;
cursor: pointer;
padding: 8px;
border-bottom: 1px solid #2221;
font-size: 0.8rem;
}
.item:hover {
background: #4b84c5;
color: #fff;
}
</style>
App.vue
<template>
<div id="app">
<br />
<div style="margin-top: 30px; margin-bottom: 10px">
<Dropdown
:list="regions"
v-model="selectedRegion"
style="margin-right: 10px"
defaultText="--Select Region--"
></Dropdown>
<Dropdown
:list="cities"
v-model="selectedCity"
defaultText="--Select City--"
></Dropdown>
</div>
<div style="padding: 30px; background: #27e2">
(This poem excerpt is here to act as a filler)
<h2>The Daffodils</h2>
<h4><i>William Wordsworth</i></h4>
I wondered lonely<br />
As a cloud<br />
That floats on high<br />
O'er vales and hills<br />
When all at once<br />
I saw a crowd<br />
A host<br />
Of golden daffodils.<br />
</div>
<div style="padding: 30px">
<b>Region Selected:</b> {{ selectedRegion }}<br />
<b>City Selected:</b> {{ selectedCity }}
</div>
</div>
</template>
<script>
import Dropdown from "./components/Dropdown";
export default {
name: "App",
components: {
Dropdown,
},
data() {
return {
set: false,
regions: ["California", "Nova Scotia", "Kerala", "Bavaria", "Queensland"],
cities: [
"Munich",
"San Diego",
"Paris",
"Prague",
"Copenhagen",
"Kolkata",
"Dhaka",
"Colombo",
"Little City",
],
selectedRegion: "",
selectedCity: "",
};
},
};
</script>
<style>
* {
font-family: "Segoe UI", Helvetica, Arial, sans-serif;
}
h1,
h2,
h3,
h4,
b {
font-weight: 400;
color: #27c;
}
</style>
Solution 1:[1]
ok... this is by far not the best solution but it is a working solution. i used all my MacGyver powers and found a way.
please check this CodeSandbox
all i did was to use your listOpen
and added a eventListner. i figured out that your custom dropdown has no build in @blur
because its not a input ofc.
so i added a event for it inside the mounted
hook.
the key is i also added a setTimeout
on 100ms because otherwise you where not able to select any item inside your dropdown, the dropdown would close with blur
faster then you are able to select anything.
Solution 2:[2]
The best approach is to use a Vue Custom Directive to hook the focus functionality to your component and propagate events to outside of it when a click happens on the inside or on the outside of it.
In the example bellow, we created the v-ctrlfocus directive that will hook to the root of your component and create a click event listener for the entire window.
That way, when the click event is fired, we can compare if it happened inside the component or outside of it.
If it happened inside, we will fire a focusin event that you can listen outside of your component with @focusin. Conversely, if the click happened outside of the component, it will fire a focusout event.
To be more updated with Vue new toys, I used typescript and <script setup> syntax:
<script setup lang = "ts">
import { defineEmits } from 'vue';
const emit = defineEmits(['focusin', 'focusout']);
let element: HTMLElement;
const vCtrlfocus = {
mounted: (el: HTMLElement) => {
element = el;
window.addEventListener('click', listenerFunction);
},
unmounted: () => {
window.removeEventListener('click', listenerFunction);
},
};
const listenerFunction = (ev: Event) => {
let targetel = < Element > ev.target;
if (targetel === element || element.contains(targetel)) {
emit('focusin');
} else {
emit('focusout');
}
};
</script>
<template>
<div v-ctrlfocus>
...your component code goes here...
</div>
</template>
The credits to this approach goes to this Stackoverflow post and to this great CSS Tricks article about the issues with different approachs to this problem.
You can also globally register this custom directive at the app level and use it all around your code, as Vue docs mention.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 | danb4r |