diff --git a/packages/library/src/lib/use-focus-trap.ts b/packages/library/src/lib/use-focus-trap.ts new file mode 100644 index 0000000..6011d40 --- /dev/null +++ b/packages/library/src/lib/use-focus-trap.ts @@ -0,0 +1,81 @@ +import type { Ref } from 'vue' +import { nextTick, onUnmounted, watch } from 'vue' + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(',') + +function getFocusable(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)) +} + +export interface FocusTrapOptions { + initialFocus?: () => HTMLElement | null +} + +export function useFocusTrap( + containerRef: Ref, + active: Ref, + options: FocusTrapOptions = {}, +) { + let previouslyFocused: HTMLElement | null = null + + function handleKeydown(event: KeyboardEvent) { + if (event.key !== 'Tab') return + const container = containerRef.value + if (!container) return + + const focusable = getFocusable(container) + if (focusable.length === 0) { + event.preventDefault() + return + } + + const first = focusable[0]! + const last = focusable[focusable.length - 1]! + const current = document.activeElement + + if (event.shiftKey) { + if (current === first || !container.contains(current)) { + event.preventDefault() + last.focus() + } + } else { + if (current === last || !container.contains(current)) { + event.preventDefault() + first.focus() + } + } + } + + watch( + active, + (isActive) => { + if (isActive) { + previouslyFocused = document.activeElement as HTMLElement | null + document.addEventListener('keydown', handleKeydown) + nextTick(() => { + const container = containerRef.value + if (!container) return + if (container.contains(document.activeElement)) return + const target = options.initialFocus?.() ?? getFocusable(container)[0] ?? container + target?.focus() + }) + } else { + document.removeEventListener('keydown', handleKeydown) + previouslyFocused?.focus?.() + previouslyFocused = null + } + }, + { immediate: true }, + ) + + onUnmounted(() => { + document.removeEventListener('keydown', handleKeydown) + }) +} diff --git a/packages/library/tests/use-focus-trap.spec.ts b/packages/library/tests/use-focus-trap.spec.ts new file mode 100644 index 0000000..37c5601 --- /dev/null +++ b/packages/library/tests/use-focus-trap.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { nextTick, ref } from 'vue' +import { useFocusTrap } from '../src/lib/use-focus-trap' + +function appendContainer() { + const container = document.createElement('div') + const first = document.createElement('button') + first.textContent = 'first' + const last = document.createElement('button') + last.textContent = 'last' + container.append(first, last) + document.body.append(container) + return { container, first, last } +} + +describe('useFocusTrap', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + it('moves focus into the container on activation', async () => { + const { container, first } = appendContainer() + const containerRef = ref(container) + const active = ref(false) + useFocusTrap(containerRef, active) + + active.value = true + await nextTick() + await nextTick() + + expect(document.activeElement).toBe(first) + }) + + it('uses the initialFocus override when provided', async () => { + const { container, last } = appendContainer() + const containerRef = ref(container) + const active = ref(false) + useFocusTrap(containerRef, active, { initialFocus: () => last }) + + active.value = true + await nextTick() + await nextTick() + + expect(document.activeElement).toBe(last) + }) + + it('wraps Tab from the last focusable back to the first', async () => { + const { container, first, last } = appendContainer() + const containerRef = ref(container) + const active = ref(true) + useFocusTrap(containerRef, active) + last.focus() + + const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + document.dispatchEvent(event) + + expect(document.activeElement).toBe(first) + }) + + it('wraps Shift+Tab from the first focusable back to the last', async () => { + const { container, first, last } = appendContainer() + const containerRef = ref(container) + const active = ref(true) + useFocusTrap(containerRef, active) + first.focus() + + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }) + document.dispatchEvent(event) + + expect(document.activeElement).toBe(last) + }) + + it('restores focus to the previously-focused element on deactivation', async () => { + const opener = document.createElement('button') + opener.textContent = 'opener' + document.body.append(opener) + opener.focus() + + const { container } = appendContainer() + const containerRef = ref(container) + const active = ref(false) + useFocusTrap(containerRef, active) + + active.value = true + await nextTick() + await nextTick() + active.value = false + await nextTick() + + expect(document.activeElement).toBe(opener) + }) +})