import { nextTick, watch, type Ref } from 'vue' import { useEventListener } from './useEventListener' export interface UseFocusTrapOptions { /** Ref to the trap's container element. Focusable elements are queried within it. */ containerRef: Ref /** Reactive flag — the trap activates when this becomes `true` and releases when it becomes `false`. */ active: Ref /** Restore focus to the element that was focused before activation, when the trap releases. Default `true`. */ restoreFocus?: boolean } export interface UseFocusTrapReturn { /** Focusable descendants of the container, in DOM order, excluding roving-tabindex (`tabindex="-1"`) elements. */ getFocusableElements: () => HTMLElement[] } const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(', ') /** * Traps Tab focus within a container while `active` is true: Tab wraps from the last * focusable element back to the first (and Shift+Tab from the first to the last), the * first focusable element (or the container itself, if none) receives focus when the * trap activates, an `[autofocus]` descendant taking precedence, and focus returns to * whatever was focused beforehand when it releases. * Used by BaseModal and WellEditPopupInternal. */ export function useFocusTrap(options: UseFocusTrapOptions): UseFocusTrapReturn { const { containerRef, active } = options const restoreFocus = options.restoreFocus ?? true let previouslyFocused: HTMLElement | null = null function getFocusableElements(): HTMLElement[] { if (!containerRef.value) return [] // Roving tabindex (inactive tabs/rail items) stays out of the Tab cycle. Checking // the attribute directly (rather than the `.tabIndex` IDL property) avoids relying // on a browser/engine's default-tabindex computation for freshly-patched elements. return Array.from(containerRef.value.querySelectorAll(FOCUSABLE_SELECTOR)) .filter((el) => el.getAttribute('tabindex') !== '-1') } function handleKeydown(event: KeyboardEvent) { if (event.key !== 'Tab' || !active.value || !containerRef.value) return const focusable = getFocusableElements() if (focusable.length === 0) { event.preventDefault() return } const first = focusable[0] const last = focusable[focusable.length - 1] if (event.shiftKey && document.activeElement === first) { event.preventDefault() last.focus() } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault() first.focus() } } // `immediate` so a container mounted already active (`v-if` + a constant // `modelValue`) still takes focus. `flush: 'sync'` so releasing the trap from `onBeforeUnmount` restores focus // before the component's reactive scope is torn down — a batched (default // 'pre') watcher would be scheduled but never flushed once that scope stops. watch(active, async (isActive) => { if (isActive) { previouslyFocused = document.activeElement as HTMLElement | null await nextTick() // Like : an [autofocus] descendant wins over the first focusable element. const target = containerRef.value?.querySelector('[autofocus]') ?? getFocusableElements()[0] if (target) { target.focus() } else { containerRef.value?.focus() } } else { if (restoreFocus) previouslyFocused?.focus() previouslyFocused = null } }, { flush: 'sync', immediate: true }) useEventListener(() => document, 'keydown', handleKeydown) return { getFocusableElements } }