@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,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
+ }
@@ -1,3 +1,4 @@
1
+ export * from './focusable'
1
2
  export * from './createExternalState'
2
3
  export * from './cx'
3
4
  export * from './reactUtils'