import type { MaybeRefOrGetter } from 'vue' import { onBeforeUnmount, onMounted, toValue } from 'vue' interface EscapeEntry { handler: (e: KeyboardEvent) => void enabled: () => boolean } const stack: EscapeEntry[] = [] let listening = false function onDocumentKeydown(e: KeyboardEvent) { if (e.key !== 'Escape') { return } // LIFO: the most recently mounted (topmost) active layer handles Escape for (let i = stack.length - 1; i >= 0; i--) { if (stack[i].enabled()) { stack[i].handler(e) return } } } function syncListener() { if (typeof document === 'undefined') { return } if (stack.length > 0 && !listening) { document.addEventListener('keydown', onDocumentKeydown) listening = true } else if (stack.length === 0 && listening) { document.removeEventListener('keydown', onDocumentKeydown) listening = false } } /** * Run a handler when Escape is pressed, while `enabled` is truthy. * - One shared document listener for the whole app * - LIFO: the most recently mounted active layer wins (dropdown inside a lightbox closes first) * - Automatically cleaned up on unmount * * @example * useEscapeKey(() => close(), () => isOpen.value) */ export function useEscapeKey(handler: (e: KeyboardEvent) => void, enabled: MaybeRefOrGetter = true) { const entry: EscapeEntry = { handler, enabled: () => !!toValue(enabled) } onMounted(() => { stack.push(entry) syncListener() }) onBeforeUnmount(() => { const i = stack.indexOf(entry) if (i !== -1) { stack.splice(i, 1) } syncListener() }) }