<nav class="navigation" v-directionals>
<ul class="navigation__list">
<li class="navigation__item" v-directionals>
<p-openable class="navigation__button" label="Default Dropdown" :close-on-blur="false">
<div class="navigation__dropdown">
<ul>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Amet porro.</span></a></li>
<li><a href=""><span>Ipsum dolorem</span></a><li>
</ul>
</div>
</p-openable>
</li>
<li class="navigation__item" v-directionals>
<p-openable class="navigation__button" label="Centered Dropdown" :close-on-blur="false">
<div class="navigation__dropdown -center">
<ul>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Amet porro.</span></a></li>
<li><a href=""><span>Ipsum dolorem</span></a><li>
</ul>
</div>
</p-openable>
</li>
<li class="navigation__item -wide" v-directionals>
<p-openable class="navigation__button" label="Full-width Dropdown" :close-on-blur="false">
<div class="navigation__dropdown grid -columns-3">
<div class="cell">
<h3>Heading</h3>
<p>
Adipisicing obcaecati tempora qui ullam voluptates beatae? Assumenda soluta quae
</p>
<ul>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Amet porro.</span></a></li>
<li><a href=""><span>Ipsum dolorem</span></a><li>
</ul>
</div>
<div class="cell">
<h3>Heading</h3>
<p>
Adipisicing obcaecati tempora qui ullam voluptates beatae? Assumenda soluta quae
</p>
<ul>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Amet porro.</span></a></li>
<li><a href=""><span>Ipsum dolorem</span></a><li>
</ul>
</div>
<div class="cell">
<h3>Heading</h3>
<p>
Adipisicing obcaecati tempora qui ullam voluptates beatae? Assumenda soluta quae
</p>
<ul>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Amet porro.</span></a></li>
<li><a href=""><span>Ipsum dolorem</span></a><li>
</ul>
</div>
</div>
</p-openable>
</li>
<li class="navigation__item" v-directionals>
<p-openable class="navigation__button" label="Right Aligned Dropdown" :close-on-blur="false">
<div class="navigation__dropdown -rightAligned">
<ul>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Consectetur asperiores</span></a></li>
<li><a href=""><span>Amet porro.</span></a></li>
<li><a href=""><span>Ipsum dolorem</span></a><li>
</ul>
</div>
</p-openable>
</li>
</ul>
</nav>
.navigation {
$b: &;
position: relative;
&__list {
display: grid;
grid-auto-flow: column;
place-content: end space-between;
}
&__link,
&__button {
border: 0;
background: #0000;
color: var(--root-color);
padding: 1.5rem;
}
&__button {
display: flex;
gap: .5rem;
&::after {
content: '';
width: .75rem;
height: 1em;
background: var(--accent-color);
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 21'%3E%3Cpath d='M34 2L18 18L2 2' fill='none' stroke='black' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E") center no-repeat;
}
}
&__item {
position: relative;
&.-wide {
position: static;
#{$b}__dropdown {
width: 100%;
left: 0;
}
}
&:has(#{$b}__dropdown) {
background: #fff;
box-shadow: var(--root-box-shadow-med);
}
}
&__dropdown {
background: #fff;
box-shadow: var(--root-box-shadow-med);
clip-path: rect(-1px 200% 200% -100%);
padding: 1.5rem;
position: absolute;
width: max-content;
z-index: 1;
&.-center {
left: 50%;
translate: -50%;
}
&.-rightAligned {
right: 0;
}
}
}
<script>
const openableGroups = {}
</script>
<script setup>
import { ref, nextTick, useSlots, useTemplateRef } from 'vue'
import focusableElements from '../composables/FocusableElements.js'
const { closeOnBlur, refocus, label, name } = defineProps({
closeOnBlur: { type: Boolean, default: true },
refocus: { type: Boolean, default: true },
label: { type: String, default: "" },
name: { type: String, default: "global" },
})
const emit = defineEmits(['open', 'close'])
const slots = useSlots()
const button = useTemplateRef('button')
const openable = useTemplateRef('openable')
const open = ref(false)
const targetOutside = evt => {
if (openable.value && !openable.value.contains(evt.target)) {
toggle()
}
}
const pressEscape = evt => {
if (evt.key === 'Escape') {
evt.stopPropagation()
toggle()
}
}
let escapeHandler = null
const updateGroup = (open) => {
if (!name) {
return
}
if (!(name in openableGroups)) {
openableGroups[name] = new Set()
}
if (open) {
openableGroups[name].forEach(t => t())
openableGroups[name].add(toggle)
} else {
openableGroups[name].delete(toggle)
}
}
const toggle = evt => {
evt?.stopPropagation()
emit(open.value ? 'close' : 'open')
open.value = !open.value
updateGroup(open.value)
nextTick(() => {
if (open.value) {
if (refocus) {
focusableElements(openable)?.[0].focus()
}
document.documentElement.addEventListener('click', targetOutside)
openable.value.addEventListener('keydown', pressEscape)
if (closeOnBlur) {
document.documentElement.addEventListener('focusin', targetOutside)
}
} else {
document.documentElement.removeEventListener('click', targetOutside)
openable.value.removeEventListener('keydown', pressEscape)
if (closeOnBlur) {
document.documentElement.removeEventListener('focusin', targetOutside)
}
focusableElements(button)?.[0].focus()
}
})
}
</script>
<template>
<button v-bind="$attrs" ref="button" @click="toggle">
<slot name="toggle" v-bind="{ toggle }">{{ label }}</slot>
</button>
<div ref="openable">
<slot v-if="open" v-bind="{ toggle }" />
</div>
</template>
import focusableElements from '../composables/FocusableElements.js'
/**
* Default key filters. These should be functions that 'filter out' what
* elements should be considered when a key is hit.
*/
const KEY_BINDINGS = {
ArrowRight: elements => elements.filter(({ x, top, bottom }) => x > 0 && top < 0 && bottom > 0),
ArrowLeft: elements => elements.filter(({ x, top, bottom }) => x < 0 && top < 0 && bottom > 0),
ArrowDown: elements => elements.filter(({ y, left, right }) => y > 0 && left < 0 && right > 0),
ArrowUp: elements => elements.filter(({ y, left, right }) => y < 0 && left < 0 && right > 0),
Home: elements => elements.length && elements.slice(0, 1),
End: elements => elements.length && elements.slice(-1),
}
/**
* Retrieves where the element is drawn on screen (client rects).
* Adds in x and y for the center of the element.
*/
const getElementRects = el => {
if (!el) {
console.error("Unable to determine location of element", el);
return null
}
const rects = el.getClientRects()[0]
if (!rects) {
return getElementRects(el.parentElement)
}
return {
bottom: rects.bottom,
height: rects.height,
left: rects.left,
right: rects.right,
top: rects.top,
width: rects.width,
x: rects.left + rects.width / 2,
y: rects.top + rects.height / 2,
}
}
/**
* Calls getElementRects for every element in nodeList, and adjust the
* values to be relative to origin for simpler follow up math. Also
* calculates the distance between the two elements' centers.
*/
const augmentElementRects = (nodeList, origin) => {
const elements = []
origin = getElementRects(origin)
nodeList.forEach(el => {
const rects = getElementRects(el)
if (rects === null) {
return
}
rects.bottom -= origin.y
rects.left -= origin.x
rects.right -= origin.x
rects.top -= origin.y
rects.x -= origin.x
rects.y -= origin.y
const distance = Math.sqrt(rects.x * rects.x + rects.y * rects.y)
elements.push({ el, ...rects, distance })
})
return elements
}
const findTarget = (el, key, root) => {
const elements = augmentElementRects(focusableElements(root), el)
if (key in KEY_BINDINGS) {
const { el } = KEY_BINDINGS[key](elements).reduce(
(closest, el) => {
return el.distance < closest.distance ? el : closest
},
{ distance: Infinity }
)
return el
}
return null
}
export default {
mounted(element) {
const handler = evt => {
const target = findTarget(evt.target, evt.key, element)
if (target) {
evt.preventDefault()
evt.stopPropagation()
target.focus()
}
}
element.addEventListener('keydown', handler)
},
}