dropdown.html
<p-openable class="link" label="Default Dropdown" :close-on-blur="false">
  <template #toggle>
    Dropdown
    <svg viewBox="0 0 18 12"><use href="/main-icons-sprite.svg#chevron-down" /></svg>
  </template>
  <div class="dropdown" v-directionals>
    <ul class="dropdown__list">
      <li class="dropdown__item"><a href="" class="dropdown__link">Consectetur asperiores</a></li>
      <li class="dropdown__item"><a href="" class="dropdown__link">Consectetur asperiores</a></li>
      <li class="dropdown__item"><a href="" class="dropdown__link">Amet porro.</a></li>
      <li class="dropdown__item"><a href="" class="dropdown__link">Ipsum dolorem</a><li>
    </ul>
  </div>
</p-openable>
index.scss
.dropdown {
  background: #fff;
  box-shadow: var(--root-box-shadow-med);
  padding: .75rem;
  position: absolute;
  width: max-content;
  z-index: 1;

  transition: opacity var(--root-ease-out-moderate);

  @starting-style {
    opacity: 0;
  }

  &__link,
  &__button {
    background: #0000;
    border: 0;
    color: var(--accent-color);
    display: block;
    padding: .75rem;
  }

  &__item {
    position: relative;
  }

  &__list {
    display: grid;
  }

  &__button {
    display: flex;
    gap: .5rem;
    justify-content: space-between;
    width: 100%;
  }

  &.-toRight {
    left: 100%;
    top: 0;
  }

  &.-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,
  openOnHover,
  hoverOrClick
} = defineProps({
  closeOnBlur: { type: Boolean, default: true },
  refocus: { type: Boolean, default: true },
  label: { type: String, default: "" },
  name: { type: String, default: "global" },
  openOnHover: { type: Boolean, default: false },
  hoverOrClick: { type: Boolean, default: false },
})
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, { noFocus = false } = {}) => {
  evt?.stopPropagation()
  emit(open.value ? 'close' : 'open')
  open.value = !open.value
  updateGroup(open.value)
  nextTick(() => {
    if (open.value) {
      if (refocus && !noFocus) {
        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)
      }

      if (refocus && !noFocus) {
        focusableElements(button)?.[0].focus()
      }
    }
  })
}


let timeout = null

const mouseover = () => {
  if (timeout) {
    clearTimeout(timeout)
  }

  timeout = setTimeout(() => {
    if (!open.value) {
      toggle(undefined, { noFocus: true })
    }
  }, 450)
}

const mouseout = () => {
  if (timeout) {
    clearTimeout(timeout)
  }

  timeout = setTimeout(() => {
    if (open.value) {
      toggle(undefined, { noFocus: true })
    }
  }, 450)
}

const keypress = evt => {
  if (evt.key == ' ' || evt.key == 'Enter') {
    toggle()
  }
}

let bindings = { click: toggle }
if (openOnHover) {
  bindings = { mouseover, mouseout, keypress }
} if (hoverOrClick) {
  bindings = { click: toggle, mouseover, mouseout }
}

</script>

<template>
  <button v-bind="$attrs" ref="button" v-on="bindings">
    <slot name="toggle" v-bind="bindings">{{ label }}</slot>
  </button>
  <div ref="openable" v-on="(openOnHover || hoverOrClick) ? { mouseover, mouseout } : {}">
    <slot v-if="open" v-bind="bindings" />
  </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)
  },
}