navigation.html
<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>
index.scss
.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;
    }
  }
}

POpenable.vue
<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>
vDirectionals.js
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)
  },
}