@chancestv/tv-focus 0.1.0

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,1249 @@
1
+ /*
2
+ * A javascript-based implementation of Spatial Navigation.
3
+ *
4
+ * Original work: Copyright (c) 2022 Luke Chang.
5
+ * https://github.com/luke-chang/js-spatial-navigation
6
+ *
7
+ * Modifications: fork 进 @shell/core/focus,TS 化、移除 jQuery 集成。
8
+ * 详见 ATTRIBUTION.md。
9
+ *
10
+ * Licensed under the MPL 2.0.
11
+ */
12
+ // @ts-nocheck
13
+ /* eslint-disable */
14
+
15
+ // jQuery 集成已移除;保留 $ 作为常量 null,使原代码中 `if ($)` / `$ && ...` 分支天然短路
16
+ var $: any = null;
17
+
18
+ /************************/
19
+ /* Global Configuration */
20
+ /************************/
21
+ // Note: an <extSelector> can be one of following types:
22
+ // - a valid selector string for "querySelectorAll" or jQuery (if it exists)
23
+ // - a NodeList or an array containing DOM elements
24
+ // - a single DOM element
25
+ // - a jQuery object
26
+ // - a string "@<sectionId>" to indicate the specified section
27
+ // - a string "@" to indicate the default section
28
+ var GlobalConfig = {
29
+ selector: '', // can be a valid <extSelector> except "@" syntax.
30
+ straightOnly: false,
31
+ straightOverlapThreshold: 0.5,
32
+ rememberSource: false,
33
+ disabled: false,
34
+ defaultElement: '', // <extSelector> except "@" syntax.
35
+ enterTo: '', // '', 'last-focused', 'default-element'
36
+ leaveFor: null, // {left: <extSelector>, right: <extSelector>,
37
+ // up: <extSelector>, down: <extSelector>}
38
+ restrict: 'self-first', // 'self-first', 'self-only', 'none'
39
+ tabIndexIgnoreList:
40
+ 'a, input, select, textarea, button, iframe, [contentEditable=true]',
41
+ navigableFilter: null
42
+ };
43
+
44
+ /*********************/
45
+ /* Constant Variable */
46
+ /*********************/
47
+ var KEYMAPPING = {
48
+ '37': 'left',
49
+ '38': 'up',
50
+ '39': 'right',
51
+ '40': 'down'
52
+ };
53
+
54
+ var REVERSE = {
55
+ 'left': 'right',
56
+ 'up': 'down',
57
+ 'right': 'left',
58
+ 'down': 'up'
59
+ };
60
+
61
+ var EVENT_PREFIX = 'sn:';
62
+ var ID_POOL_PREFIX = 'section-';
63
+
64
+ /********************/
65
+ /* Private Variable */
66
+ /********************/
67
+ var _idPool = 0;
68
+ var _ready = false;
69
+ var _pause = false;
70
+ var _sections = {};
71
+ var _sectionCount = 0;
72
+ var _defaultSectionId = '';
73
+ var _lastSectionId = '';
74
+ var _duringFocusChange = false;
75
+
76
+ /************/
77
+ /* Polyfill */
78
+ /************/
79
+ var elementMatchesSelector =
80
+ Element.prototype.matches ||
81
+ Element.prototype.matchesSelector ||
82
+ Element.prototype.mozMatchesSelector ||
83
+ Element.prototype.webkitMatchesSelector ||
84
+ Element.prototype.msMatchesSelector ||
85
+ Element.prototype.oMatchesSelector ||
86
+ function (selector) {
87
+ var matchedNodes =
88
+ (this.parentNode || this.document).querySelectorAll(selector);
89
+ return [].slice.call(matchedNodes).indexOf(this) >= 0;
90
+ };
91
+
92
+ /*****************/
93
+ /* Core Function */
94
+ /*****************/
95
+ function getRect(elem) {
96
+ var cr = elem.getBoundingClientRect();
97
+ var rect = {
98
+ left: cr.left,
99
+ top: cr.top,
100
+ right: cr.right,
101
+ bottom: cr.bottom,
102
+ width: cr.width,
103
+ height: cr.height
104
+ };
105
+ rect.element = elem;
106
+ rect.center = {
107
+ x: rect.left + Math.floor(rect.width / 2),
108
+ y: rect.top + Math.floor(rect.height / 2)
109
+ };
110
+ rect.center.left = rect.center.right = rect.center.x;
111
+ rect.center.top = rect.center.bottom = rect.center.y;
112
+ return rect;
113
+ }
114
+
115
+ function partition(rects, targetRect, straightOverlapThreshold) {
116
+ var groups = [[], [], [], [], [], [], [], [], []];
117
+
118
+ for (var i = 0; i < rects.length; i++) {
119
+ var rect = rects[i];
120
+ var center = rect.center;
121
+ var x, y, groupId;
122
+
123
+ if (center.x < targetRect.left) {
124
+ x = 0;
125
+ } else if (center.x <= targetRect.right) {
126
+ x = 1;
127
+ } else {
128
+ x = 2;
129
+ }
130
+
131
+ if (center.y < targetRect.top) {
132
+ y = 0;
133
+ } else if (center.y <= targetRect.bottom) {
134
+ y = 1;
135
+ } else {
136
+ y = 2;
137
+ }
138
+
139
+ groupId = y * 3 + x;
140
+ groups[groupId].push(rect);
141
+
142
+ if ([0, 2, 6, 8].indexOf(groupId) !== -1) {
143
+ var threshold = straightOverlapThreshold;
144
+
145
+ if (rect.left <= targetRect.right - targetRect.width * threshold) {
146
+ if (groupId === 2) {
147
+ groups[1].push(rect);
148
+ } else if (groupId === 8) {
149
+ groups[7].push(rect);
150
+ }
151
+ }
152
+
153
+ if (rect.right >= targetRect.left + targetRect.width * threshold) {
154
+ if (groupId === 0) {
155
+ groups[1].push(rect);
156
+ } else if (groupId === 6) {
157
+ groups[7].push(rect);
158
+ }
159
+ }
160
+
161
+ if (rect.top <= targetRect.bottom - targetRect.height * threshold) {
162
+ if (groupId === 6) {
163
+ groups[3].push(rect);
164
+ } else if (groupId === 8) {
165
+ groups[5].push(rect);
166
+ }
167
+ }
168
+
169
+ if (rect.bottom >= targetRect.top + targetRect.height * threshold) {
170
+ if (groupId === 0) {
171
+ groups[3].push(rect);
172
+ } else if (groupId === 2) {
173
+ groups[5].push(rect);
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ return groups;
180
+ }
181
+
182
+ function generateDistanceFunction(targetRect) {
183
+ return {
184
+ nearPlumbLineIsBetter: function(rect) {
185
+ var d;
186
+ if (rect.center.x < targetRect.center.x) {
187
+ d = targetRect.center.x - rect.right;
188
+ } else {
189
+ d = rect.left - targetRect.center.x;
190
+ }
191
+ return d < 0 ? 0 : d;
192
+ },
193
+ nearHorizonIsBetter: function(rect) {
194
+ var d;
195
+ if (rect.center.y < targetRect.center.y) {
196
+ d = targetRect.center.y - rect.bottom;
197
+ } else {
198
+ d = rect.top - targetRect.center.y;
199
+ }
200
+ return d < 0 ? 0 : d;
201
+ },
202
+ nearTargetLeftIsBetter: function(rect) {
203
+ var d;
204
+ if (rect.center.x < targetRect.center.x) {
205
+ d = targetRect.left - rect.right;
206
+ } else {
207
+ d = rect.left - targetRect.left;
208
+ }
209
+ return d < 0 ? 0 : d;
210
+ },
211
+ nearTargetTopIsBetter: function(rect) {
212
+ var d;
213
+ if (rect.center.y < targetRect.center.y) {
214
+ d = targetRect.top - rect.bottom;
215
+ } else {
216
+ d = rect.top - targetRect.top;
217
+ }
218
+ return d < 0 ? 0 : d;
219
+ },
220
+ topIsBetter: function(rect) {
221
+ return rect.top;
222
+ },
223
+ bottomIsBetter: function(rect) {
224
+ return -1 * rect.bottom;
225
+ },
226
+ leftIsBetter: function(rect) {
227
+ return rect.left;
228
+ },
229
+ rightIsBetter: function(rect) {
230
+ return -1 * rect.right;
231
+ }
232
+ };
233
+ }
234
+
235
+ function prioritize(priorities) {
236
+ var destPriority = null;
237
+ for (var i = 0; i < priorities.length; i++) {
238
+ if (priorities[i].group.length) {
239
+ destPriority = priorities[i];
240
+ break;
241
+ }
242
+ }
243
+
244
+ if (!destPriority) {
245
+ return null;
246
+ }
247
+
248
+ var destDistance = destPriority.distance;
249
+
250
+ destPriority.group.sort(function(a, b) {
251
+ for (var i = 0; i < destDistance.length; i++) {
252
+ var distance = destDistance[i];
253
+ var delta = distance(a) - distance(b);
254
+ if (delta) {
255
+ return delta;
256
+ }
257
+ }
258
+ return 0;
259
+ });
260
+
261
+ return destPriority.group;
262
+ }
263
+
264
+ // preferNearest=true:跨 section 查找,直线/斜向合并按距离竞争(就近原则)。
265
+ // preferNearest=false(默认):section 内查找,保留上游分层(直线强于斜向)。
266
+ function navigate(target, direction, candidates, config, preferNearest) {
267
+ if (!target || !direction || !candidates || !candidates.length) {
268
+ return null;
269
+ }
270
+
271
+ var rects = [];
272
+ for (var i = 0; i < candidates.length; i++) {
273
+ var rect = getRect(candidates[i]);
274
+ if (rect) {
275
+ rects.push(rect);
276
+ }
277
+ }
278
+ if (!rects.length) {
279
+ return null;
280
+ }
281
+
282
+ var targetRect = getRect(target);
283
+ if (!targetRect) {
284
+ return null;
285
+ }
286
+
287
+ var distanceFunction = generateDistanceFunction(targetRect);
288
+
289
+ var groups = partition(
290
+ rects,
291
+ targetRect,
292
+ config.straightOverlapThreshold
293
+ );
294
+
295
+ var internalGroups = partition(
296
+ groups[4],
297
+ targetRect.center,
298
+ config.straightOverlapThreshold
299
+ );
300
+
301
+ var priorities;
302
+ var df = distanceFunction;
303
+ var internalGroup, straightGroup, diagonalGroups;
304
+ var internalDist, straightDist, diagonalDist, mergedDist;
305
+
306
+ switch (direction) {
307
+ case 'left':
308
+ internalGroup = internalGroups[0].concat(internalGroups[3])
309
+ .concat(internalGroups[6]);
310
+ straightGroup = groups[3];
311
+ diagonalGroups = groups[0].concat(groups[6]);
312
+ internalDist = [df.nearPlumbLineIsBetter, df.topIsBetter];
313
+ straightDist = [df.nearPlumbLineIsBetter, df.topIsBetter];
314
+ diagonalDist = [df.nearHorizonIsBetter, df.rightIsBetter,
315
+ df.nearTargetTopIsBetter];
316
+ // 合并时主轴=水平最近,次轴=垂直对齐
317
+ mergedDist = [df.nearPlumbLineIsBetter, df.nearHorizonIsBetter,
318
+ df.topIsBetter];
319
+ break;
320
+ case 'right':
321
+ internalGroup = internalGroups[2].concat(internalGroups[5])
322
+ .concat(internalGroups[8]);
323
+ straightGroup = groups[5];
324
+ diagonalGroups = groups[2].concat(groups[8]);
325
+ internalDist = [df.nearPlumbLineIsBetter, df.topIsBetter];
326
+ straightDist = [df.nearPlumbLineIsBetter, df.topIsBetter];
327
+ diagonalDist = [df.nearHorizonIsBetter, df.leftIsBetter,
328
+ df.nearTargetTopIsBetter];
329
+ mergedDist = [df.nearPlumbLineIsBetter, df.nearHorizonIsBetter,
330
+ df.topIsBetter];
331
+ break;
332
+ case 'up':
333
+ internalGroup = internalGroups[0].concat(internalGroups[1])
334
+ .concat(internalGroups[2]);
335
+ straightGroup = groups[1];
336
+ diagonalGroups = groups[0].concat(groups[2]);
337
+ internalDist = [df.nearHorizonIsBetter, df.leftIsBetter];
338
+ straightDist = [df.nearHorizonIsBetter, df.leftIsBetter];
339
+ diagonalDist = [df.nearPlumbLineIsBetter, df.bottomIsBetter,
340
+ df.nearTargetLeftIsBetter];
341
+ // 合并时主轴=垂直最近,次轴=水平对齐
342
+ mergedDist = [df.nearHorizonIsBetter, df.nearPlumbLineIsBetter,
343
+ df.leftIsBetter];
344
+ break;
345
+ case 'down':
346
+ internalGroup = internalGroups[6].concat(internalGroups[7])
347
+ .concat(internalGroups[8]);
348
+ straightGroup = groups[7];
349
+ diagonalGroups = groups[6].concat(groups[8]);
350
+ internalDist = [df.nearHorizonIsBetter, df.leftIsBetter];
351
+ straightDist = [df.nearHorizonIsBetter, df.leftIsBetter];
352
+ diagonalDist = [df.nearPlumbLineIsBetter, df.topIsBetter,
353
+ df.nearTargetLeftIsBetter];
354
+ mergedDist = [df.nearHorizonIsBetter, df.nearPlumbLineIsBetter,
355
+ df.leftIsBetter];
356
+ break;
357
+ default:
358
+ return null;
359
+ }
360
+
361
+ if (preferNearest) {
362
+ // 跨 section:直线与斜向候选合并为一层按距离竞争,最近一行/一列胜出,
363
+ // 修复「远处同列元素抢焦点」(如按下从按钮行越过相邻行跳到远处对齐元素)。
364
+ priorities = [
365
+ { group: internalGroup, distance: internalDist },
366
+ {
367
+ group: config.straightOnly
368
+ ? straightGroup
369
+ : straightGroup.concat(diagonalGroups),
370
+ distance: mergedDist
371
+ }
372
+ ];
373
+ } else {
374
+ // section 内:保留上游分层(直线候选强于斜向),保证同行/同列相邻项优先,
375
+ // 不被「不同行/不同列但像素更近」的元素抢走(如变宽卡片墙里同行相邻卡)。
376
+ priorities = [
377
+ { group: internalGroup, distance: internalDist },
378
+ { group: straightGroup, distance: straightDist },
379
+ { group: diagonalGroups, distance: diagonalDist }
380
+ ];
381
+ if (config.straightOnly) {
382
+ priorities.pop();
383
+ }
384
+ }
385
+
386
+ var destGroup = prioritize(priorities);
387
+ if (!destGroup) {
388
+ return null;
389
+ }
390
+
391
+ var dest = null;
392
+ if (config.rememberSource &&
393
+ config.previous &&
394
+ config.previous.destination === target &&
395
+ config.previous.reverse === direction) {
396
+ for (var j = 0; j < destGroup.length; j++) {
397
+ if (destGroup[j].element === config.previous.target) {
398
+ dest = destGroup[j].element;
399
+ break;
400
+ }
401
+ }
402
+ }
403
+
404
+ if (!dest) {
405
+ dest = destGroup[0].element;
406
+ }
407
+
408
+ return dest;
409
+ }
410
+
411
+ /********************/
412
+ /* Private Function */
413
+ /********************/
414
+ function generateId() {
415
+ var id;
416
+ while(true) {
417
+ id = ID_POOL_PREFIX + String(++_idPool);
418
+ if (!_sections[id]) {
419
+ break;
420
+ }
421
+ }
422
+ return id;
423
+ }
424
+
425
+ function parseSelector(selector) {
426
+ var result = [];
427
+ try {
428
+ if (selector) {
429
+ if ($) {
430
+ result = $(selector).get();
431
+ } else if (typeof selector === 'string') {
432
+ result = [].slice.call(document.querySelectorAll(selector));
433
+ } else if (typeof selector === 'object' && selector.length) {
434
+ result = [].slice.call(selector);
435
+ } else if (typeof selector === 'object' && selector.nodeType === 1) {
436
+ result = [selector];
437
+ }
438
+ }
439
+ } catch (err) {
440
+ console.error(err);
441
+ }
442
+ return result;
443
+ }
444
+
445
+ function matchSelector(elem, selector) {
446
+ if ($) {
447
+ return $(elem).is(selector);
448
+ } else if (typeof selector === 'string') {
449
+ return elementMatchesSelector.call(elem, selector);
450
+ } else if (typeof selector === 'object' && selector.length) {
451
+ return selector.indexOf(elem) >= 0;
452
+ } else if (typeof selector === 'object' && selector.nodeType === 1) {
453
+ return elem === selector;
454
+ }
455
+ return false;
456
+ }
457
+
458
+ function getCurrentFocusedElement() {
459
+ var activeElement = document.activeElement;
460
+ if (activeElement && activeElement !== document.body) {
461
+ return activeElement;
462
+ }
463
+ }
464
+
465
+ function extend(out) {
466
+ out = out || {};
467
+ for (var i = 1; i < arguments.length; i++) {
468
+ if (!arguments[i]) {
469
+ continue;
470
+ }
471
+ for (var key in arguments[i]) {
472
+ if (arguments[i].hasOwnProperty(key) &&
473
+ arguments[i][key] !== undefined) {
474
+ out[key] = arguments[i][key];
475
+ }
476
+ }
477
+ }
478
+ return out;
479
+ }
480
+
481
+ function exclude(elemList, excludedElem) {
482
+ if (!Array.isArray(excludedElem)) {
483
+ excludedElem = [excludedElem];
484
+ }
485
+ for (var i = 0, index; i < excludedElem.length; i++) {
486
+ index = elemList.indexOf(excludedElem[i]);
487
+ if (index >= 0) {
488
+ elemList.splice(index, 1);
489
+ }
490
+ }
491
+ return elemList;
492
+ }
493
+
494
+ function isNavigable(elem, sectionId, verifySectionSelector) {
495
+ if (! elem || !sectionId ||
496
+ !_sections[sectionId] || _sections[sectionId].disabled) {
497
+ return false;
498
+ }
499
+ if ((elem.offsetWidth <= 0 && elem.offsetHeight <= 0) ||
500
+ elem.hasAttribute('disabled')) {
501
+ return false;
502
+ }
503
+ if (verifySectionSelector &&
504
+ !matchSelector(elem, _sections[sectionId].selector)) {
505
+ return false;
506
+ }
507
+ if (typeof _sections[sectionId].navigableFilter === 'function') {
508
+ if (_sections[sectionId].navigableFilter(elem, sectionId) === false) {
509
+ return false;
510
+ }
511
+ } else if (typeof GlobalConfig.navigableFilter === 'function') {
512
+ if (GlobalConfig.navigableFilter(elem, sectionId) === false) {
513
+ return false;
514
+ }
515
+ }
516
+ return true;
517
+ }
518
+
519
+ function getSectionId(elem) {
520
+ for (var id in _sections) {
521
+ if (!_sections[id].disabled &&
522
+ matchSelector(elem, _sections[id].selector)) {
523
+ return id;
524
+ }
525
+ }
526
+ }
527
+
528
+ function getSectionNavigableElements(sectionId) {
529
+ return parseSelector(_sections[sectionId].selector).filter(function(elem) {
530
+ return isNavigable(elem, sectionId);
531
+ });
532
+ }
533
+
534
+ function getSectionDefaultElement(sectionId) {
535
+ var defaultElement = parseSelector(_sections[sectionId].defaultElement).find(function(elem) {
536
+ return isNavigable(elem, sectionId, true);
537
+ });
538
+ if (!defaultElement) {
539
+ return null;
540
+ }
541
+ return defaultElement;
542
+ }
543
+
544
+ function getSectionLastFocusedElement(sectionId) {
545
+ var lastFocusedElement = _sections[sectionId].lastFocusedElement;
546
+ if (!isNavigable(lastFocusedElement, sectionId, true)) {
547
+ return null;
548
+ }
549
+ return lastFocusedElement;
550
+ }
551
+
552
+ function fireEvent(elem, type, details, cancelable) {
553
+ if (arguments.length < 4) {
554
+ cancelable = true;
555
+ }
556
+ var evt = document.createEvent('CustomEvent');
557
+ evt.initCustomEvent(EVENT_PREFIX + type, true, cancelable, details);
558
+ return elem.dispatchEvent(evt);
559
+ }
560
+
561
+ function focusElement(elem, sectionId, direction) {
562
+ if (!elem) {
563
+ return false;
564
+ }
565
+
566
+ var currentFocusedElement = getCurrentFocusedElement();
567
+
568
+ var silentFocus = function() {
569
+ if (currentFocusedElement) {
570
+ currentFocusedElement.blur();
571
+ }
572
+ elem.focus();
573
+ focusChanged(elem, sectionId);
574
+ };
575
+
576
+ if (_duringFocusChange) {
577
+ silentFocus();
578
+ return true;
579
+ }
580
+
581
+ _duringFocusChange = true;
582
+
583
+ if (_pause) {
584
+ silentFocus();
585
+ _duringFocusChange = false;
586
+ return true;
587
+ }
588
+
589
+ if (currentFocusedElement) {
590
+ var unfocusProperties = {
591
+ nextElement: elem,
592
+ nextSectionId: sectionId,
593
+ direction: direction,
594
+ native: false
595
+ };
596
+ if (!fireEvent(currentFocusedElement, 'willunfocus', unfocusProperties)) {
597
+ _duringFocusChange = false;
598
+ return false;
599
+ }
600
+ currentFocusedElement.blur();
601
+ fireEvent(currentFocusedElement, 'unfocused', unfocusProperties, false);
602
+ }
603
+
604
+ var focusProperties = {
605
+ previousElement: currentFocusedElement,
606
+ sectionId: sectionId,
607
+ direction: direction,
608
+ native: false
609
+ };
610
+ if (!fireEvent(elem, 'willfocus', focusProperties)) {
611
+ _duringFocusChange = false;
612
+ return false;
613
+ }
614
+ elem.focus();
615
+ fireEvent(elem, 'focused', focusProperties, false);
616
+
617
+ _duringFocusChange = false;
618
+
619
+ focusChanged(elem, sectionId);
620
+ return true;
621
+ }
622
+
623
+ function focusChanged(elem, sectionId) {
624
+ if (!sectionId) {
625
+ sectionId = getSectionId(elem);
626
+ }
627
+ if (sectionId) {
628
+ _sections[sectionId].lastFocusedElement = elem;
629
+ _lastSectionId = sectionId;
630
+ }
631
+ }
632
+
633
+ function focusExtendedSelector(selector, direction) {
634
+ if (selector.charAt(0) == '@') {
635
+ if (selector.length == 1) {
636
+ return focusSection();
637
+ } else {
638
+ var sectionId = selector.substr(1);
639
+ return focusSection(sectionId);
640
+ }
641
+ } else {
642
+ var next = parseSelector(selector)[0];
643
+ if (next) {
644
+ var nextSectionId = getSectionId(next);
645
+ if (isNavigable(next, nextSectionId)) {
646
+ return focusElement(next, nextSectionId, direction);
647
+ }
648
+ }
649
+ }
650
+ return false;
651
+ }
652
+
653
+ function focusSection(sectionId) {
654
+ var range = [];
655
+ var addRange = function(id) {
656
+ if (id && range.indexOf(id) < 0 &&
657
+ _sections[id] && !_sections[id].disabled) {
658
+ range.push(id);
659
+ }
660
+ };
661
+
662
+ if (sectionId) {
663
+ addRange(sectionId);
664
+ } else {
665
+ addRange(_defaultSectionId);
666
+ addRange(_lastSectionId);
667
+ Object.keys(_sections).map(addRange);
668
+ }
669
+
670
+ for (var i = 0; i < range.length; i++) {
671
+ var id = range[i];
672
+ var next;
673
+
674
+ if (_sections[id].enterTo == 'last-focused') {
675
+ next = getSectionLastFocusedElement(id) ||
676
+ getSectionDefaultElement(id) ||
677
+ getSectionNavigableElements(id)[0];
678
+ } else {
679
+ next = getSectionDefaultElement(id) ||
680
+ getSectionLastFocusedElement(id) ||
681
+ getSectionNavigableElements(id)[0];
682
+ }
683
+
684
+ if (next) {
685
+ return focusElement(next, id);
686
+ }
687
+ }
688
+
689
+ return false;
690
+ }
691
+
692
+ function fireNavigatefailed(elem, direction) {
693
+ fireEvent(elem, 'navigatefailed', {
694
+ direction: direction
695
+ }, false);
696
+ }
697
+
698
+ function gotoLeaveFor(sectionId, direction) {
699
+ if (_sections[sectionId].leaveFor &&
700
+ _sections[sectionId].leaveFor[direction] !== undefined) {
701
+ var next = _sections[sectionId].leaveFor[direction];
702
+
703
+ if (typeof next === 'string') {
704
+ if (next === '') {
705
+ return null;
706
+ }
707
+ return focusExtendedSelector(next, direction);
708
+ }
709
+
710
+ if ($ && next instanceof $) {
711
+ next = next.get(0);
712
+ }
713
+
714
+ var nextSectionId = getSectionId(next);
715
+ if (isNavigable(next, nextSectionId)) {
716
+ return focusElement(next, nextSectionId, direction);
717
+ }
718
+ }
719
+ return false;
720
+ }
721
+
722
+ // 元素 → 其最近「滚动/裁剪祖先」的缓存。容器关系在元素生命周期内稳定,
723
+ // 低端盒子上每次按键都 walk + getComputedStyle 太贵,故 WeakMap 缓存(元素销毁自动回收)。
724
+ var _scrollScopeCache = new WeakMap();
725
+
726
+ // 从元素向上找第一个 overflow 非 visible 的祖先(auto/scroll/hidden/clip)。
727
+ // 判断的是祖先而非元素/section 本身:5 个不可滚的子 section 会收敛到共同的可滚父/爷;
728
+ // 容器外的固定页头(如返回)则落到别的祖先。无裁剪祖先时返回 null(全页同一平面)。
729
+ function getScrollScope(elem) {
730
+ if (_scrollScopeCache.has(elem)) {
731
+ return _scrollScopeCache.get(elem);
732
+ }
733
+ var scope = null;
734
+ var node = elem.parentElement;
735
+ while (node && node !== document.documentElement) {
736
+ var style = window.getComputedStyle(node);
737
+ var ox = style.overflowX;
738
+ var oy = style.overflowY;
739
+ if (ox === 'auto' || ox === 'scroll' || ox === 'hidden' || ox === 'clip' ||
740
+ oy === 'auto' || oy === 'scroll' || oy === 'hidden' || oy === 'clip') {
741
+ scope = node;
742
+ break;
743
+ }
744
+ node = node.parentElement;
745
+ }
746
+ _scrollScopeCache.set(elem, scope);
747
+ return scope;
748
+ }
749
+
750
+ // 就近原则的「层级」延伸:跨 section 查找时,先只在与当前焦点同一滚动/裁剪容器内找
751
+ // (哪怕候选已滚出视口),同容器该方向确实没有候选时,才允许跳到容器外的元素(如固定页头)。
752
+ function navigateWithinScrollScope(target, direction, candidates, config, preferNearest) {
753
+ if (!candidates || candidates.length < 2) {
754
+ return navigate(target, direction, candidates, config, preferNearest);
755
+ }
756
+ var targetScope = getScrollScope(target);
757
+ var inScope = [];
758
+ var outScope = [];
759
+ for (var i = 0; i < candidates.length; i++) {
760
+ if (getScrollScope(candidates[i]) === targetScope) {
761
+ inScope.push(candidates[i]);
762
+ } else {
763
+ outScope.push(candidates[i]);
764
+ }
765
+ }
766
+ if (inScope.length && outScope.length) {
767
+ return navigate(target, direction, inScope, config, preferNearest) ||
768
+ navigate(target, direction, outScope, config, preferNearest);
769
+ }
770
+ return navigate(target, direction, candidates, config, preferNearest);
771
+ }
772
+
773
+ function focusNext(direction, currentFocusedElement, currentSectionId) {
774
+ var extSelector =
775
+ currentFocusedElement.getAttribute('data-sn-' + direction);
776
+ if (typeof extSelector === 'string') {
777
+ if (extSelector === '' ||
778
+ !focusExtendedSelector(extSelector, direction)) {
779
+ fireNavigatefailed(currentFocusedElement, direction);
780
+ return false;
781
+ }
782
+ return true;
783
+ }
784
+
785
+ var sectionNavigableElements = {};
786
+ var allNavigableElements = [];
787
+ for (var id in _sections) {
788
+ sectionNavigableElements[id] = getSectionNavigableElements(id);
789
+ allNavigableElements =
790
+ allNavigableElements.concat(sectionNavigableElements[id]);
791
+ }
792
+
793
+ var config = extend({}, GlobalConfig, _sections[currentSectionId]);
794
+ var next;
795
+
796
+ if (config.restrict == 'self-only' || config.restrict == 'self-first') {
797
+ var currentSectionNavigableElements =
798
+ sectionNavigableElements[currentSectionId];
799
+
800
+ next = navigate(
801
+ currentFocusedElement,
802
+ direction,
803
+ exclude(currentSectionNavigableElements, currentFocusedElement),
804
+ config
805
+ );
806
+
807
+ if (!next && config.restrict == 'self-first') {
808
+ // 离开本 section 跨区查找:就近原则 + 滚动容器感知
809
+ next = navigateWithinScrollScope(
810
+ currentFocusedElement,
811
+ direction,
812
+ exclude(allNavigableElements, currentSectionNavigableElements),
813
+ config,
814
+ true
815
+ );
816
+ }
817
+ } else {
818
+ // restrict:'none':全局单平面,保留上游分层(直线优先),仅叠加滚动容器感知
819
+ next = navigateWithinScrollScope(
820
+ currentFocusedElement,
821
+ direction,
822
+ exclude(allNavigableElements, currentFocusedElement),
823
+ config,
824
+ false
825
+ );
826
+ }
827
+
828
+ if (next) {
829
+ _sections[currentSectionId].previous = {
830
+ target: currentFocusedElement,
831
+ destination: next,
832
+ reverse: REVERSE[direction]
833
+ };
834
+
835
+ var nextSectionId = getSectionId(next);
836
+
837
+ if (currentSectionId != nextSectionId) {
838
+ var result = gotoLeaveFor(currentSectionId, direction);
839
+ if (result) {
840
+ return true;
841
+ } else if (result === null) {
842
+ fireNavigatefailed(currentFocusedElement, direction);
843
+ return false;
844
+ }
845
+
846
+ var enterToElement;
847
+ switch (_sections[nextSectionId].enterTo) {
848
+ case 'last-focused':
849
+ enterToElement = getSectionLastFocusedElement(nextSectionId) ||
850
+ getSectionDefaultElement(nextSectionId);
851
+ break;
852
+ case 'default-element':
853
+ enterToElement = getSectionDefaultElement(nextSectionId);
854
+ break;
855
+ }
856
+ if (enterToElement) {
857
+ next = enterToElement;
858
+ }
859
+ }
860
+
861
+ return focusElement(next, nextSectionId, direction);
862
+ } else if (gotoLeaveFor(currentSectionId, direction)) {
863
+ return true;
864
+ }
865
+
866
+ fireNavigatefailed(currentFocusedElement, direction);
867
+ return false;
868
+ }
869
+
870
+ function onKeyDown(evt) {
871
+ if (!_sectionCount || _pause ||
872
+ evt.altKey || evt.ctrlKey || evt.metaKey || evt.shiftKey) {
873
+ return;
874
+ }
875
+
876
+ var currentFocusedElement;
877
+ var preventDefault = function() {
878
+ evt.preventDefault();
879
+ evt.stopPropagation();
880
+ return false;
881
+ };
882
+
883
+ var direction = KEYMAPPING[evt.keyCode];
884
+ if (!direction) {
885
+ if (evt.keyCode == 13) {
886
+ currentFocusedElement = getCurrentFocusedElement();
887
+ if (currentFocusedElement && getSectionId(currentFocusedElement)) {
888
+ if (!fireEvent(currentFocusedElement, 'enter-down')) {
889
+ return preventDefault();
890
+ }
891
+ }
892
+ }
893
+ return;
894
+ }
895
+
896
+ currentFocusedElement = getCurrentFocusedElement();
897
+
898
+ if (!currentFocusedElement) {
899
+ if (_lastSectionId) {
900
+ currentFocusedElement = getSectionLastFocusedElement(_lastSectionId);
901
+ }
902
+ if (!currentFocusedElement) {
903
+ focusSection();
904
+ return preventDefault();
905
+ }
906
+ }
907
+
908
+ var currentSectionId = getSectionId(currentFocusedElement);
909
+ if (!currentSectionId) {
910
+ return;
911
+ }
912
+
913
+ var willmoveProperties = {
914
+ direction: direction,
915
+ sectionId: currentSectionId,
916
+ cause: 'keydown'
917
+ };
918
+
919
+ if (fireEvent(currentFocusedElement, 'willmove', willmoveProperties)) {
920
+ focusNext(direction, currentFocusedElement, currentSectionId);
921
+ }
922
+
923
+ return preventDefault();
924
+ }
925
+
926
+ function onKeyUp(evt) {
927
+ if (evt.altKey || evt.ctrlKey || evt.metaKey || evt.shiftKey) {
928
+ return;
929
+ }
930
+ if (!_pause && _sectionCount && evt.keyCode == 13) {
931
+ var currentFocusedElement = getCurrentFocusedElement();
932
+ if (currentFocusedElement && getSectionId(currentFocusedElement)) {
933
+ if (!fireEvent(currentFocusedElement, 'enter-up')) {
934
+ evt.preventDefault();
935
+ evt.stopPropagation();
936
+ }
937
+ }
938
+ }
939
+ }
940
+
941
+ function onFocus(evt) {
942
+ var target = evt.target;
943
+ if (target !== window && target !== document &&
944
+ _sectionCount && !_duringFocusChange) {
945
+ var sectionId = getSectionId(target);
946
+ if (sectionId) {
947
+ if (_pause) {
948
+ focusChanged(target, sectionId);
949
+ return;
950
+ }
951
+
952
+ var focusProperties = {
953
+ sectionId: sectionId,
954
+ native: true
955
+ };
956
+
957
+ if (!fireEvent(target, 'willfocus', focusProperties)) {
958
+ _duringFocusChange = true;
959
+ target.blur();
960
+ _duringFocusChange = false;
961
+ } else {
962
+ fireEvent(target, 'focused', focusProperties, false);
963
+ focusChanged(target, sectionId);
964
+ }
965
+ }
966
+ }
967
+ }
968
+
969
+ function onBlur(evt) {
970
+ var target = evt.target;
971
+ if (target !== window && target !== document && !_pause &&
972
+ _sectionCount && !_duringFocusChange && getSectionId(target)) {
973
+ var unfocusProperties = {
974
+ native: true
975
+ };
976
+ if (!fireEvent(target, 'willunfocus', unfocusProperties)) {
977
+ _duringFocusChange = true;
978
+ setTimeout(function() {
979
+ target.focus();
980
+ _duringFocusChange = false;
981
+ });
982
+ } else {
983
+ fireEvent(target, 'unfocused', unfocusProperties, false);
984
+ }
985
+ }
986
+ }
987
+
988
+ /*******************/
989
+ /* Public Function */
990
+ /*******************/
991
+ var SpatialNavigation = {
992
+ init: function() {
993
+ if (!_ready) {
994
+ window.addEventListener('keydown', onKeyDown);
995
+ window.addEventListener('keyup', onKeyUp);
996
+ window.addEventListener('focus', onFocus, true);
997
+ window.addEventListener('blur', onBlur, true);
998
+ _ready = true;
999
+ }
1000
+ },
1001
+
1002
+ uninit: function() {
1003
+ window.removeEventListener('blur', onBlur, true);
1004
+ window.removeEventListener('focus', onFocus, true);
1005
+ window.removeEventListener('keyup', onKeyUp);
1006
+ window.removeEventListener('keydown', onKeyDown);
1007
+ SpatialNavigation.clear();
1008
+ _idPool = 0;
1009
+ _ready = false;
1010
+ },
1011
+
1012
+ clear: function() {
1013
+ _sections = {};
1014
+ _sectionCount = 0;
1015
+ _defaultSectionId = '';
1016
+ _lastSectionId = '';
1017
+ _duringFocusChange = false;
1018
+ },
1019
+
1020
+ // set(<config>);
1021
+ // set(<sectionId>, <config>);
1022
+ set: function() {
1023
+ var sectionId, config;
1024
+
1025
+ if (typeof arguments[0] === 'object') {
1026
+ config = arguments[0];
1027
+ } else if (typeof arguments[0] === 'string' &&
1028
+ typeof arguments[1] === 'object') {
1029
+ sectionId = arguments[0];
1030
+ config = arguments[1];
1031
+ if (!_sections[sectionId]) {
1032
+ throw new Error('Section "' + sectionId + '" doesn\'t exist!');
1033
+ }
1034
+ } else {
1035
+ return;
1036
+ }
1037
+
1038
+ for (var key in config) {
1039
+ if (GlobalConfig[key] !== undefined) {
1040
+ if (sectionId) {
1041
+ _sections[sectionId][key] = config[key];
1042
+ } else if (config[key] !== undefined) {
1043
+ GlobalConfig[key] = config[key];
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ if (sectionId) {
1049
+ // remove "undefined" items
1050
+ _sections[sectionId] = extend({}, _sections[sectionId]);
1051
+ }
1052
+ },
1053
+
1054
+ // add(<config>);
1055
+ // add(<sectionId>, <config>);
1056
+ add: function() {
1057
+ var sectionId;
1058
+ var config = {};
1059
+
1060
+ if (typeof arguments[0] === 'object') {
1061
+ config = arguments[0];
1062
+ } else if (typeof arguments[0] === 'string' &&
1063
+ typeof arguments[1] === 'object') {
1064
+ sectionId = arguments[0];
1065
+ config = arguments[1];
1066
+ }
1067
+
1068
+ if (!sectionId) {
1069
+ sectionId = (typeof config.id === 'string') ? config.id : generateId();
1070
+ }
1071
+
1072
+ if (_sections[sectionId]) {
1073
+ throw new Error('Section "' + sectionId + '" has already existed!');
1074
+ }
1075
+
1076
+ _sections[sectionId] = {};
1077
+ _sectionCount++;
1078
+
1079
+ SpatialNavigation.set(sectionId, config);
1080
+
1081
+ return sectionId;
1082
+ },
1083
+
1084
+ remove: function(sectionId) {
1085
+ if (!sectionId || typeof sectionId !== 'string') {
1086
+ throw new Error('Please assign the "sectionId"!');
1087
+ }
1088
+ if (_sections[sectionId]) {
1089
+ _sections[sectionId] = undefined;
1090
+ _sections = extend({}, _sections);
1091
+ _sectionCount--;
1092
+ if (_lastSectionId === sectionId) {
1093
+ _lastSectionId = '';
1094
+ }
1095
+ return true;
1096
+ }
1097
+ return false;
1098
+ },
1099
+
1100
+ disable: function(sectionId) {
1101
+ if (_sections[sectionId]) {
1102
+ _sections[sectionId].disabled = true;
1103
+ return true;
1104
+ }
1105
+ return false;
1106
+ },
1107
+
1108
+ enable: function(sectionId) {
1109
+ if (_sections[sectionId]) {
1110
+ _sections[sectionId].disabled = false;
1111
+ return true;
1112
+ }
1113
+ return false;
1114
+ },
1115
+
1116
+ pause: function() {
1117
+ _pause = true;
1118
+ },
1119
+
1120
+ resume: function() {
1121
+ _pause = false;
1122
+ },
1123
+
1124
+ // focus([silent])
1125
+ // focus(<sectionId>, [silent])
1126
+ // focus(<extSelector>, [silent])
1127
+ // Note: "silent" is optional and default to false
1128
+ focus: function(elem, silent) {
1129
+ var result = false;
1130
+
1131
+ if (silent === undefined && typeof elem === 'boolean') {
1132
+ silent = elem;
1133
+ elem = undefined;
1134
+ }
1135
+
1136
+ var autoPause = !_pause && silent;
1137
+
1138
+ if (autoPause) {
1139
+ SpatialNavigation.pause();
1140
+ }
1141
+
1142
+ if (!elem) {
1143
+ result = focusSection();
1144
+ } else {
1145
+ if (typeof elem === 'string') {
1146
+ if (_sections[elem]) {
1147
+ result = focusSection(elem);
1148
+ } else {
1149
+ result = focusExtendedSelector(elem);
1150
+ }
1151
+ } else {
1152
+ if ($ && elem instanceof $) {
1153
+ elem = elem.get(0);
1154
+ }
1155
+
1156
+ var nextSectionId = getSectionId(elem);
1157
+ if (isNavigable(elem, nextSectionId)) {
1158
+ result = focusElement(elem, nextSectionId);
1159
+ }
1160
+ }
1161
+ }
1162
+
1163
+ if (autoPause) {
1164
+ SpatialNavigation.resume();
1165
+ }
1166
+
1167
+ return result;
1168
+ },
1169
+
1170
+ // move(<direction>)
1171
+ // move(<direction>, <selector>)
1172
+ move: function(direction, selector) {
1173
+ direction = direction.toLowerCase();
1174
+ if (!REVERSE[direction]) {
1175
+ return false;
1176
+ }
1177
+
1178
+ var elem = selector ?
1179
+ parseSelector(selector)[0] : getCurrentFocusedElement();
1180
+ if (!elem) {
1181
+ return false;
1182
+ }
1183
+
1184
+ var sectionId = getSectionId(elem);
1185
+ if (!sectionId) {
1186
+ return false;
1187
+ }
1188
+
1189
+ var willmoveProperties = {
1190
+ direction: direction,
1191
+ sectionId: sectionId,
1192
+ cause: 'api'
1193
+ };
1194
+
1195
+ if (!fireEvent(elem, 'willmove', willmoveProperties)) {
1196
+ return false;
1197
+ }
1198
+
1199
+ return focusNext(direction, elem, sectionId);
1200
+ },
1201
+
1202
+ // makeFocusable()
1203
+ // makeFocusable(<sectionId>)
1204
+ makeFocusable: function(sectionId) {
1205
+ var doMakeFocusable = function(section) {
1206
+ var tabIndexIgnoreList = section.tabIndexIgnoreList !== undefined ?
1207
+ section.tabIndexIgnoreList : GlobalConfig.tabIndexIgnoreList;
1208
+ parseSelector(section.selector).forEach(function(elem) {
1209
+ if (!matchSelector(elem, tabIndexIgnoreList)) {
1210
+ if (!elem.getAttribute('tabindex')) {
1211
+ elem.setAttribute('tabindex', '-1');
1212
+ }
1213
+ }
1214
+ });
1215
+ };
1216
+
1217
+ if (sectionId) {
1218
+ if (_sections[sectionId]) {
1219
+ doMakeFocusable(_sections[sectionId]);
1220
+ } else {
1221
+ throw new Error('Section "' + sectionId + '" doesn\'t exist!');
1222
+ }
1223
+ } else {
1224
+ for (var id in _sections) {
1225
+ doMakeFocusable(_sections[id]);
1226
+ }
1227
+ }
1228
+ },
1229
+
1230
+ setDefaultSection: function(sectionId) {
1231
+ if (!sectionId) {
1232
+ _defaultSectionId = '';
1233
+ } else if (!_sections[sectionId]) {
1234
+ throw new Error('Section "' + sectionId + '" doesn\'t exist!');
1235
+ } else {
1236
+ _defaultSectionId = sectionId;
1237
+ }
1238
+ }
1239
+ };
1240
+
1241
+ /**********************/
1242
+ /* ESM Export */
1243
+ /**********************/
1244
+ // 保留 window 挂载以兼容某些通过全局变量访问的场景
1245
+ if (typeof window !== 'undefined') {
1246
+ (window as any).SpatialNavigation = SpatialNavigation;
1247
+ }
1248
+
1249
+ export default SpatialNavigation;