Created
May 16, 2018 11:20
-
-
Save SigurdMW/c4d9b12698df815aaf7594d0d9cf5c6b to your computer and use it in GitHub Desktop.
Accessible dropdown for vue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div | |
class="dropdown" | |
:class="{ 'dropdown--open': isOpen }" | |
v-click-outside="closeDropdown" | |
> | |
<button | |
class="dropdown__button" | |
type="button" | |
@click="toggle" | |
:id="id" | |
aria-haspopup="true" | |
:aria-expanded="isOpen" | |
:class="{ [buttonClass]: hasButtonClass }" | |
> | |
<slot name="before-button-text-slot"></slot> | |
{{ buttonText }} | |
</button> | |
<ul | |
class="dropdown__menu" | |
:aria-labelledby="id" | |
:aria-hidden="!isOpen" | |
:class="{ 'dropdown__menu--open': isOpen }" | |
@keydown.down.stop.prevent="moveFocusDown" | |
@keydown.up.stop.prevent="moveFocusUp" | |
> | |
<slot></slot> | |
</ul> | |
</div> | |
</template> | |
<script> | |
import { isFocusWithin } from "services/helpers"; | |
export default { | |
name: "DropdownComponent", | |
props: { | |
buttonText: { | |
type: String, | |
required: true | |
}, | |
buttonClass: { | |
type: String, | |
default: "" | |
} | |
}, | |
data () { | |
return { | |
id: null, | |
isOpen: false, | |
children: [], | |
hasButtonClass: this.buttonClass !== "" | |
} | |
}, | |
mounted () { | |
this.id = "dropdown-" + this._uid; | |
document.addEventListener("keydown", (e) => { | |
if (e.keyCode === 27) { //Esc key | |
if (this.isOpen) { | |
const isFocused = isFocusWithin(this.$el, document.activeElement); | |
if (isFocused) this.$el.querySelector("#" + this.id).focus(); | |
this.isOpen = false; | |
} | |
} | |
if (e.keyCode === 9) { //tab | |
if (this.isOpen && !isFocusWithin(this.$el, document.activeElement)) { | |
this.isOpen = !this.isOpen; | |
} | |
} | |
}); | |
this.children = Array.from(this.$el.querySelectorAll(".dropdown__menu a, .dropdown__menu button")); | |
this.closeDropdownOnClick(this.children); | |
}, | |
updated () { | |
this.children = Array.from(this.$el.querySelectorAll(".dropdown__menu a, .dropdown__menu button")); | |
this.closeDropdownOnClick(this.children); | |
}, | |
methods: { | |
toggle () { | |
this.isOpen = !this.isOpen; | |
}, | |
moveFocusUp (e) { | |
this.moveFocus(e, 'up'); | |
}, | |
moveFocusDown (e) { | |
this.moveFocus(e, 'down'); | |
}, | |
moveFocus (e, type) { | |
for (let i = 0; i < this.children.length; i++) { | |
if (document.activeElement === this.children[i]) { | |
if (type === 'up') { | |
if (i - 1 >= 0) { | |
this.children[i - 1].focus(); | |
} else { | |
this.children[this.children.length - 1].focus(); | |
} | |
} | |
if (type === 'down') { | |
if (i + 1 < this.children.length) { | |
this.children[i+1].focus(); | |
} else { | |
this.children[0].focus(); | |
} | |
} | |
break; | |
} | |
} | |
}, | |
closeDropdown () { | |
if (this.isOpen) { | |
const isFocused = isFocusWithin(this.$el, document.activeElement); | |
if (isFocused) this.$el.querySelector("#" + this.id).focus(); | |
this.isOpen = !this.isOpen; | |
} | |
}, | |
closeDropdownOnClick (els) { | |
els.map(el => { | |
// need to remove event listener due to els (children) | |
// is updated after mount | |
el.removeEventListener("click", this.handleClickOnDropdown); | |
el.addEventListener("click", this.handleClickOnDropdown); | |
}); | |
}, | |
handleClickOnDropdown (e) { | |
// do not handle focus on close because of PageComponent | |
// ensures focus on heading | |
this.isOpen = !this.isOpen; | |
} | |
}, | |
directives: { | |
'click-outside': { | |
bind: function(el, binding, vNode) { | |
// thanks to https://jsfiddle.net/Linusborg/Lx49LaL8/ | |
// Provided expression must evaluate to a function. | |
if (typeof binding.value !== 'function') { | |
const compName = vNode.context.name | |
let warn = `[Vue-click-outside:] provided expression '${binding.expression}' is not a function, but has to be` | |
if (compName) { warn += `Found in component '${compName}'` } | |
this.captureError(new Error(err)); | |
} | |
// Define Handler and cache it on the element | |
const bubble = binding.modifiers.bubble | |
const handler = (e) => { | |
if (bubble || (!el.contains(e.target) && el !== e.target)) { | |
binding.value(e) | |
} | |
} | |
el.__vueClickOutside__ = handler | |
// add Event Listeners | |
document.addEventListener('click', handler) | |
}, | |
unbind: function(el, binding) { | |
// Remove Event Listeners | |
document.removeEventListener('click', el.__vueClickOutside__) | |
el.__vueClickOutside__ = null | |
} | |
} | |
} | |
} | |
</script> | |
// Usage | |
<dropdown-component buttonText="Dropdown" buttonClass="some-class"> | |
<span slot="before-button-text-slot">Some text</span> | |
<li class="dropdown__item"> | |
<a href="javascript:void(0);">Some link</a> | |
</li> | |
<li class="dropdown__item"> | |
<a href="javascript: void(0);">Some other link</a> | |
</li> | |
</dropdown-component> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment