@wwog/react 1.3.14 → 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,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
+ })