@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.
@@ -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'
@@ -6,3 +6,4 @@ export * from './Observer'
6
6
  export * from './Repeat'
7
7
  export * from './Portal'
8
8
  export * from './Boundary'
9
+ export * from './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
+ })