import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {getFocusableElements, getTabbableElements, isFocusable, isTabbable} from './focusable'
describe('focusable utils', () => {
let container: HTMLDivElement
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
container.remove()
})
it('识别基础可聚焦元素', () => {
container.innerHTML = `
Link
No Link
`
const btn = container.querySelector('#btn')!
const input = container.querySelector('#input')!
const link = container.querySelector('#link')!
const noLink = container.querySelector('#no-link')!
expect(isFocusable(btn)).toBe(true)
expect(isTabbable(btn)).toBe(true)
expect(isFocusable(input)).toBe(true)
expect(isFocusable(link)).toBe(true)
expect(isFocusable(noLink)).toBe(false)
expect(isTabbable(noLink)).toBe(false)
})
it('区分 focusable 与 tabbable(tabindex=-1)', () => {
container.innerHTML = `
Negative
`
const neg = container.querySelector('#neg')!
expect(isFocusable(neg)).toBe(true)
expect(isTabbable(neg)).toBe(false)
expect(getFocusableElements(container)).toHaveLength(1)
expect(getTabbableElements(container)).toHaveLength(0)
})
it('排除 disabled 与 hidden input', () => {
container.innerHTML = `
`
expect(getFocusableElements(container)).toHaveLength(1)
expect(getTabbableElements(container)).toHaveLength(1)
})
it('排除不可见元素', () => {
container.innerHTML = `
`
expect(getFocusableElements(container).map((el) => el.id)).toEqual(['visible'])
expect(getTabbableElements(container).map((el) => el.id)).toEqual(['visible'])
})
it('排除 inert 容器内的元素', () => {
container.innerHTML = `
`
expect(getFocusableElements(container).map((el) => el.id)).toEqual(['outside'])
})
it('处理 fieldset disabled', () => {
container.innerHTML = `
`
expect(isFocusable(container.querySelector('#legend-input')!)).toBe(true)
expect(isFocusable(container.querySelector('#blocked-input')!)).toBe(false)
})
it('radio group 中只有 checked 项 tabbable', () => {
container.innerHTML = `
`
expect(isFocusable(container.querySelector('#r1')!)).toBe(true)
expect(isFocusable(container.querySelector('#r2')!)).toBe(true)
expect(isTabbable(container.querySelector('#r1')!)).toBe(false)
expect(isTabbable(container.querySelector('#r2')!)).toBe(true)
expect(getTabbableElements(container)).toHaveLength(1)
})
it('按 tab 顺序排序 tabbable 元素', () => {
container.innerHTML = `
`
expect(getTabbableElements(container).map((el) => el.id)).toEqual(['second', 'third', 'first'])
})
it('includeContainer 包含容器本身', () => {
container.setAttribute('tabindex', '0')
container.innerHTML = ''
expect(getFocusableElements(container, {includeContainer: true})).toHaveLength(2)
expect(getTabbableElements(container, {includeContainer: true})).toHaveLength(2)
})
it('遍历 open shadow root', () => {
const host = document.createElement('div')
container.appendChild(host)
const shadow = host.attachShadow({mode: 'open'})
shadow.innerHTML = ''
expect(getFocusableElements(host)).toHaveLength(1)
expect(getTabbableElements(host)).toHaveLength(1)
})
it('details 未展开时内部元素不可聚焦', () => {
container.innerHTML = `
Summary
`
expect(isFocusable(container.querySelector('summary')!)).toBe(true)
expect(isFocusable(container.querySelector('#inside')!)).toBe(false)
})
it('iframe 计入 focusable', () => {
container.innerHTML = ''
expect(getFocusableElements(container)).toHaveLength(2)
expect(isFocusable(container.querySelector('iframe')!)).toBe(true)
expect(isTabbable(container.querySelector('iframe')!)).toBe(false)
})
it('contenteditable 元素识别', () => {
container.innerHTML = `
Editable
True
Not
`
expect(isFocusable(container.querySelector('#editable')!)).toBe(true)
expect(isTabbable(container.querySelector('#editable')!)).toBe(true)
expect(isFocusable(container.querySelector('#editable-true')!)).toBe(true)
expect(isTabbable(container.querySelector('#editable-true')!)).toBe(true)
expect(isFocusable(container.querySelector('#no-edit')!)).toBe(false)
expect(isTabbable(container.querySelector('#no-edit')!)).toBe(false)
})
it('audio/video controls 元素识别', () => {
container.innerHTML = `
`
expect(isFocusable(container.querySelector('#audio-ctrl')!)).toBe(true)
expect(isFocusable(container.querySelector('#audio-noctrl')!)).toBe(false)
expect(isFocusable(container.querySelector('#video-ctrl')!)).toBe(true)
expect(isFocusable(container.querySelector('#video-noctrl')!)).toBe(false)
expect(isTabbable(container.querySelector('#audio-ctrl')!)).toBe(true)
expect(isTabbable(container.querySelector('#video-ctrl')!)).toBe(true)
})
it('area[href] 计入 focusable', () => {
container.innerHTML = `
`
expect(isFocusable(container.querySelector('#area-href')!)).toBe(true)
expect(isFocusable(container.querySelector('#area-nohref')!)).toBe(false)
})
it('displayCheck: none 不过滤隐藏元素', () => {
container.innerHTML = `
`
const opts = {displayCheck: 'none' as const}
expect(getFocusableElements(container, opts).map((el) => el.id)).toEqual(['visible', 'hidden'])
expect(getTabbableElements(container, opts).map((el) => el.id)).toEqual(['visible', 'hidden'])
expect(isFocusable(container.querySelector('#hidden')!, opts)).toBe(true)
expect(isTabbable(container.querySelector('#hidden')!, opts)).toBe(true)
})
it('displayCheck: non-zero-area 过滤零面积元素', () => {
container.innerHTML = `
`
const opts = {displayCheck: 'non-zero-area' as const}
const result = getFocusableElements(container, opts)
expect(result.map((el) => el.id)).toEqual(['normal'])
expect(isFocusable(container.querySelector('#zero')!, opts)).toBe(false)
})
it('非挂载元素在 full displayCheck 下不可聚焦', () => {
const detached = document.createElement('button')
expect(isFocusable(detached)).toBe(false)
expect(isTabbable(detached)).toBe(false)
})
it('元素自身带 inert 属性不被识别', () => {
container.innerHTML = `
`
expect(isFocusable(container.querySelector('#self-inert')!)).toBe(false)
expect(isTabbable(container.querySelector('#self-inert')!)).toBe(false)
expect(getFocusableElements(container).map((el) => el.id)).toEqual(['normal'])
})
it('嵌套 inert:祖先 inert 过滤后代元素', () => {
container.innerHTML = `
`
const result = getFocusableElements(container)
expect(result.map((el) => el.id)).toEqual(['ok'])
})
it('tabindex 混合排序:正数 > 零 > 负数不可 tabbable', () => {
container.innerHTML = `
`
const result = getTabbableElements(container)
expect(result).toHaveLength(5)
expect(result.map((el) => el.id).slice(0, 3)).toEqual(['pos-1', 'pos-3', 'pos-5'])
expect(result.map((el) => el.id).slice(3)).toEqual(['zero-1', 'zero-2'])
})
it('自定义 getShadowRoot 函数解析 shadow', () => {
const shadowContainer = document.createElement('div')
container.appendChild(shadowContainer)
const closedShadow = shadowContainer.attachShadow({mode: 'closed'})
const inner = document.createElement('button')
inner.id = 'inner-btn'
closedShadow.appendChild(inner)
const getShadow = (el: Element) => (el === shadowContainer ? closedShadow : undefined)
const result = getFocusableElements(shadowContainer, {getShadowRoot: getShadow})
expect(result.map((el) => el.id)).toEqual(['inner-btn'])
})
it('select 和 textarea 基础识别', () => {
container.innerHTML = `
`
expect(isFocusable(container.querySelector('#sel')!)).toBe(true)
expect(isTabbable(container.querySelector('#sel')!)).toBe(true)
expect(isFocusable(container.querySelector('#ta')!)).toBe(true)
expect(isFocusable(container.querySelector('#sel-disabled')!)).toBe(false)
})
it('不同 form 中间名 radio 各自独立 tabbable', () => {
container.innerHTML = `
`
expect(getTabbableElements(container).map((el) => el.id)).toEqual(['a2', 'b2'])
expect(isTabbable(container.querySelector('#a2')!)).toBe(true)
expect(isTabbable(container.querySelector('#b2')!)).toBe(true)
})
it('details 有 summary 时自身可聚焦,无 summary 时不纳入 candidate', () => {
container.innerHTML = `
Click
`
expect(isFocusable(container.querySelector('#with-summary')!)).toBe(false)
expect(isFocusable(container.querySelector('#no-summary')!)).toBe(true)
})
it('多层嵌套 shadow DOM 中收集元素', () => {
const outer = document.createElement('div')
container.appendChild(outer)
const outerShadow = outer.attachShadow({mode: 'open'})
const mid = document.createElement('div')
outerShadow.appendChild(mid)
const innerShadow = mid.attachShadow({mode: 'open'})
innerShadow.innerHTML = ''
const result = getFocusableElements(outer)
expect(result.map((el) => el.id)).toEqual(['deep-shadow-btn'])
})
it('slot 展开 assigned elements 后收集', () => {
const host = document.createElement('div')
container.appendChild(host)
const shadow = host.attachShadow({mode: 'open'})
shadow.innerHTML = ''
const btn = document.createElement('button')
btn.id = 'slotted-btn'
host.appendChild(btn)
const result = getFocusableElements(host)
expect(result.map((el) => el.id)).toEqual(['slotted-btn'])
})
it('visibility: collapse 等同于 hidden', () => {
container.innerHTML = `
`
expect(getFocusableElements(container).map((el) => el.id)).toEqual(['visible'])
expect(isFocusable(container.querySelector('#collapsed')!)).toBe(false)
})
it('空容器返回空数组', () => {
container.innerHTML = ''
expect(getFocusableElements(container)).toHaveLength(0)
expect(getTabbableElements(container)).toHaveLength(0)
})
it('displayCheck: full-native 使用 checkVisibility API', () => {
container.innerHTML = ''
const opt = {displayCheck: 'full-native' as const}
expect(isFocusable(container.querySelector('#btn')!, opt)).toBe(true)
expect(isTabbable(container.querySelector('#btn')!, opt)).toBe(true)
})
})