<script setup>
import {
nextTick,
onMounted,
onUnmounted,
ref,
useId,
useTemplateRef,
watch,
} from "vue";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
buttonText: {
type: String,
default: "Go to page",
},
buttonClasses: {
type: Array,
default: () => ["button", "button--wide", "button--primary"],
},
items: {
type: Array,
required: true,
},
});
const open = ref(false);
const focusedIndex = ref(-1);
const keysSoFar = ref("");
const keyClear = ref(null);
const componentEl = useTemplateRef("componentRef");
const itemRefs = useTemplateRef("itemsRef");
const menuButtonEl = useTemplateRef("menuButton");
const idModifier = useId();
onMounted(() => {
document.addEventListener("click", onDocumentClick);
});
onUnmounted(() => {
document.removeEventListener("click", onDocumentClick);
});
function idify(id) {
return `${idModifier}__${id}`;
}
function openAndFocus(index = 0) {
open.value = true;
focusedIndex.value = index;
}
function handleSearch(character) {
const searchIndex = focusedIndex.value;
keysSoFar.value += character;
clearKeysSoFarAfterDelay();
let nextMatch = findMatchInRange(searchIndex + 1, props.items.length);
if (!nextMatch) {
nextMatch = findMatchInRange(0, searchIndex);
}
return nextMatch;
}
function findMatchInRange(startIndex, endIndex) {
const list = props.items;
for (let n = startIndex; n < endIndex; n++) {
const label = list[n].title;
if (label.toString().toLowerCase().indexOf(keysSoFar.value) === 0) {
return n;
}
}
return null;
}
function clearKeysSoFarAfterDelay() {
if (keyClear.value) {
clearTimeout(keyClear.value);
keyClear.value = null;
}
keyClear.value = setTimeout(() => {
keysSoFar.value = "";
keyClear.value = null;
}, 500);
}
function onDocumentClick($event) {
if (!componentEl.value.contains($event.target)) {
if (open.value) open.value = false;
}
}
function onFocusOut($event) {
if (componentEl.value.contains($event.relatedTarget)) return;
open.value = false;
}
function onArrowDown() {
if (open.value) {
if (focusedIndex.value === props.items.length - 1) {
focusedIndex.value = 0;
} else {
focusedIndex.value++;
}
} else openAndFocus();
}
function onArrowUp() {
if (open.value) {
if (focusedIndex.value === 0) {
focusedIndex.value = props.items.length - 1;
} else {
focusedIndex.value--;
}
} else openAndFocus(props.items.length - 1);
}
function onButtonClick() {
if (open.value) {
focusedIndex.value = -1;
open.value = false;
} else {
focusedIndex.value = 0;
open.value = true;
}
}
function onButtonEnter() {
if (!open.value) {
openAndFocus();
} else open.value = false;
}
function onMenuEnter() {
if (keyClear.value) return;
if (props.items?.[focusedIndex.value]) {
itemRefs.value[focusedIndex.value].click();
}
}
function onHome() {
if (open.value) {
focusedIndex.value = 0;
}
}
function onEnd() {
if (open.value) {
focusedIndex.value = props.items.length - 1;
}
}
function onType($event) {
const key = $event.key;
if (
[
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
"Esc",
"Enter",
].includes(key)
)
return;
if (key === " " && !keysSoFar.value.length) return;
const indexToFocus = handleSearch(key.toLowerCase());
if (props.items?.[indexToFocus]) {
focusedIndex.value = indexToFocus;
}
}
watch(focusedIndex, async (index) => {
if (props.items?.[index]) {
await nextTick();
itemRefs.value[index].focus();
}
});
watch(open, (value) => {
if (value === false) {
menuButtonEl.value.focus();
focusedIndex.value = -1;
}
});
</script>
<template>
<div class="menuButton" v-on:focusout="onFocusOut" ref="componentRef">
<button
:id="idify('menuButton')"
class="menuButton__button"
:class="[
{
'menuButton__button--open': open,
},
...buttonClasses,
]"
type="button"
v-bind="$attrs"
aria-haspopup="true"
:aria-controls="idify('menu')"
:aria-expanded="open"
ref="menuButton"
v-on:click="onButtonClick"
v-on:keydown.down.prevent="onArrowDown"
v-on:keydown.up.prevent="onArrowUp"
v-on:keydown.esc.prevent="open = false"
v-on:keydown.enter.space.prevent="onButtonEnter"
v-on:keydown.home.prevent="onHome"
v-on:keydown.end.prevent="onEnd"
>
{{ buttonText
}}<span class="appendedIcon" :class="{ 'appendedIcon--open': open }"
>‍<i class="fas fa-angle-down"></i
></span>
</button>
<ul
:id="idify('menu')"
class="menuButton__list"
role="menu"
:aria-labelledby="idify('menuButton')"
v-show="open"
v-on:keydown.down.prevent="onArrowDown"
v-on:keydown.up.prevent="onArrowUp"
v-on:keydown.esc.prevent="open = false"
v-on:keydown.enter.space.prevent="onMenuEnter"
v-on:keydown.home.prevent="onHome"
v-on:keydown.end.prevent="onEnd"
v-on:keydown="onType"
>
<li class="menuButton__item" role="none" v-for="(item, index) in items">
<a
:href="item.url"
:target="item.newTab ? '_blank' : '_self'"
class="menuButton__link"
role="menuitem"
:tabindex="focusedIndex === index ? 0 : -1"
ref="itemsRef"
v-on:mouseover="(event) => event.currentTarget.focus()"
v-html="item.title"
>
</a>
</li>
</ul>
</div>
</template>
<style scoped lang="scss">
.menuButton {
position: relative;
}
.menuButton__button--open {
--button-bg: var(--accent);
--button-border-color: var(--button-bg);
--button-color: var(--blue--ultraDark);
}
.appendedIcon--open svg {
transform: rotate(180deg);
}
.menuButton__list {
$borderWidth: 2px;
background-color: var(--white);
box-shadow: 0 3px 6px rgba(41, 41, 41, 0.6); //60% of --neutral--ultraDark (#292929)
border: $borderWidth solid var(--blue--ultraDark);
padding: 0.3em 0;
max-height: var(--menuButtonListMaxHeight, 350px);
max-width: var(--menuButtonListMaxWidth, 100%);
position: absolute;
z-index: calc(var(--z) * 30);
overflow-y: auto;
top: calc(100% - $borderWidth);
left: 0;
right: 0;
}
.menuButton__item {
list-style: none;
margin-block-start: 0;
}
.menuButton__link {
--color: var(--blue--ultraDark);
background: none;
font-weight: var(--bold-weight);
display: block;
padding: 0.5em 1em;
text-decoration: none;
&:hover,
&:active,
&:focus {
background: var(--blue--dark);
outline: 0;
--color: var(--white);
}
}
</style>