import { expect, describe, it, beforeEach, afterEach, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import React from 'react'
import { FocusTrap, type FocusDirection } from './FocusTrap'
function toEl(e: { element(): Element }): HTMLElement {
return e.element() as HTMLElement
}
describe('FocusTrap', () => {
let container: HTMLDivElement
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
container.remove()
})
it('默认 Tab/Shift+Tab 在可聚焦元素间循环', async () => {
const { getByTestId } = render(
,
{ container },
)
const input1 = toEl(getByTestId('input1'))
const btn1 = toEl(getByTestId('btn1'))
const btn2 = toEl(getByTestId('btn2'))
input1.focus()
expect(document.activeElement).toBe(input1)
btn1.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
expect(document.activeElement).toBe(btn1)
btn1.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
expect(document.activeElement).toBe(btn2)
btn2.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
expect(document.activeElement).toBe(input1)
input1.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }))
expect(document.activeElement).toBe(btn2)
})
it('disabled 时取消焦点劫持', async () => {
const { getByTestId } = render(
,
{ container },
)
const btn1 = toEl(getByTestId('btn1'))
const handled = btn1.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }),
)
expect(handled).toBe(true)
})
it('keyMap 支持自定义按键导航', async () => {
const { getByTestId } = render(
,
{ container },
)
const input1 = toEl(getByTestId('input1'))
const btn1 = toEl(getByTestId('btn1'))
const btn2 = toEl(getByTestId('btn2'))
input1.focus()
input1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
expect(document.activeElement).toBe(btn1)
btn1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
expect(document.activeElement).toBe(btn2)
btn2.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
expect(document.activeElement).toBe(btn1)
})
it('keyMap 支持左右方向键导航', async () => {
const { getByTestId } = render(
,
{ container },
)
const input1 = toEl(getByTestId('input1'))
const btn1 = toEl(getByTestId('btn1'))
input1.focus()
input1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
expect(document.activeElement).toBe(btn1)
btn1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
expect(document.activeElement).toBe(input1)
})
it('keyMap 可与 Tab 共存', async () => {
const { getByTestId } = render(
,
{ container },
)
const input1 = toEl(getByTestId('input1'))
const btn1 = toEl(getByTestId('btn1'))
input1.focus()
input1.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
expect(document.activeElement).toBe(btn1)
btn1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
expect(document.activeElement).toBe(input1)
})
it('onNavigate 支持自定义焦点解析', async () => {
const navigate = (
_current: HTMLElement | null,
_elements: HTMLElement[],
direction: FocusDirection,
) => {
if (direction === 'next') return _elements[_elements.length - 1]
return _elements[0]
}
const { getByTestId } = render(
,
{ container },
)
const input1 = toEl(getByTestId('input1'))
const btn2 = toEl(getByTestId('btn2'))
input1.focus()
input1.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
expect(document.activeElement).toBe(btn2)
})
it('autoFocus 挂载时自动聚焦第一个元素', async () => {
const { getByTestId } = render(
,
{ container },
)
const input1 = toEl(getByTestId('input1'))
await vi.waitFor(() => {
expect(document.activeElement).toBe(input1)
})
})
it('空 tabbable 元素时不报错', async () => {
const { getByTestId } = render(
No focusable
,
{ container },
)
const div1 = toEl(getByTestId('div1'))
div1.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
})
it('focusableOptions 透传给 getTabbableElements', async () => {
const captured: { elements: HTMLElement[] } = { elements: [] }
const { getByTestId } = render(
{
captured.elements = elements
return current
}}
>
,
{ container },
)
const btn2 = toEl(getByTestId('btn2'))
btn2.focus()
btn2.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
expect(captured.elements.length).toBe(2)
})
it('未被 keyMap 覆盖的按键不拦截', async () => {
const { getByTestId } = render(
,
{ container },
)
const btn1 = toEl(getByTestId('btn1'))
const handled = btn1.dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
)
expect(handled).toBe(true)
})
})