import { nextTick, onBeforeUnmount } from 'vue' /** Broad selector covering inputs of any type (e.g. search, email), buttons, links, etc. */ const FOCUSABLE_SELECTOR = [ 'a[href]:not([disabled])', 'button:not([disabled])', 'textarea:not([disabled])', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(', ') const isVisible = (el: HTMLElement) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) export const getFocusableElements = (container: Element): HTMLElement[] => Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( (el) => el.tabIndex >= 0 && !el.hasAttribute('disabled') && isVisible(el), ) /** * Focus trap for dialogs (modal, drawer). Activates on open, cleans up on close/unmount. */ export function useFocusTrap() { let cleanup: (() => void) | null = null const deactivate = () => { cleanup?.() cleanup = null } const activate = (getContainer: () => HTMLElement | null) => { deactivate() nextTick(() => { const container = getContainer() if (!container) return const focusable = getFocusableElements(container) if (focusable.length > 0) { focusable[0].focus() } const handleTabKey = (e: KeyboardEvent) => { if (e.key !== 'Tab') return const current = getFocusableElements(container) if (current.length === 0) return const first = current[0] const last = current[current.length - 1] if (e.shiftKey && document.activeElement === first) { last.focus() e.preventDefault() } else if (!e.shiftKey && document.activeElement === last) { first.focus() e.preventDefault() } } container.addEventListener('keydown', handleTabKey, true) cleanup = () => { container.removeEventListener('keydown', handleTabKey, true) } }) } onBeforeUnmount(deactivate) return { activate, deactivate } }