@wwog/react 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/index.d.mts +158 -10
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/components/Sundry/FocusTrap.test.tsx +238 -0
- package/src/components/Sundry/FocusTrap.tsx +238 -0
- package/src/components/Sundry/index.ts +1 -0
- package/src/utils/focusable.test.ts +376 -0
- package/src/utils/focusable.ts +609 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import React, { useRef, useEffect, useCallback, type ReactNode } from 'react'
|
|
2
|
+
import { getTabbableElements, type FocusableOptions } from '../../utils/focusable'
|
|
3
|
+
|
|
4
|
+
export type FocusDirection = 'next' | 'prev' | 'first' | 'last'
|
|
5
|
+
|
|
6
|
+
export interface FocusTrapProps {
|
|
7
|
+
/**
|
|
8
|
+
* @description_en The child elements to trap focus within.
|
|
9
|
+
* @description_zh 需要劫持焦点的子元素。
|
|
10
|
+
*/
|
|
11
|
+
children?: ReactNode
|
|
12
|
+
/**
|
|
13
|
+
* @description_en Whether to disable the focus trap.
|
|
14
|
+
* @description_zh 是否禁用焦点劫持。
|
|
15
|
+
* @default false
|
|
16
|
+
*/
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
/**
|
|
19
|
+
* @description_en Whether to auto-focus the first tabbable element on mount.
|
|
20
|
+
* @description_zh 是否在挂载时自动聚焦到第一个可 Tab 聚焦的元素。
|
|
21
|
+
* @default false
|
|
22
|
+
*/
|
|
23
|
+
autoFocus?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* @description_en Whether to restore focus to the previously focused element on unmount.
|
|
26
|
+
* @description_zh 是否在卸载时恢复焦点到之前聚焦的元素。
|
|
27
|
+
* @default false
|
|
28
|
+
*/
|
|
29
|
+
restoreFocus?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* @description_en Custom key-to-direction mapping to extend or override the default Tab-based navigation.
|
|
32
|
+
* @description_zh 自定义按键到焦点方向的映射,用于扩展或覆盖默认的 Tab 导航。
|
|
33
|
+
* @default { Tab: 'next' }
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* // Arrow up/down navigation
|
|
37
|
+
* keyMap={{ ArrowDown: 'next', ArrowUp: 'prev' }}
|
|
38
|
+
* // Arrow left/right navigation
|
|
39
|
+
* keyMap={{ ArrowRight: 'next', ArrowLeft: 'prev' }}
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
keyMap?: Partial<Record<string, FocusDirection>>
|
|
43
|
+
/**
|
|
44
|
+
* @description_en Custom focus resolution function. Return the element to focus, or null to use default cycle.
|
|
45
|
+
* @description_zh 自定义焦点解析函数。返回要聚焦的元素,或返回 null 使用默认循环行为。
|
|
46
|
+
* @optional
|
|
47
|
+
*/
|
|
48
|
+
onNavigate?: (
|
|
49
|
+
current: HTMLElement | null,
|
|
50
|
+
elements: HTMLElement[],
|
|
51
|
+
direction: FocusDirection,
|
|
52
|
+
) => HTMLElement | null
|
|
53
|
+
/**
|
|
54
|
+
* @description_en Options passed to getTabbableElements.
|
|
55
|
+
* @description_zh 传递给 getTabbableElements 的选项。
|
|
56
|
+
* @optional
|
|
57
|
+
*/
|
|
58
|
+
focusableOptions?: FocusableOptions
|
|
59
|
+
/**
|
|
60
|
+
* @description_en CSS class name for the container.
|
|
61
|
+
* @description_zh 容器元素的 CSS 类名。
|
|
62
|
+
* @optional
|
|
63
|
+
*/
|
|
64
|
+
className?: string
|
|
65
|
+
/**
|
|
66
|
+
* @description_en Inline styles for the container.
|
|
67
|
+
* @description_zh 容器元素的内联样式。
|
|
68
|
+
* @optional
|
|
69
|
+
*/
|
|
70
|
+
style?: React.CSSProperties
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const defaultKeyMap: Record<string, FocusDirection> = { Tab: 'next' }
|
|
74
|
+
|
|
75
|
+
function getTargetIndex(
|
|
76
|
+
currentIndex: number,
|
|
77
|
+
elements: HTMLElement[],
|
|
78
|
+
direction: FocusDirection,
|
|
79
|
+
): number {
|
|
80
|
+
const last = elements.length - 1
|
|
81
|
+
switch (direction) {
|
|
82
|
+
case 'next':
|
|
83
|
+
return currentIndex < last ? currentIndex + 1 : 0
|
|
84
|
+
case 'prev':
|
|
85
|
+
return currentIndex > 0 ? currentIndex - 1 : last
|
|
86
|
+
case 'first':
|
|
87
|
+
return 0
|
|
88
|
+
case 'last':
|
|
89
|
+
return last
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @description_zh 焦点陷阱组件,将键盘焦点循环限制在容器内的可聚焦元素中,支持自定义按键映射和导航逻辑。
|
|
95
|
+
* @description_en Focus trap component that constrains keyboard focus cycling to focusable elements within a container, with support for custom key mappings and navigation logic.
|
|
96
|
+
* @component
|
|
97
|
+
* @example
|
|
98
|
+
* ```tsx
|
|
99
|
+
* // Default Tab trapping
|
|
100
|
+
* <FocusTrap>
|
|
101
|
+
* <input />
|
|
102
|
+
* <button>Save</button>
|
|
103
|
+
* </FocusTrap>
|
|
104
|
+
*
|
|
105
|
+
* // Arrow key navigation
|
|
106
|
+
* <FocusTrap keyMap={{ ArrowDown: 'next', ArrowUp: 'prev' }}>
|
|
107
|
+
* <input />
|
|
108
|
+
* <button>Save</button>
|
|
109
|
+
* </FocusTrap>
|
|
110
|
+
*
|
|
111
|
+
* // With auto-focus and restore
|
|
112
|
+
* <FocusTrap autoFocus restoreFocus>
|
|
113
|
+
* <input />
|
|
114
|
+
* <button>Save</button>
|
|
115
|
+
* </FocusTrap>
|
|
116
|
+
*
|
|
117
|
+
* // Cross-list navigation: items from multiple lists are collected
|
|
118
|
+
* // into a single focus order, seamlessly crossing between lists.
|
|
119
|
+
* // ArrowDown from A-2 → B-1, ArrowUp from B-1 → A-2
|
|
120
|
+
* <FocusTrap keyMap={{ ArrowDown: 'next', ArrowUp: 'prev' }}>
|
|
121
|
+
* <div>
|
|
122
|
+
* <h3>List A</h3>
|
|
123
|
+
* <button>A-1</button>
|
|
124
|
+
* <button>A-2</button>
|
|
125
|
+
* </div>
|
|
126
|
+
* <div>
|
|
127
|
+
* <h3>List B</h3>
|
|
128
|
+
* <button>B-1</button>
|
|
129
|
+
* <button>B-2</button>
|
|
130
|
+
* </div>
|
|
131
|
+
* </FocusTrap>
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export function FocusTrap({
|
|
135
|
+
children,
|
|
136
|
+
disabled = false,
|
|
137
|
+
autoFocus = false,
|
|
138
|
+
restoreFocus = false,
|
|
139
|
+
keyMap,
|
|
140
|
+
onNavigate,
|
|
141
|
+
focusableOptions,
|
|
142
|
+
className,
|
|
143
|
+
style,
|
|
144
|
+
}: FocusTrapProps): ReactNode {
|
|
145
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
146
|
+
const previousActiveElement = useRef<HTMLElement | null>(null)
|
|
147
|
+
|
|
148
|
+
const getTabbable = useCallback(() => {
|
|
149
|
+
if (!containerRef.current) return []
|
|
150
|
+
return getTabbableElements(containerRef.current, focusableOptions)
|
|
151
|
+
}, [focusableOptions])
|
|
152
|
+
|
|
153
|
+
const focusElement = useCallback((element: HTMLElement | null) => {
|
|
154
|
+
if (element && typeof element.focus === 'function') {
|
|
155
|
+
element.focus()
|
|
156
|
+
}
|
|
157
|
+
}, [])
|
|
158
|
+
|
|
159
|
+
const handleKeyDown = useCallback(
|
|
160
|
+
(e: KeyboardEvent) => {
|
|
161
|
+
if (disabled) return
|
|
162
|
+
|
|
163
|
+
const mergedKeyMap = { ...defaultKeyMap, ...keyMap }
|
|
164
|
+
let direction = mergedKeyMap[e.key]
|
|
165
|
+
if (!direction) return
|
|
166
|
+
|
|
167
|
+
e.preventDefault()
|
|
168
|
+
e.stopPropagation()
|
|
169
|
+
|
|
170
|
+
const elements = getTabbable()
|
|
171
|
+
if (elements.length === 0) return
|
|
172
|
+
|
|
173
|
+
if (e.key === 'Tab' && e.shiftKey) {
|
|
174
|
+
direction = 'prev'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const currentElement = document.activeElement as HTMLElement | null
|
|
178
|
+
const currentIndex = currentElement ? elements.indexOf(currentElement) : -1
|
|
179
|
+
|
|
180
|
+
let target: HTMLElement | null = null
|
|
181
|
+
|
|
182
|
+
if (onNavigate) {
|
|
183
|
+
target = onNavigate(currentElement, elements, direction)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!target) {
|
|
187
|
+
const targetIndex = getTargetIndex(currentIndex, elements, direction)
|
|
188
|
+
target = elements[targetIndex]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
focusElement(target)
|
|
192
|
+
},
|
|
193
|
+
[disabled, keyMap, onNavigate, getTabbable, focusElement],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (disabled) return
|
|
198
|
+
const container = containerRef.current
|
|
199
|
+
if (!container) return
|
|
200
|
+
|
|
201
|
+
container.addEventListener('keydown', handleKeyDown)
|
|
202
|
+
return () => {
|
|
203
|
+
container.removeEventListener('keydown', handleKeyDown)
|
|
204
|
+
}
|
|
205
|
+
}, [disabled, handleKeyDown])
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (disabled || !autoFocus) return
|
|
209
|
+
const elements = getTabbable()
|
|
210
|
+
if (elements.length > 0) {
|
|
211
|
+
elements[0].focus()
|
|
212
|
+
}
|
|
213
|
+
}, [disabled, autoFocus, getTabbable])
|
|
214
|
+
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (disabled || !restoreFocus) return
|
|
217
|
+
|
|
218
|
+
const prev = document.activeElement as HTMLElement | null
|
|
219
|
+
if (prev && prev !== containerRef.current) {
|
|
220
|
+
previousActiveElement.current = prev
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return () => {
|
|
224
|
+
const prev = previousActiveElement.current
|
|
225
|
+
if (prev && typeof prev.focus === 'function') {
|
|
226
|
+
prev.focus()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}, [disabled, restoreFocus])
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div ref={containerRef} className={className} style={style}>
|
|
233
|
+
{children}
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
FocusTrap.displayName = 'W/FocusTrap'
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
|
2
|
+
import {getFocusableElements, getTabbableElements, isFocusable, isTabbable} from './focusable'
|
|
3
|
+
|
|
4
|
+
describe('focusable utils', () => {
|
|
5
|
+
let container: HTMLDivElement
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
container = document.createElement('div')
|
|
9
|
+
document.body.appendChild(container)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
container.remove()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('识别基础可聚焦元素', () => {
|
|
17
|
+
container.innerHTML = `
|
|
18
|
+
<button id="btn">Button</button>
|
|
19
|
+
<input id="input" />
|
|
20
|
+
<a id="link" href="#">Link</a>
|
|
21
|
+
<a id="no-link">No Link</a>
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
const btn = container.querySelector('#btn')!
|
|
25
|
+
const input = container.querySelector('#input')!
|
|
26
|
+
const link = container.querySelector('#link')!
|
|
27
|
+
const noLink = container.querySelector('#no-link')!
|
|
28
|
+
|
|
29
|
+
expect(isFocusable(btn)).toBe(true)
|
|
30
|
+
expect(isTabbable(btn)).toBe(true)
|
|
31
|
+
expect(isFocusable(input)).toBe(true)
|
|
32
|
+
expect(isFocusable(link)).toBe(true)
|
|
33
|
+
expect(isFocusable(noLink)).toBe(false)
|
|
34
|
+
expect(isTabbable(noLink)).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('区分 focusable 与 tabbable(tabindex=-1)', () => {
|
|
38
|
+
container.innerHTML = `<div id="neg" tabindex="-1">Negative</div>`
|
|
39
|
+
|
|
40
|
+
const neg = container.querySelector('#neg')!
|
|
41
|
+
expect(isFocusable(neg)).toBe(true)
|
|
42
|
+
expect(isTabbable(neg)).toBe(false)
|
|
43
|
+
expect(getFocusableElements(container)).toHaveLength(1)
|
|
44
|
+
expect(getTabbableElements(container)).toHaveLength(0)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('排除 disabled 与 hidden input', () => {
|
|
48
|
+
container.innerHTML = `
|
|
49
|
+
<button disabled>Disabled</button>
|
|
50
|
+
<input type="hidden" />
|
|
51
|
+
<input disabled />
|
|
52
|
+
<button>Enabled</button>
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
expect(getFocusableElements(container)).toHaveLength(1)
|
|
56
|
+
expect(getTabbableElements(container)).toHaveLength(1)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('排除不可见元素', () => {
|
|
60
|
+
container.innerHTML = `
|
|
61
|
+
<button id="visible">Visible</button>
|
|
62
|
+
<button id="hidden" style="display: none">Hidden</button>
|
|
63
|
+
<button id="invisible" style="visibility: hidden">Invisible</button>
|
|
64
|
+
`
|
|
65
|
+
|
|
66
|
+
expect(getFocusableElements(container).map((el) => el.id)).toEqual(['visible'])
|
|
67
|
+
expect(getTabbableElements(container).map((el) => el.id)).toEqual(['visible'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('排除 inert 容器内的元素', () => {
|
|
71
|
+
container.innerHTML = `
|
|
72
|
+
<button id="outside">Outside</button>
|
|
73
|
+
<div inert>
|
|
74
|
+
<button id="inside">Inside</button>
|
|
75
|
+
</div>
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
expect(getFocusableElements(container).map((el) => el.id)).toEqual(['outside'])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('处理 fieldset disabled', () => {
|
|
82
|
+
container.innerHTML = `
|
|
83
|
+
<fieldset disabled>
|
|
84
|
+
<legend><input id="legend-input" /></legend>
|
|
85
|
+
<input id="blocked-input" />
|
|
86
|
+
</fieldset>
|
|
87
|
+
`
|
|
88
|
+
|
|
89
|
+
expect(isFocusable(container.querySelector('#legend-input')!)).toBe(true)
|
|
90
|
+
expect(isFocusable(container.querySelector('#blocked-input')!)).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('radio group 中只有 checked 项 tabbable', () => {
|
|
94
|
+
container.innerHTML = `
|
|
95
|
+
<input type="radio" name="group" id="r1" />
|
|
96
|
+
<input type="radio" name="group" id="r2" checked />
|
|
97
|
+
<input type="radio" name="group" id="r3" />
|
|
98
|
+
`
|
|
99
|
+
|
|
100
|
+
expect(isFocusable(container.querySelector('#r1')!)).toBe(true)
|
|
101
|
+
expect(isFocusable(container.querySelector('#r2')!)).toBe(true)
|
|
102
|
+
expect(isTabbable(container.querySelector('#r1')!)).toBe(false)
|
|
103
|
+
expect(isTabbable(container.querySelector('#r2')!)).toBe(true)
|
|
104
|
+
expect(getTabbableElements(container)).toHaveLength(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('按 tab 顺序排序 tabbable 元素', () => {
|
|
108
|
+
container.innerHTML = `
|
|
109
|
+
<button id="third" tabindex="3">Third</button>
|
|
110
|
+
<button id="first">First</button>
|
|
111
|
+
<button id="second" tabindex="1">Second</button>
|
|
112
|
+
`
|
|
113
|
+
|
|
114
|
+
expect(getTabbableElements(container).map((el) => el.id)).toEqual(['second', 'third', 'first'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('includeContainer 包含容器本身', () => {
|
|
118
|
+
container.setAttribute('tabindex', '0')
|
|
119
|
+
container.innerHTML = '<button>Child</button>'
|
|
120
|
+
|
|
121
|
+
expect(getFocusableElements(container, {includeContainer: true})).toHaveLength(2)
|
|
122
|
+
expect(getTabbableElements(container, {includeContainer: true})).toHaveLength(2)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('遍历 open shadow root', () => {
|
|
126
|
+
const host = document.createElement('div')
|
|
127
|
+
container.appendChild(host)
|
|
128
|
+
const shadow = host.attachShadow({mode: 'open'})
|
|
129
|
+
shadow.innerHTML = '<button id="shadow-btn">Shadow</button>'
|
|
130
|
+
|
|
131
|
+
expect(getFocusableElements(host)).toHaveLength(1)
|
|
132
|
+
expect(getTabbableElements(host)).toHaveLength(1)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('details 未展开时内部元素不可聚焦', () => {
|
|
136
|
+
container.innerHTML = `
|
|
137
|
+
<details id="details">
|
|
138
|
+
<summary>Summary</summary>
|
|
139
|
+
<button id="inside">Inside</button>
|
|
140
|
+
</details>
|
|
141
|
+
`
|
|
142
|
+
|
|
143
|
+
expect(isFocusable(container.querySelector('summary')!)).toBe(true)
|
|
144
|
+
expect(isFocusable(container.querySelector('#inside')!)).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('iframe 计入 focusable', () => {
|
|
148
|
+
container.innerHTML = '<iframe title="frame"></iframe><button>Btn</button>'
|
|
149
|
+
|
|
150
|
+
expect(getFocusableElements(container)).toHaveLength(2)
|
|
151
|
+
expect(isFocusable(container.querySelector('iframe')!)).toBe(true)
|
|
152
|
+
expect(isTabbable(container.querySelector('iframe')!)).toBe(false)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('contenteditable 元素识别', () => {
|
|
156
|
+
container.innerHTML = `
|
|
157
|
+
<div id="editable" contenteditable>Editable</div>
|
|
158
|
+
<div id="editable-true" contenteditable="true">True</div>
|
|
159
|
+
<div id="no-edit" contenteditable="false">Not</div>
|
|
160
|
+
`
|
|
161
|
+
|
|
162
|
+
expect(isFocusable(container.querySelector('#editable')!)).toBe(true)
|
|
163
|
+
expect(isTabbable(container.querySelector('#editable')!)).toBe(true)
|
|
164
|
+
expect(isFocusable(container.querySelector('#editable-true')!)).toBe(true)
|
|
165
|
+
expect(isTabbable(container.querySelector('#editable-true')!)).toBe(true)
|
|
166
|
+
expect(isFocusable(container.querySelector('#no-edit')!)).toBe(false)
|
|
167
|
+
expect(isTabbable(container.querySelector('#no-edit')!)).toBe(false)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('audio/video controls 元素识别', () => {
|
|
171
|
+
container.innerHTML = `
|
|
172
|
+
<audio id="audio-ctrl" controls></audio>
|
|
173
|
+
<audio id="audio-noctrl"></audio>
|
|
174
|
+
<video id="video-ctrl" controls></video>
|
|
175
|
+
<video id="video-noctrl"></video>
|
|
176
|
+
`
|
|
177
|
+
|
|
178
|
+
expect(isFocusable(container.querySelector('#audio-ctrl')!)).toBe(true)
|
|
179
|
+
expect(isFocusable(container.querySelector('#audio-noctrl')!)).toBe(false)
|
|
180
|
+
expect(isFocusable(container.querySelector('#video-ctrl')!)).toBe(true)
|
|
181
|
+
expect(isFocusable(container.querySelector('#video-noctrl')!)).toBe(false)
|
|
182
|
+
expect(isTabbable(container.querySelector('#audio-ctrl')!)).toBe(true)
|
|
183
|
+
expect(isTabbable(container.querySelector('#video-ctrl')!)).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('area[href] 计入 focusable', () => {
|
|
187
|
+
container.innerHTML = `
|
|
188
|
+
<map name="testmap">
|
|
189
|
+
<area id="area-href" shape="rect" href="#" coords="0,0,100,100" />
|
|
190
|
+
<area id="area-nohref" shape="rect" coords="0,0,100,100" />
|
|
191
|
+
</map>
|
|
192
|
+
`
|
|
193
|
+
|
|
194
|
+
expect(isFocusable(container.querySelector('#area-href')!)).toBe(true)
|
|
195
|
+
expect(isFocusable(container.querySelector('#area-nohref')!)).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('displayCheck: none 不过滤隐藏元素', () => {
|
|
199
|
+
container.innerHTML = `
|
|
200
|
+
<button id="visible">V</button>
|
|
201
|
+
<button id="hidden" style="display: none">H</button>
|
|
202
|
+
`
|
|
203
|
+
const opts = {displayCheck: 'none' as const}
|
|
204
|
+
|
|
205
|
+
expect(getFocusableElements(container, opts).map((el) => el.id)).toEqual(['visible', 'hidden'])
|
|
206
|
+
expect(getTabbableElements(container, opts).map((el) => el.id)).toEqual(['visible', 'hidden'])
|
|
207
|
+
expect(isFocusable(container.querySelector('#hidden')!, opts)).toBe(true)
|
|
208
|
+
expect(isTabbable(container.querySelector('#hidden')!, opts)).toBe(true)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('displayCheck: non-zero-area 过滤零面积元素', () => {
|
|
212
|
+
container.innerHTML = `
|
|
213
|
+
<button id="normal">N</button>
|
|
214
|
+
<button id="zero" style="width:0;height:0;overflow:hidden;padding:0;border:0"></button>
|
|
215
|
+
`
|
|
216
|
+
const opts = {displayCheck: 'non-zero-area' as const}
|
|
217
|
+
|
|
218
|
+
const result = getFocusableElements(container, opts)
|
|
219
|
+
expect(result.map((el) => el.id)).toEqual(['normal'])
|
|
220
|
+
expect(isFocusable(container.querySelector('#zero')!, opts)).toBe(false)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('非挂载元素在 full displayCheck 下不可聚焦', () => {
|
|
224
|
+
const detached = document.createElement('button')
|
|
225
|
+
expect(isFocusable(detached)).toBe(false)
|
|
226
|
+
expect(isTabbable(detached)).toBe(false)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('元素自身带 inert 属性不被识别', () => {
|
|
230
|
+
container.innerHTML = `
|
|
231
|
+
<button id="normal">N</button>
|
|
232
|
+
<button id="self-inert" inert>S</button>
|
|
233
|
+
`
|
|
234
|
+
expect(isFocusable(container.querySelector('#self-inert')!)).toBe(false)
|
|
235
|
+
expect(isTabbable(container.querySelector('#self-inert')!)).toBe(false)
|
|
236
|
+
expect(getFocusableElements(container).map((el) => el.id)).toEqual(['normal'])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('嵌套 inert:祖先 inert 过滤后代元素', () => {
|
|
240
|
+
container.innerHTML = `
|
|
241
|
+
<div>
|
|
242
|
+
<button id="ok">OK</button>
|
|
243
|
+
</div>
|
|
244
|
+
<div inert>
|
|
245
|
+
<div>
|
|
246
|
+
<button id="deep">Deep</button>
|
|
247
|
+
</div>
|
|
248
|
+
<input id="deep-input" />
|
|
249
|
+
</div>
|
|
250
|
+
`
|
|
251
|
+
const result = getFocusableElements(container)
|
|
252
|
+
expect(result.map((el) => el.id)).toEqual(['ok'])
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('tabindex 混合排序:正数 > 零 > 负数不可 tabbable', () => {
|
|
256
|
+
container.innerHTML = `
|
|
257
|
+
<button id="zero-1">Z1</button>
|
|
258
|
+
<button id="pos-5" tabindex="5">P5</button>
|
|
259
|
+
<button id="pos-3" tabindex="3">P3</button>
|
|
260
|
+
<button id="zero-2">Z2</button>
|
|
261
|
+
<button id="neg" tabindex="-1">Neg</button>
|
|
262
|
+
<button id="pos-1" tabindex="1">P1</button>
|
|
263
|
+
`
|
|
264
|
+
const result = getTabbableElements(container)
|
|
265
|
+
expect(result).toHaveLength(5)
|
|
266
|
+
expect(result.map((el) => el.id).slice(0, 3)).toEqual(['pos-1', 'pos-3', 'pos-5'])
|
|
267
|
+
expect(result.map((el) => el.id).slice(3)).toEqual(['zero-1', 'zero-2'])
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('自定义 getShadowRoot 函数解析 shadow', () => {
|
|
271
|
+
const shadowContainer = document.createElement('div')
|
|
272
|
+
container.appendChild(shadowContainer)
|
|
273
|
+
const closedShadow = shadowContainer.attachShadow({mode: 'closed'})
|
|
274
|
+
const inner = document.createElement('button')
|
|
275
|
+
inner.id = 'inner-btn'
|
|
276
|
+
closedShadow.appendChild(inner)
|
|
277
|
+
|
|
278
|
+
const getShadow = (el: Element) => (el === shadowContainer ? closedShadow : undefined)
|
|
279
|
+
const result = getFocusableElements(shadowContainer, {getShadowRoot: getShadow})
|
|
280
|
+
expect(result.map((el) => el.id)).toEqual(['inner-btn'])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('select 和 textarea 基础识别', () => {
|
|
284
|
+
container.innerHTML = `
|
|
285
|
+
<select id="sel"><option>A</option></select>
|
|
286
|
+
<textarea id="ta"></textarea>
|
|
287
|
+
<select id="sel-disabled" disabled><option>B</option></select>
|
|
288
|
+
`
|
|
289
|
+
|
|
290
|
+
expect(isFocusable(container.querySelector('#sel')!)).toBe(true)
|
|
291
|
+
expect(isTabbable(container.querySelector('#sel')!)).toBe(true)
|
|
292
|
+
expect(isFocusable(container.querySelector('#ta')!)).toBe(true)
|
|
293
|
+
expect(isFocusable(container.querySelector('#sel-disabled')!)).toBe(false)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('不同 form 中间名 radio 各自独立 tabbable', () => {
|
|
297
|
+
container.innerHTML = `
|
|
298
|
+
<form id="form-a">
|
|
299
|
+
<input type="radio" name="r" id="a1" />
|
|
300
|
+
<input type="radio" name="r" id="a2" checked />
|
|
301
|
+
</form>
|
|
302
|
+
<form id="form-b">
|
|
303
|
+
<input type="radio" name="r" id="b1" />
|
|
304
|
+
<input type="radio" name="r" id="b2" checked />
|
|
305
|
+
</form>
|
|
306
|
+
`
|
|
307
|
+
|
|
308
|
+
expect(getTabbableElements(container).map((el) => el.id)).toEqual(['a2', 'b2'])
|
|
309
|
+
expect(isTabbable(container.querySelector('#a2')!)).toBe(true)
|
|
310
|
+
expect(isTabbable(container.querySelector('#b2')!)).toBe(true)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('details 有 summary 时自身可聚焦,无 summary 时不纳入 candidate', () => {
|
|
314
|
+
container.innerHTML = `
|
|
315
|
+
<details id="with-summary">
|
|
316
|
+
<summary>Click</summary>
|
|
317
|
+
<button>Inside</button>
|
|
318
|
+
</details>
|
|
319
|
+
<details id="no-summary">
|
|
320
|
+
<button>Inside</button>
|
|
321
|
+
</details>
|
|
322
|
+
`
|
|
323
|
+
|
|
324
|
+
expect(isFocusable(container.querySelector('#with-summary')!)).toBe(false)
|
|
325
|
+
expect(isFocusable(container.querySelector('#no-summary')!)).toBe(true)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('多层嵌套 shadow DOM 中收集元素', () => {
|
|
329
|
+
const outer = document.createElement('div')
|
|
330
|
+
container.appendChild(outer)
|
|
331
|
+
const outerShadow = outer.attachShadow({mode: 'open'})
|
|
332
|
+
const mid = document.createElement('div')
|
|
333
|
+
outerShadow.appendChild(mid)
|
|
334
|
+
const innerShadow = mid.attachShadow({mode: 'open'})
|
|
335
|
+
innerShadow.innerHTML = '<button id="deep-shadow-btn">Deep</button>'
|
|
336
|
+
|
|
337
|
+
const result = getFocusableElements(outer)
|
|
338
|
+
expect(result.map((el) => el.id)).toEqual(['deep-shadow-btn'])
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('slot 展开 assigned elements 后收集', () => {
|
|
342
|
+
const host = document.createElement('div')
|
|
343
|
+
container.appendChild(host)
|
|
344
|
+
const shadow = host.attachShadow({mode: 'open'})
|
|
345
|
+
shadow.innerHTML = '<slot></slot>'
|
|
346
|
+
const btn = document.createElement('button')
|
|
347
|
+
btn.id = 'slotted-btn'
|
|
348
|
+
host.appendChild(btn)
|
|
349
|
+
|
|
350
|
+
const result = getFocusableElements(host)
|
|
351
|
+
expect(result.map((el) => el.id)).toEqual(['slotted-btn'])
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('visibility: collapse 等同于 hidden', () => {
|
|
355
|
+
container.innerHTML = `
|
|
356
|
+
<button id="visible">V</button>
|
|
357
|
+
<button id="collapsed" style="visibility: collapse">C</button>
|
|
358
|
+
`
|
|
359
|
+
|
|
360
|
+
expect(getFocusableElements(container).map((el) => el.id)).toEqual(['visible'])
|
|
361
|
+
expect(isFocusable(container.querySelector('#collapsed')!)).toBe(false)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('空容器返回空数组', () => {
|
|
365
|
+
container.innerHTML = ''
|
|
366
|
+
expect(getFocusableElements(container)).toHaveLength(0)
|
|
367
|
+
expect(getTabbableElements(container)).toHaveLength(0)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('displayCheck: full-native 使用 checkVisibility API', () => {
|
|
371
|
+
container.innerHTML = '<button id="btn">X</button>'
|
|
372
|
+
const opt = {displayCheck: 'full-native' as const}
|
|
373
|
+
expect(isFocusable(container.querySelector('#btn')!, opt)).toBe(true)
|
|
374
|
+
expect(isTabbable(container.querySelector('#btn')!, opt)).toBe(true)
|
|
375
|
+
})
|
|
376
|
+
})
|