import { afterEach, describe, expect, it } from 'vitest'
import { createFocusTrap, getFocusable } from './focus-trap'
function build(html: string): HTMLElement {
const host = document.createElement('div')
host.innerHTML = html
document.body.appendChild(host)
return host
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('getFocusable', () => {
it('collects Tab-focusable elements in DOM order', () => {
const host = build(`
a
d
`)
const f = getFocusable(host)
expect(f.map((el) => el.tagName.toLowerCase())).toEqual(['a', 'button', 'input', 'div'])
})
it('excludes disabled, tabindex=-1, hidden and aria-hidden elements', () => {
const host = build(`
`)
const f = getFocusable(host)
expect(f).toHaveLength(1)
expect(f[0]!.textContent).toBe('real')
})
})
describe('createFocusTrap', () => {
it('moves focus to the first focusable on activate', async () => {
const host = build(``)
const trap = createFocusTrap(host)
trap.activate()
await Promise.resolve()
expect(document.activeElement?.textContent).toBe('first')
trap.release()
})
it('honours an explicit initialFocus element', async () => {
const host = build(``)
const target = host.querySelector('#target')!
const trap = createFocusTrap(host, { initialFocus: target })
trap.activate()
await Promise.resolve()
expect(document.activeElement).toBe(target)
trap.release()
})
it('wraps Tab from the last element back to the first', async () => {
const host = build(``)
const first = host.children[0] as HTMLElement
const last = host.children[1] as HTMLElement
const trap = createFocusTrap(host)
trap.activate()
await Promise.resolve()
last.focus()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
expect(document.activeElement).toBe(first)
trap.release()
})
it('wraps Shift+Tab from the first element to the last', async () => {
const host = build(``)
const first = host.children[0] as HTMLElement
const last = host.children[1] as HTMLElement
const trap = createFocusTrap(host)
trap.activate()
await Promise.resolve()
first.focus()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }))
expect(document.activeElement).toBe(last)
trap.release()
})
it('restores focus to the previously-focused element on release', async () => {
const outside = build(``).querySelector('#opener')!
outside.focus()
const host = build(``)
const trap = createFocusTrap(host)
trap.activate()
await Promise.resolve()
expect(document.activeElement?.textContent).toBe('inner')
trap.release()
expect(document.activeElement).toBe(outside)
})
it('calls onEscape when Escape is pressed while active', async () => {
const host = build(``)
let escaped = false
const trap = createFocusTrap(host, { onEscape: () => (escaped = true) })
trap.activate()
await Promise.resolve()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(escaped).toBe(true)
trap.release()
})
})