@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,609 @@
|
|
|
1
|
+
const candidateSelectors = [
|
|
2
|
+
'input:not([inert]):not([inert] *)',
|
|
3
|
+
'select:not([inert]):not([inert] *)',
|
|
4
|
+
'textarea:not([inert]):not([inert] *)',
|
|
5
|
+
'a[href]:not([inert]):not([inert] *)',
|
|
6
|
+
'area[href]:not([inert]):not([inert] *)',
|
|
7
|
+
'button:not([inert]):not([inert] *)',
|
|
8
|
+
'[tabindex]:not(slot):not([inert]):not([inert] *)',
|
|
9
|
+
'audio[controls]:not([inert]):not([inert] *)',
|
|
10
|
+
'video[controls]:not([inert]):not([inert] *)',
|
|
11
|
+
'[contenteditable]:not([contenteditable="false"]):not([inert]):not([inert] *)',
|
|
12
|
+
'details>summary:first-of-type:not([inert]):not([inert] *)',
|
|
13
|
+
'details:not([inert]):not([inert] *)',
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
const candidateSelector = candidateSelectors.join(',')
|
|
17
|
+
|
|
18
|
+
const focusableCandidateSelector = [
|
|
19
|
+
...candidateSelectors,
|
|
20
|
+
'iframe:not([inert]):not([inert] *)',
|
|
21
|
+
].join(',')
|
|
22
|
+
|
|
23
|
+
const elementMatches =
|
|
24
|
+
typeof Element === 'undefined'
|
|
25
|
+
? () => false
|
|
26
|
+
: Element.prototype.matches ||
|
|
27
|
+
(Element.prototype as Element & {msMatchesSelector?: typeof Element.prototype.matches})
|
|
28
|
+
.msMatchesSelector ||
|
|
29
|
+
(Element.prototype as Element & {webkitMatchesSelector?: typeof Element.prototype.matches})
|
|
30
|
+
.webkitMatchesSelector
|
|
31
|
+
|
|
32
|
+
function matches(node: Element, selector: string): boolean {
|
|
33
|
+
return elementMatches ? elementMatches.call(node, selector) : false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FocusableOptions {
|
|
37
|
+
/**
|
|
38
|
+
* @description_en Whether to include the container element in results.
|
|
39
|
+
* @description_zh 是否将容器元素本身纳入结果。
|
|
40
|
+
* @default false
|
|
41
|
+
*/
|
|
42
|
+
includeContainer?: boolean
|
|
43
|
+
/**
|
|
44
|
+
* @description_en Traverse open shadow roots. Pass a function for custom shadow resolution.
|
|
45
|
+
* @description_zh 是否遍历 open shadow root;也可传入函数自定义 shadow 解析。
|
|
46
|
+
* @default true
|
|
47
|
+
*/
|
|
48
|
+
getShadowRoot?: boolean | GetShadowRootFn
|
|
49
|
+
/**
|
|
50
|
+
* @description_en Strategy for visibility checks.
|
|
51
|
+
* @description_zh 可见性检查策略。
|
|
52
|
+
* @default 'full'
|
|
53
|
+
*/
|
|
54
|
+
displayCheck?: 'full' | 'full-native' | 'legacy-full' | 'non-zero-area' | 'none'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type GetShadowRootFn = (element: Element) => ShadowRoot | boolean | undefined
|
|
58
|
+
|
|
59
|
+
interface CandidateScope {
|
|
60
|
+
scopeParent: Element
|
|
61
|
+
candidates: Array<Element | CandidateScope>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ResolvedOptions {
|
|
65
|
+
includeContainer: boolean
|
|
66
|
+
getShadowRoot: boolean | GetShadowRootFn
|
|
67
|
+
displayCheck: NonNullable<FocusableOptions['displayCheck']>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface SortableTabbable {
|
|
71
|
+
documentOrder: number
|
|
72
|
+
tabIndex: number
|
|
73
|
+
item: Element | CandidateScope
|
|
74
|
+
isScope: boolean
|
|
75
|
+
content: Element[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const defaultOptions: ResolvedOptions = {
|
|
79
|
+
includeContainer: false,
|
|
80
|
+
getShadowRoot: true,
|
|
81
|
+
displayCheck: 'full',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveOptions(options?: FocusableOptions): ResolvedOptions {
|
|
85
|
+
return {
|
|
86
|
+
includeContainer: options?.includeContainer ?? defaultOptions.includeContainer,
|
|
87
|
+
getShadowRoot: options?.getShadowRoot ?? defaultOptions.getShadowRoot,
|
|
88
|
+
displayCheck: options?.displayCheck ?? defaultOptions.displayCheck,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getRootNode(element: Element): Node {
|
|
93
|
+
return element.getRootNode()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isInert(node: Node | null | undefined, lookUp = true): boolean {
|
|
97
|
+
if (!(node instanceof Element)) {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const inertAttr = node.getAttribute('inert')
|
|
102
|
+
const inert = inertAttr === '' || inertAttr === 'true'
|
|
103
|
+
|
|
104
|
+
if (inert) {
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!lookUp) {
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof node.closest === 'function') {
|
|
113
|
+
return node.closest('[inert]') !== null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return isInert(node.parentElement, true)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isContentEditable(node: Element): boolean {
|
|
120
|
+
const value = node.getAttribute('contenteditable')
|
|
121
|
+
return value === '' || value === 'true'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hasTabIndex(node: Element): boolean {
|
|
125
|
+
return !Number.isNaN(Number.parseInt(node.getAttribute('tabindex') ?? '', 10))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @description_zh 获取元素的有效 tab 顺序值(含浏览器默认映射)。
|
|
130
|
+
* @description_en Returns the effective tab order for an element, including browser defaults.
|
|
131
|
+
*/
|
|
132
|
+
export function getTabIndex(node: Element): number {
|
|
133
|
+
if (!(node instanceof HTMLElement)) {
|
|
134
|
+
return -1
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (node.tabIndex < 0) {
|
|
138
|
+
if (
|
|
139
|
+
(/^(AUDIO|VIDEO|DETAILS)$/i.test(node.tagName) || isContentEditable(node)) &&
|
|
140
|
+
!hasTabIndex(node)
|
|
141
|
+
) {
|
|
142
|
+
return 0
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return node.tabIndex
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getSortOrderTabIndex(node: Element, isScope: boolean): number {
|
|
150
|
+
const tabIndex = getTabIndex(node)
|
|
151
|
+
|
|
152
|
+
if (tabIndex < 0 && isScope && !hasTabIndex(node)) {
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return tabIndex
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isInput(node: Element): node is HTMLInputElement {
|
|
160
|
+
return node.tagName === 'INPUT'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isHiddenInput(node: Element): boolean {
|
|
164
|
+
return isInput(node) && node.type === 'hidden'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isDetailsWithSummary(node: Element): boolean {
|
|
168
|
+
return (
|
|
169
|
+
node.tagName === 'DETAILS' &&
|
|
170
|
+
Array.prototype.some.call(node.children, (child: Element) => child.tagName === 'SUMMARY')
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getCheckedRadio(
|
|
175
|
+
nodes: HTMLInputElement[],
|
|
176
|
+
form: HTMLFormElement | null,
|
|
177
|
+
): HTMLInputElement | undefined {
|
|
178
|
+
for (const node of nodes) {
|
|
179
|
+
if (node.checked && node.form === form) {
|
|
180
|
+
return node
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return undefined
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isTabbableRadio(node: HTMLInputElement): boolean {
|
|
187
|
+
if (!node.name) {
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const radioScope = node.form ?? getRootNode(node)
|
|
192
|
+
const queryRadios = (name: string) =>
|
|
193
|
+
(radioScope as ParentNode).querySelectorAll<HTMLInputElement>(
|
|
194
|
+
`input[type="radio"][name="${CSS.escape(name)}"]`,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const radioSet = queryRadios(node.name)
|
|
198
|
+
const checked = getCheckedRadio(Array.from(radioSet), node.form)
|
|
199
|
+
return !checked || checked === node
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isNonTabbableRadio(node: Element): boolean {
|
|
203
|
+
return isInput(node) && node.type === 'radio' && !isTabbableRadio(node)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isNodeAttached(node: Element): boolean {
|
|
207
|
+
let nodeRoot = getRootNode(node)
|
|
208
|
+
let nodeRootHost = nodeRoot instanceof ShadowRoot ? nodeRoot.host : undefined
|
|
209
|
+
let attached = false
|
|
210
|
+
|
|
211
|
+
if (nodeRoot && nodeRoot !== node) {
|
|
212
|
+
attached = Boolean(
|
|
213
|
+
nodeRootHost?.ownerDocument?.contains(nodeRootHost) || node.ownerDocument?.contains(node),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
while (!attached && nodeRootHost) {
|
|
217
|
+
nodeRoot = getRootNode(nodeRootHost)
|
|
218
|
+
nodeRootHost = nodeRoot instanceof ShadowRoot ? nodeRoot.host : undefined
|
|
219
|
+
attached = Boolean(nodeRootHost?.ownerDocument?.contains(nodeRootHost))
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return attached
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isZeroArea(node: Element): boolean {
|
|
227
|
+
const {width, height} = node.getBoundingClientRect()
|
|
228
|
+
return width === 0 && height === 0
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isHidden(node: Element, options: ResolvedOptions): boolean {
|
|
232
|
+
if (options.displayCheck === 'none') {
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (options.displayCheck === 'full-native' && 'checkVisibility' in node) {
|
|
237
|
+
const visible = (node as HTMLElement).checkVisibility({
|
|
238
|
+
checkOpacity: false,
|
|
239
|
+
opacityProperty: false,
|
|
240
|
+
contentVisibilityAuto: true,
|
|
241
|
+
visibilityProperty: true,
|
|
242
|
+
checkVisibilityCSS: true,
|
|
243
|
+
})
|
|
244
|
+
return !visible
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const {visibility} = getComputedStyle(node)
|
|
248
|
+
if (visibility === 'hidden' || visibility === 'collapse') {
|
|
249
|
+
return true
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const isDirectSummary = matches(node, 'details>summary:first-of-type')
|
|
253
|
+
const nodeUnderDetails = isDirectSummary ? node.parentElement : node
|
|
254
|
+
if (nodeUnderDetails && matches(nodeUnderDetails, 'details:not([open]) *')) {
|
|
255
|
+
return true
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (
|
|
259
|
+
options.displayCheck === 'full' ||
|
|
260
|
+
options.displayCheck === 'full-native' ||
|
|
261
|
+
options.displayCheck === 'legacy-full'
|
|
262
|
+
) {
|
|
263
|
+
if (typeof options.getShadowRoot === 'function') {
|
|
264
|
+
const originalNode = node
|
|
265
|
+
let current: Element | null = node
|
|
266
|
+
|
|
267
|
+
while (current) {
|
|
268
|
+
const parentElement: Element | null = current.parentElement
|
|
269
|
+
const rootNode: Node = getRootNode(current)
|
|
270
|
+
|
|
271
|
+
if (
|
|
272
|
+
parentElement &&
|
|
273
|
+
!parentElement.shadowRoot &&
|
|
274
|
+
options.getShadowRoot(parentElement) === true
|
|
275
|
+
) {
|
|
276
|
+
return isZeroArea(current)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (current.assignedSlot) {
|
|
280
|
+
current = current.assignedSlot
|
|
281
|
+
} else if (!parentElement && rootNode !== current.ownerDocument) {
|
|
282
|
+
current = rootNode instanceof ShadowRoot ? rootNode.host : null
|
|
283
|
+
} else {
|
|
284
|
+
current = parentElement
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
node = originalNode
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (isNodeAttached(node)) {
|
|
292
|
+
return node.getClientRects().length === 0
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (options.displayCheck !== 'legacy-full') {
|
|
296
|
+
return true
|
|
297
|
+
}
|
|
298
|
+
} else if (options.displayCheck === 'non-zero-area') {
|
|
299
|
+
return isZeroArea(node)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isDisabledFromFieldset(node: Element): boolean {
|
|
306
|
+
if (!/^(INPUT|BUTTON|SELECT|TEXTAREA)$/i.test(node.tagName)) {
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let parentNode = node.parentElement
|
|
311
|
+
while (parentNode) {
|
|
312
|
+
if (parentNode.tagName === 'FIELDSET' && (parentNode as HTMLFieldSetElement).disabled) {
|
|
313
|
+
for (let i = 0; i < parentNode.children.length; i++) {
|
|
314
|
+
const child = parentNode.children.item(i)
|
|
315
|
+
if (child?.tagName === 'LEGEND') {
|
|
316
|
+
return matches(parentNode, 'fieldset[disabled] *') ? true : !child.contains(node)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return true
|
|
320
|
+
}
|
|
321
|
+
parentNode = parentNode.parentElement
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isDisabledElement(node: HTMLElement): boolean {
|
|
328
|
+
return 'disabled' in node && Boolean((node as HTMLInputElement).disabled)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isFocusableCandidate(node: Element, options: ResolvedOptions): boolean {
|
|
332
|
+
if (!(node instanceof HTMLElement)) {
|
|
333
|
+
return false
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (
|
|
337
|
+
isDisabledElement(node) ||
|
|
338
|
+
isHiddenInput(node) ||
|
|
339
|
+
isHidden(node, options) ||
|
|
340
|
+
isDetailsWithSummary(node) ||
|
|
341
|
+
isDisabledFromFieldset(node)
|
|
342
|
+
) {
|
|
343
|
+
return false
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return true
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function isTabbableCandidate(node: Element, options: ResolvedOptions): boolean {
|
|
350
|
+
if (isNonTabbableRadio(node) || getTabIndex(node) < 0) {
|
|
351
|
+
return false
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return isFocusableCandidate(node, options)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isShadowRootTabbable(shadowHostNode: Element): boolean {
|
|
358
|
+
const tabIndex = Number.parseInt(shadowHostNode.getAttribute('tabindex') ?? '', 10)
|
|
359
|
+
if (Number.isNaN(tabIndex) || tabIndex >= 0) {
|
|
360
|
+
return true
|
|
361
|
+
}
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function getCandidates(
|
|
366
|
+
container: Element,
|
|
367
|
+
includeContainer: boolean,
|
|
368
|
+
filter: (node: Element) => boolean,
|
|
369
|
+
selector = candidateSelector,
|
|
370
|
+
): Element[] {
|
|
371
|
+
if (isInert(container)) {
|
|
372
|
+
return []
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const candidates = Array.from(container.querySelectorAll(selector))
|
|
376
|
+
if (includeContainer && matches(container, selector)) {
|
|
377
|
+
candidates.unshift(container)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return candidates.filter(filter)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function getCandidatesIteratively(
|
|
384
|
+
elements: Element[],
|
|
385
|
+
includeContainer: boolean,
|
|
386
|
+
options: ResolvedOptions,
|
|
387
|
+
filter: (node: Element) => boolean,
|
|
388
|
+
flatten: boolean,
|
|
389
|
+
selector: string,
|
|
390
|
+
shadowRootFilter?: (shadowHostNode: Element) => boolean,
|
|
391
|
+
): Array<Element | CandidateScope> {
|
|
392
|
+
const candidates: Array<Element | CandidateScope> = []
|
|
393
|
+
const elementsToCheck = [...elements]
|
|
394
|
+
|
|
395
|
+
while (elementsToCheck.length > 0) {
|
|
396
|
+
const element = elementsToCheck.shift()
|
|
397
|
+
if (!element) {
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (isInert(element, false)) {
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (element.tagName === 'SLOT') {
|
|
406
|
+
const slot = element as HTMLSlotElement
|
|
407
|
+
const assigned = slot.assignedElements()
|
|
408
|
+
const content = assigned.length > 0 ? assigned : Array.from(element.children)
|
|
409
|
+
const nestedCandidates = getCandidatesIteratively(
|
|
410
|
+
content,
|
|
411
|
+
true,
|
|
412
|
+
options,
|
|
413
|
+
filter,
|
|
414
|
+
flatten,
|
|
415
|
+
selector,
|
|
416
|
+
shadowRootFilter,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if (flatten) {
|
|
420
|
+
candidates.push(...nestedCandidates)
|
|
421
|
+
} else {
|
|
422
|
+
candidates.push({
|
|
423
|
+
scopeParent: element,
|
|
424
|
+
candidates: nestedCandidates,
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (
|
|
431
|
+
matches(element, selector) &&
|
|
432
|
+
filter(element) &&
|
|
433
|
+
(includeContainer || !elements.includes(element))
|
|
434
|
+
) {
|
|
435
|
+
candidates.push(element)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const getShadowRootOption = options.getShadowRoot
|
|
439
|
+
const shadowRoot: ShadowRoot | boolean | undefined =
|
|
440
|
+
element.shadowRoot ??
|
|
441
|
+
(typeof getShadowRootOption === 'function' ? getShadowRootOption(element) : undefined)
|
|
442
|
+
|
|
443
|
+
const validShadowRoot =
|
|
444
|
+
Boolean(shadowRoot) &&
|
|
445
|
+
!isInert(shadowRoot as Node, false) &&
|
|
446
|
+
(!shadowRootFilter || shadowRootFilter(element))
|
|
447
|
+
|
|
448
|
+
if (validShadowRoot) {
|
|
449
|
+
const nestedCandidates = getCandidatesIteratively(
|
|
450
|
+
shadowRoot === true
|
|
451
|
+
? Array.from(element.children)
|
|
452
|
+
: Array.from((shadowRoot as ShadowRoot).children),
|
|
453
|
+
true,
|
|
454
|
+
options,
|
|
455
|
+
filter,
|
|
456
|
+
flatten,
|
|
457
|
+
selector,
|
|
458
|
+
shadowRootFilter,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
if (flatten) {
|
|
462
|
+
candidates.push(...nestedCandidates)
|
|
463
|
+
} else {
|
|
464
|
+
candidates.push({
|
|
465
|
+
scopeParent: element,
|
|
466
|
+
candidates: nestedCandidates,
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
elementsToCheck.unshift(...Array.from(element.children))
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return candidates
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function sortOrderedTabbables(a: SortableTabbable, b: SortableTabbable): number {
|
|
478
|
+
return a.tabIndex === b.tabIndex ? a.documentOrder - b.documentOrder : a.tabIndex - b.tabIndex
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function sortByTabOrder(candidates: Array<Element | CandidateScope>): Element[] {
|
|
482
|
+
const regularTabbables: Element[] = []
|
|
483
|
+
const orderedTabbables: SortableTabbable[] = []
|
|
484
|
+
|
|
485
|
+
candidates.forEach((item, index) => {
|
|
486
|
+
const isScope = 'scopeParent' in item
|
|
487
|
+
const element = isScope ? item.scopeParent : item
|
|
488
|
+
const candidateTabIndex = getSortOrderTabIndex(element, isScope)
|
|
489
|
+
const elements = isScope ? sortByTabOrder(item.candidates) : [element]
|
|
490
|
+
|
|
491
|
+
if (candidateTabIndex === 0) {
|
|
492
|
+
regularTabbables.push(...elements)
|
|
493
|
+
} else {
|
|
494
|
+
orderedTabbables.push({
|
|
495
|
+
documentOrder: index,
|
|
496
|
+
tabIndex: candidateTabIndex,
|
|
497
|
+
item,
|
|
498
|
+
isScope,
|
|
499
|
+
content: elements,
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
return orderedTabbables
|
|
505
|
+
.sort(sortOrderedTabbables)
|
|
506
|
+
.flatMap((sortable) => sortable.content)
|
|
507
|
+
.concat(regularTabbables)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function collectCandidates(
|
|
511
|
+
container: Element,
|
|
512
|
+
options: ResolvedOptions,
|
|
513
|
+
filter: (node: Element) => boolean,
|
|
514
|
+
flatten: boolean,
|
|
515
|
+
selector: string,
|
|
516
|
+
shadowRootFilter?: (shadowHostNode: Element) => boolean,
|
|
517
|
+
): Element[] {
|
|
518
|
+
if (options.getShadowRoot) {
|
|
519
|
+
return getCandidatesIteratively(
|
|
520
|
+
[container],
|
|
521
|
+
options.includeContainer,
|
|
522
|
+
options,
|
|
523
|
+
filter,
|
|
524
|
+
flatten,
|
|
525
|
+
selector,
|
|
526
|
+
shadowRootFilter,
|
|
527
|
+
) as Element[]
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return getCandidates(container, options.includeContainer, filter, selector)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function toHTMLElementList(elements: Element[]): HTMLElement[] {
|
|
534
|
+
return elements.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* @description_zh 判断单个元素是否可被 programmatic focus(含 tabindex="-1")。
|
|
539
|
+
* @description_en Whether an element can receive programmatic focus (includes tabindex="-1").
|
|
540
|
+
*/
|
|
541
|
+
export function isFocusable(node: Element, options?: FocusableOptions): boolean {
|
|
542
|
+
const resolved = resolveOptions(options)
|
|
543
|
+
|
|
544
|
+
if (!matches(node, focusableCandidateSelector)) {
|
|
545
|
+
return false
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return isFocusableCandidate(node, resolved)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* @description_zh 判断单个元素是否可通过 Tab 键聚焦。
|
|
553
|
+
* @description_en Whether an element can be focused via the Tab key.
|
|
554
|
+
*/
|
|
555
|
+
export function isTabbable(node: Element, options?: FocusableOptions): boolean {
|
|
556
|
+
const resolved = resolveOptions(options)
|
|
557
|
+
|
|
558
|
+
if (!matches(node, candidateSelector)) {
|
|
559
|
+
return false
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return isTabbableCandidate(node, resolved)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* @description_zh 获取容器内所有可聚焦元素(含 tabindex="-1")。
|
|
567
|
+
* @description_en Returns all focusable elements within a container (includes tabindex="-1").
|
|
568
|
+
*/
|
|
569
|
+
export function getFocusableElements(
|
|
570
|
+
container: Element,
|
|
571
|
+
options?: FocusableOptions,
|
|
572
|
+
): HTMLElement[] {
|
|
573
|
+
const resolved = resolveOptions(options)
|
|
574
|
+
const candidates = collectCandidates(
|
|
575
|
+
container,
|
|
576
|
+
resolved,
|
|
577
|
+
(node) => isFocusableCandidate(node, resolved),
|
|
578
|
+
true,
|
|
579
|
+
focusableCandidateSelector,
|
|
580
|
+
)
|
|
581
|
+
return toHTMLElementList(candidates)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* @description_zh 获取容器内所有可通过 Tab 键循环聚焦的元素,按 tab 顺序排列。
|
|
586
|
+
* @description_en Returns tabbable elements within a container, sorted by tab order.
|
|
587
|
+
*/
|
|
588
|
+
export function getTabbableElements(container: Element, options?: FocusableOptions): HTMLElement[] {
|
|
589
|
+
const resolved = resolveOptions(options)
|
|
590
|
+
|
|
591
|
+
let candidates: Array<Element | CandidateScope>
|
|
592
|
+
if (resolved.getShadowRoot) {
|
|
593
|
+
candidates = getCandidatesIteratively(
|
|
594
|
+
[container],
|
|
595
|
+
resolved.includeContainer,
|
|
596
|
+
resolved,
|
|
597
|
+
(node) => isTabbableCandidate(node, resolved),
|
|
598
|
+
false,
|
|
599
|
+
candidateSelector,
|
|
600
|
+
isShadowRootTabbable,
|
|
601
|
+
)
|
|
602
|
+
} else {
|
|
603
|
+
candidates = getCandidates(container, resolved.includeContainer, (node) =>
|
|
604
|
+
isTabbableCandidate(node, resolved),
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return toHTMLElementList(sortByTabOrder(candidates))
|
|
609
|
+
}
|
package/src/utils/index.ts
CHANGED