arrowtab 0.0.4 → 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.
Files changed (86) hide show
  1. package/README.md +129 -1
  2. package/dist/index.d.ts +5 -1
  3. package/dist/index.js +2 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/caret.d.ts +5 -0
  6. package/dist/lib/caret.js +136 -0
  7. package/dist/lib/caret.js.map +1 -0
  8. package/dist/lib/changesValueOnArrowKeys.d.ts +3 -0
  9. package/dist/lib/changesValueOnArrowKeys.js +15 -0
  10. package/dist/lib/changesValueOnArrowKeys.js.map +1 -0
  11. package/dist/lib/debugMode.d.ts +5 -5
  12. package/dist/lib/debugMode.js +28 -32
  13. package/dist/lib/debugMode.js.map +1 -1
  14. package/dist/lib/directions.d.ts +13 -0
  15. package/dist/lib/directions.js +17 -0
  16. package/dist/lib/directions.js.map +1 -0
  17. package/dist/lib/dom.d.ts +16 -0
  18. package/dist/lib/dom.js +62 -0
  19. package/dist/lib/dom.js.map +1 -0
  20. package/dist/lib/focusHistory.d.ts +11 -0
  21. package/dist/lib/focusHistory.js +72 -0
  22. package/dist/lib/focusHistory.js.map +1 -0
  23. package/dist/lib/focusTracker.d.ts +12 -0
  24. package/dist/lib/focusTracker.js +43 -0
  25. package/dist/lib/focusTracker.js.map +1 -0
  26. package/dist/lib/focusable.d.ts +8 -0
  27. package/dist/lib/focusable.js +135 -0
  28. package/dist/lib/focusable.js.map +1 -0
  29. package/dist/lib/geometry.d.ts +35 -0
  30. package/dist/lib/geometry.js +70 -0
  31. package/dist/lib/geometry.js.map +1 -0
  32. package/dist/lib/hasDisabledKeys.d.ts +4 -3
  33. package/dist/lib/hasDisabledKeys.js +5 -25
  34. package/dist/lib/hasDisabledKeys.js.map +1 -1
  35. package/dist/lib/initArrowTab.d.ts +12 -2
  36. package/dist/lib/initArrowTab.js +133 -70
  37. package/dist/lib/initArrowTab.js.map +1 -1
  38. package/dist/lib/initArrowTab.test.d.ts +1 -0
  39. package/dist/lib/initArrowTab.test.js +689 -0
  40. package/dist/lib/initArrowTab.test.js.map +1 -0
  41. package/dist/lib/navigateEvent.d.ts +16 -0
  42. package/dist/lib/navigateEvent.js +9 -0
  43. package/dist/lib/navigateEvent.js.map +1 -0
  44. package/dist/lib/rankCandidates.d.ts +11 -0
  45. package/dist/lib/rankCandidates.js +31 -0
  46. package/dist/lib/rankCandidates.js.map +1 -0
  47. package/dist/lib/scope.d.ts +11 -0
  48. package/dist/lib/scope.js +60 -0
  49. package/dist/lib/scope.js.map +1 -0
  50. package/dist/lib/strategies.d.ts +21 -10
  51. package/dist/lib/strategies.js +63 -106
  52. package/dist/lib/strategies.js.map +1 -1
  53. package/dist/lib/strategies.test.d.ts +1 -0
  54. package/dist/lib/strategies.test.js +88 -0
  55. package/dist/lib/strategies.test.js.map +1 -0
  56. package/dist/lib/widgets.d.ts +5 -0
  57. package/dist/lib/widgets.js +50 -0
  58. package/dist/lib/widgets.js.map +1 -0
  59. package/dist/lib/wrap.d.ts +12 -0
  60. package/dist/lib/wrap.js +21 -0
  61. package/dist/lib/wrap.js.map +1 -0
  62. package/package.json +2 -1
  63. package/dist/lib/distances.d.ts +0 -13
  64. package/dist/lib/distances.js +0 -15
  65. package/dist/lib/distances.js.map +0 -1
  66. package/dist/lib/getAngle.d.ts +0 -4
  67. package/dist/lib/getAngle.js +0 -15
  68. package/dist/lib/getAngle.js.map +0 -1
  69. package/dist/lib/getFocusable.d.ts +0 -1
  70. package/dist/lib/getFocusable.js +0 -29
  71. package/dist/lib/getFocusable.js.map +0 -1
  72. package/dist/lib/getXyDistance.d.ts +0 -7
  73. package/dist/lib/getXyDistance.js +0 -12
  74. package/dist/lib/getXyDistance.js.map +0 -1
  75. package/dist/lib/hasTextSelection.d.ts +0 -4
  76. package/dist/lib/hasTextSelection.js +0 -53
  77. package/dist/lib/hasTextSelection.js.map +0 -1
  78. package/dist/lib/positions.d.ts +0 -8
  79. package/dist/lib/positions.js +0 -16
  80. package/dist/lib/positions.js.map +0 -1
  81. package/dist/lib/preventNativeArrowKeyPresses.d.ts +0 -4
  82. package/dist/lib/preventNativeArrowKeyPresses.js +0 -24
  83. package/dist/lib/preventNativeArrowKeyPresses.js.map +0 -1
  84. package/dist/lib/sumBy.d.ts +0 -1
  85. package/dist/lib/sumBy.js +0 -2
  86. package/dist/lib/sumBy.js.map +0 -1
@@ -0,0 +1,689 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeAll, beforeEach, describe, expect, test, } from 'vitest';
3
+ import { initArrowTab } from './initArrowTab.js';
4
+ import { spatial } from './strategies.js';
5
+ // happy-dom has no layout, so elements get their position from data-rect="left top width height"
6
+ const getDataRect = ({ element }) => {
7
+ const [left = 0, top = 0, width = 0, height = 0] = (element.getAttribute('data-rect') ?? '')
8
+ .split(' ')
9
+ .map(Number);
10
+ return { left, top, width, height };
11
+ };
12
+ beforeAll(() => {
13
+ Element.prototype.getBoundingClientRect = function () {
14
+ const { left, top, width, height } = getDataRect({ element: this });
15
+ return DOMRect.fromRect({ x: left, y: top, width, height });
16
+ };
17
+ Object.defineProperties(HTMLElement.prototype, {
18
+ clientWidth: {
19
+ get() {
20
+ return getDataRect({ element: this }).width;
21
+ },
22
+ },
23
+ clientHeight: {
24
+ get() {
25
+ return getDataRect({ element: this }).height;
26
+ },
27
+ },
28
+ });
29
+ });
30
+ let cleanup = () => { };
31
+ const setup = ({ html, options, }) => {
32
+ document.body.innerHTML = html;
33
+ cleanup = initArrowTab(options).cleanup;
34
+ };
35
+ const byId = (id) => document.getElementById(id);
36
+ const press = ({ key, init }) => {
37
+ const event = new KeyboardEvent('keydown', {
38
+ key,
39
+ bubbles: true,
40
+ cancelable: true,
41
+ ...init,
42
+ });
43
+ (document.activeElement ?? document.body).dispatchEvent(event);
44
+ return event;
45
+ };
46
+ const nextTick = () => new Promise((resolve) => setTimeout(resolve));
47
+ beforeEach(() => {
48
+ document.body.innerHTML = '';
49
+ });
50
+ afterEach(() => {
51
+ cleanup();
52
+ });
53
+ describe('navigation', () => {
54
+ test('moves focus in the pressed direction', () => {
55
+ setup({
56
+ html: `
57
+ <button id="a" data-rect="0 0 100 40">a</button>
58
+ <button id="b" data-rect="120 10 100 40">b</button>
59
+ <button id="c" data-rect="0 60 100 40">c</button>
60
+ `,
61
+ });
62
+ byId('a').focus();
63
+ expect(press({ key: 'ArrowRight' }).defaultPrevented).toBe(true);
64
+ expect(document.activeElement).toBe(byId('b'));
65
+ press({ key: 'ArrowLeft' });
66
+ press({ key: 'ArrowDown' });
67
+ expect(document.activeElement).toBe(byId('c'));
68
+ });
69
+ test('keeps the native behavior if nothing is within reach', () => {
70
+ setup({ html: `<button id="a" data-rect="0 0 100 40">a</button>` });
71
+ byId('a').focus();
72
+ expect(press({ key: 'ArrowDown' }).defaultPrevented).toBe(false);
73
+ });
74
+ test('prevents value changes of fields even if nothing is within reach', () => {
75
+ setup({ html: `<input id="a" type="number" data-rect="0 0 100 40" />` });
76
+ byId('a').focus();
77
+ expect(press({ key: 'ArrowDown' }).defaultPrevented).toBe(true);
78
+ });
79
+ test('ignores modifier keys', () => {
80
+ setup({
81
+ html: `
82
+ <button id="a" data-rect="0 0 100 40">a</button>
83
+ <button id="b" data-rect="120 0 100 40">b</button>
84
+ `,
85
+ });
86
+ byId('a').focus();
87
+ for (const modifier of ['shiftKey', 'altKey', 'metaKey', 'ctrlKey']) {
88
+ const event = press({ key: 'ArrowRight', init: { [modifier]: true } });
89
+ expect(event.defaultPrevented).toBe(false);
90
+ expect(document.activeElement).toBe(byId('a'));
91
+ }
92
+ });
93
+ test('reaches non-editable controls inside a contenteditable', () => {
94
+ setup({
95
+ html: `
96
+ <button id="a" data-rect="0 0 100 40">a</button>
97
+ <div contenteditable="true" data-rect="0 60 300 40">
98
+ <a href="#" data-rect="0 60 100 40">part of the text</a>
99
+ <button id="action" contenteditable="false" data-rect="200 60 100 40">action</button>
100
+ </div>
101
+ `,
102
+ });
103
+ byId('a').focus();
104
+ press({ key: 'ArrowRight' });
105
+ expect(document.activeElement).toBe(byId('action'));
106
+ });
107
+ test('reaches nested editors with a tabindex', () => {
108
+ setup({
109
+ html: `
110
+ <button id="a" data-rect="0 0 100 40">a</button>
111
+ <div contenteditable="true" data-rect="0 60 300 40">
112
+ <div id="nested" contenteditable="true" tabindex="0" data-rect="200 60 100 40">nested</div>
113
+ </div>
114
+ `,
115
+ });
116
+ byId('a').focus();
117
+ press({ key: 'ArrowRight' });
118
+ expect(document.activeElement).toBe(byId('nested'));
119
+ });
120
+ test('skips elements that are not focusable via Tab', () => {
121
+ setup({
122
+ html: `
123
+ <button id="a" data-rect="0 0 100 40">a</button>
124
+ <button disabled data-rect="120 0 100 40">disabled</button>
125
+ <button tabindex="-1" data-rect="240 0 100 40">tabindex</button>
126
+ <div inert><button data-rect="360 0 100 40">inert</button></div>
127
+ <div aria-hidden="true"><button data-rect="480 0 100 40">hidden</button></div>
128
+ <button data-rect="600 0 0 0">empty</button>
129
+ <button id="b" data-rect="720 0 100 40">b</button>
130
+ `,
131
+ });
132
+ byId('a').focus();
133
+ press({ key: 'ArrowRight' });
134
+ expect(document.activeElement).toBe(byId('b'));
135
+ });
136
+ test('uses the grid strategy if configured', () => {
137
+ setup({
138
+ html: `
139
+ <button id="a" data-rect="0 0 100 40">a</button>
140
+ <button id="far" data-rect="400 60 100 40">far</button>
141
+ <button id="near" data-rect="150 60 100 40">near</button>
142
+ `,
143
+ options: { strategy: 'grid' },
144
+ });
145
+ byId('a').focus();
146
+ press({ key: 'ArrowDown' });
147
+ expect(document.activeElement).toBe(byId('near'));
148
+ });
149
+ test('going back returns to the element focus came from', () => {
150
+ setup({
151
+ html: `
152
+ <button id="wide" data-rect="0 0 300 40">wide</button>
153
+ <button id="left" data-rect="0 60 100 40">left</button>
154
+ <button id="right" data-rect="200 60 100 40">right</button>
155
+ <button id="top-right" data-rect="200 -60 100 40">top right</button>
156
+ `,
157
+ });
158
+ byId('top-right').focus();
159
+ press({ key: 'ArrowDown' });
160
+ expect(document.activeElement).toBe(byId('wide'));
161
+ press({ key: 'ArrowUp' });
162
+ expect(document.activeElement).toBe(byId('top-right'));
163
+ byId('right').focus();
164
+ press({ key: 'ArrowUp' });
165
+ expect(document.activeElement).toBe(byId('wide'));
166
+ press({ key: 'ArrowDown' });
167
+ expect(document.activeElement).toBe(byId('right'));
168
+ });
169
+ test('skips elements clipped by overflow: hidden', () => {
170
+ // happy-dom doesn't expand the overflow shorthand
171
+ setup({
172
+ html: `
173
+ <button id="a" data-rect="0 0 100 40">a</button>
174
+ <div style="overflow-x: hidden; overflow-y: hidden" data-rect="120 0 100 40">
175
+ <button id="visible" data-rect="120 0 100 40">visible</button>
176
+ <button id="clipped" data-rect="220 0 100 40">clipped</button>
177
+ </div>
178
+ <div style="overflow-x: auto; overflow-y: auto" data-rect="0 60 100 40">
179
+ <button data-rect="0 60 100 40">scrollable</button>
180
+ <button id="scrolled" data-rect="120 60 100 40">scrolled out</button>
181
+ </div>
182
+ `,
183
+ });
184
+ byId('visible').focus();
185
+ press({ key: 'ArrowRight' });
186
+ expect(document.activeElement).toBe(byId('visible'));
187
+ press({ key: 'ArrowDown' });
188
+ expect(document.activeElement).toBe(byId('scrolled'));
189
+ });
190
+ test('elements in a scroll container inside overflow: hidden stay reachable', () => {
191
+ setup({
192
+ html: `
193
+ <div style="overflow-x: hidden; overflow-y: hidden" data-rect="0 0 220 40">
194
+ <div style="overflow-x: auto; overflow-y: auto" data-rect="0 0 220 40">
195
+ <button id="a" data-rect="0 0 100 40">a</button>
196
+ <button id="b" data-rect="120 0 100 40">b</button>
197
+ <button id="c" data-rect="240 0 100 40">scrolled out</button>
198
+ </div>
199
+ </div>
200
+ `,
201
+ });
202
+ byId('b').focus();
203
+ press({ key: 'ArrowRight' });
204
+ expect(document.activeElement).toBe(byId('c'));
205
+ });
206
+ test('data-arrowtab="skip" excludes elements', () => {
207
+ setup({
208
+ html: `
209
+ <button id="a" data-rect="0 0 100 40">a</button>
210
+ <a href="#" data-arrowtab="skip" data-rect="120 0 100 40">help</a>
211
+ <button id="b" data-rect="240 0 100 40">b</button>
212
+ `,
213
+ });
214
+ byId('a').focus();
215
+ press({ key: 'ArrowRight' });
216
+ expect(document.activeElement).toBe(byId('b'));
217
+ });
218
+ test('the navigate event can cancel navigation', () => {
219
+ setup({
220
+ html: `
221
+ <button id="a" data-rect="0 0 100 40">a</button>
222
+ <button id="b" data-rect="120 0 100 40">b</button>
223
+ `,
224
+ });
225
+ byId('a').focus();
226
+ const details = [];
227
+ const onNavigate = (event) => {
228
+ details.push(event.detail);
229
+ event.preventDefault();
230
+ };
231
+ document.addEventListener('arrowtab:navigate', onNavigate);
232
+ expect(press({ key: 'ArrowRight' }).defaultPrevented).toBe(false);
233
+ document.removeEventListener('arrowtab:navigate', onNavigate);
234
+ expect(document.activeElement).toBe(byId('a'));
235
+ expect(details).toEqual([
236
+ { from: byId('a'), to: byId('b'), direction: 'right' },
237
+ ]);
238
+ });
239
+ test('the root option limits navigation', () => {
240
+ document.body.innerHTML = `
241
+ <div id="root">
242
+ <button id="a" data-rect="0 0 100 40">a</button>
243
+ </div>
244
+ <button id="outside" data-rect="120 0 100 40">outside</button>
245
+ <button id="below" data-rect="120 60 100 40">below</button>
246
+ `;
247
+ cleanup = initArrowTab({ root: byId('root') }).cleanup;
248
+ byId('a').focus();
249
+ press({ key: 'ArrowRight' });
250
+ expect(document.activeElement).toBe(byId('a'));
251
+ byId('outside').focus();
252
+ press({ key: 'ArrowDown' });
253
+ expect(document.activeElement).toBe(byId('outside'));
254
+ });
255
+ test('the root option keeps lost focus recovery', () => {
256
+ document.body.innerHTML = `
257
+ <div id="root">
258
+ <button id="removed" data-rect="0 0 100 40">removed</button>
259
+ <button id="below" data-rect="0 60 100 40">below</button>
260
+ </div>
261
+ `;
262
+ cleanup = initArrowTab({ root: byId('root') }).cleanup;
263
+ byId('removed').focus();
264
+ byId('removed').remove();
265
+ document.activeElement?.blur();
266
+ press({ key: 'ArrowDown' });
267
+ expect(document.activeElement).toBe(byId('below'));
268
+ });
269
+ test('navigates into and out of shadow roots', () => {
270
+ setup({
271
+ html: `
272
+ <button id="a" data-rect="0 0 100 40">a</button>
273
+ <div id="host"></div>
274
+ <button id="b" data-rect="240 0 100 40">b</button>
275
+ `,
276
+ });
277
+ const shadow = byId('host').attachShadow({ mode: 'open' });
278
+ shadow.innerHTML = `<input id="inner" data-rect="120 0 100 40" />`;
279
+ const inner = shadow.getElementById('inner');
280
+ byId('a').focus();
281
+ press({ key: 'ArrowRight' });
282
+ expect(shadow.activeElement).toBe(inner);
283
+ inner.dispatchEvent(new KeyboardEvent('keydown', {
284
+ key: 'ArrowRight',
285
+ bubbles: true,
286
+ composed: true,
287
+ cancelable: true,
288
+ }));
289
+ expect(document.activeElement).toBe(byId('b'));
290
+ });
291
+ test('navigates between elements slotted into a shadow modal', () => {
292
+ setup({
293
+ html: `
294
+ <button data-rect="0 300 100 40">behind</button>
295
+ <div id="host">
296
+ <button id="a" data-rect="0 0 100 40">a</button>
297
+ <button id="b" data-rect="0 60 100 40">b</button>
298
+ </div>
299
+ `,
300
+ });
301
+ byId('host').attachShadow({ mode: 'open' }).innerHTML = `
302
+ <div role="dialog" aria-modal="true"><slot></slot></div>
303
+ `;
304
+ byId('a').focus();
305
+ // happy-dom lacks assignedSlot, staying inside the modal is checked in the browser
306
+ press({ key: 'ArrowDown' });
307
+ expect(document.activeElement).toBe(byId('b'));
308
+ });
309
+ test('entering a scope respects custom strategy exclusions', () => {
310
+ setup({
311
+ html: `
312
+ <button class="skip-me" data-rect="0 0 100 40">skipped</button>
313
+ <button id="b" data-rect="0 60 100 40">b</button>
314
+ `,
315
+ options: {
316
+ strategy: ({ origin, candidates, direction }) => spatial({
317
+ origin,
318
+ direction,
319
+ candidates: candidates.filter(({ element }) => !element.matches('.skip-me')),
320
+ }),
321
+ },
322
+ });
323
+ press({ key: 'ArrowDown' });
324
+ expect(document.activeElement).toBe(byId('b'));
325
+ });
326
+ test('entering a focusable container respects custom strategy exclusions', () => {
327
+ setup({
328
+ html: `
329
+ <div id="card" tabindex="0" data-rect="0 0 300 100">
330
+ <button class="skip-me" data-rect="0 0 100 40">skipped</button>
331
+ <button id="b" data-rect="120 0 100 40">b</button>
332
+ </div>
333
+ `,
334
+ options: {
335
+ strategy: ({ origin, candidates, direction }) => spatial({
336
+ origin,
337
+ direction,
338
+ candidates: candidates.filter(({ element }) => !element.matches('.skip-me')),
339
+ }),
340
+ },
341
+ });
342
+ byId('card').focus();
343
+ press({ key: 'ArrowRight' });
344
+ expect(document.activeElement).toBe(byId('b'));
345
+ });
346
+ test('finds elements in the shadow root of a slotted component', () => {
347
+ setup({
348
+ html: `
349
+ <button data-rect="0 300 100 40">behind</button>
350
+ <div id="host"><div id="slotted"></div></div>
351
+ `,
352
+ });
353
+ byId('host').attachShadow({ mode: 'open' }).innerHTML = `
354
+ <div role="dialog" aria-modal="true"><slot></slot></div>
355
+ `;
356
+ const shadow = byId('slotted').attachShadow({ mode: 'open' });
357
+ shadow.innerHTML = `
358
+ <button id="a" data-rect="0 0 100 40">a</button>
359
+ <button id="b" data-rect="0 60 100 40">b</button>
360
+ `;
361
+ shadow.getElementById('a').focus();
362
+ press({ key: 'ArrowDown' });
363
+ expect(shadow.activeElement).toBe(shadow.getElementById('b'));
364
+ });
365
+ test('cleanup removes the listener', () => {
366
+ setup({
367
+ html: `
368
+ <button id="a" data-rect="0 0 100 40">a</button>
369
+ <button id="b" data-rect="120 0 100 40">b</button>
370
+ `,
371
+ });
372
+ cleanup();
373
+ byId('a').focus();
374
+ press({ key: 'ArrowRight' });
375
+ expect(document.activeElement).toBe(byId('a'));
376
+ });
377
+ });
378
+ describe('text fields', () => {
379
+ const html = `
380
+ <input id="input" value="hello" data-rect="0 0 100 40" />
381
+ <textarea id="textarea" data-rect="120 0 100 40">first\nsecond</textarea>
382
+ <button id="right" data-rect="240 0 100 40">right</button>
383
+ <button id="below" data-rect="120 60 100 40">below</button>
384
+ `;
385
+ test('moves the caret until it reaches the edge', () => {
386
+ setup({ html });
387
+ const input = byId('input');
388
+ input.focus();
389
+ input.setSelectionRange(2, 2);
390
+ expect(press({ key: 'ArrowRight' }).defaultPrevented).toBe(false);
391
+ expect(document.activeElement).toBe(input);
392
+ input.setSelectionRange(5, 5);
393
+ press({ key: 'ArrowRight' });
394
+ expect(document.activeElement).toBe(byId('textarea'));
395
+ });
396
+ test('textareas only leave at the first / last line', () => {
397
+ setup({ html });
398
+ const textarea = byId('textarea');
399
+ textarea.focus();
400
+ textarea.setSelectionRange(2, 2);
401
+ press({ key: 'ArrowDown' });
402
+ expect(document.activeElement).toBe(textarea);
403
+ textarea.setSelectionRange(8, 8);
404
+ press({ key: 'ArrowDown' });
405
+ expect(document.activeElement).toBe(byId('below'));
406
+ });
407
+ });
408
+ describe('contenteditable', () => {
409
+ const placeCaret = ({ node, offset }) => {
410
+ const range = document.createRange();
411
+ range.setStart(node, offset);
412
+ range.collapse(true);
413
+ window.getSelection()?.removeAllRanges();
414
+ window.getSelection()?.addRange(range);
415
+ };
416
+ test('preserved whitespace counts as content', () => {
417
+ setup({
418
+ html: `
419
+ <div id="editor" contenteditable="true" style="white-space: pre-wrap" data-rect="0 0 100 40">abc </div>
420
+ <button data-rect="120 0 100 40">right</button>
421
+ `,
422
+ });
423
+ const editor = byId('editor');
424
+ editor.focus();
425
+ placeCaret({ node: editor.firstChild, offset: 3 });
426
+ press({ key: 'ArrowRight' });
427
+ expect(document.activeElement).toBe(editor);
428
+ });
429
+ test('collapsed whitespace is ignored', () => {
430
+ setup({
431
+ html: `
432
+ <div id="editor" contenteditable="true" data-rect="0 0 100 40">abc </div>
433
+ <button id="right" data-rect="120 0 100 40">right</button>
434
+ `,
435
+ });
436
+ const editor = byId('editor');
437
+ editor.focus();
438
+ placeCaret({ node: editor.firstChild, offset: 3 });
439
+ press({ key: 'ArrowRight' });
440
+ expect(document.activeElement).toBe(byId('right'));
441
+ });
442
+ });
443
+ describe('opting out', () => {
444
+ test('data-arrowtab disables single directions', () => {
445
+ setup({
446
+ html: `
447
+ <div data-arrowtab="disable-right">
448
+ <button id="a" data-rect="0 0 100 40">a</button>
449
+ </div>
450
+ <button id="b" data-rect="120 0 100 40">b</button>
451
+ <button id="c" data-rect="0 60 100 40">c</button>
452
+ `,
453
+ });
454
+ byId('a').focus();
455
+ press({ key: 'ArrowRight' });
456
+ expect(document.activeElement).toBe(byId('a'));
457
+ press({ key: 'ArrowDown' });
458
+ expect(document.activeElement).toBe(byId('c'));
459
+ });
460
+ test('data-arrowtab on the body applies without focus', () => {
461
+ setup({
462
+ html: `<button id="a" data-rect="0 0 100 40">a</button>`,
463
+ });
464
+ document.body.setAttribute('data-arrowtab', 'disable-down');
465
+ const event = press({ key: 'ArrowDown' });
466
+ document.body.removeAttribute('data-arrowtab');
467
+ expect(event.defaultPrevented).toBe(false);
468
+ expect(document.activeElement).not.toBe(byId('a'));
469
+ });
470
+ test('ARIA widgets keep their own arrow keys', () => {
471
+ setup({
472
+ html: `
473
+ <div role="tablist">
474
+ <button id="tab" role="tab" data-rect="0 0 100 40">tab</button>
475
+ <button role="tab" tabindex="-1" data-rect="120 0 100 40">tab 2</button>
476
+ </div>
477
+ <button id="below" data-rect="0 60 100 40">below</button>
478
+ `,
479
+ });
480
+ byId('tab').focus();
481
+ expect(press({ key: 'ArrowRight' }).defaultPrevented).toBe(false);
482
+ expect(document.activeElement).toBe(byId('tab'));
483
+ press({ key: 'ArrowDown' });
484
+ expect(document.activeElement).toBe(byId('below'));
485
+ });
486
+ test('open comboboxes keep up / down', () => {
487
+ setup({
488
+ html: `
489
+ <input id="combobox" role="combobox" aria-expanded="true" data-rect="0 0 100 40" />
490
+ <button data-rect="0 60 100 40">below</button>
491
+ `,
492
+ });
493
+ byId('combobox').focus();
494
+ press({ key: 'ArrowDown' });
495
+ expect(document.activeElement).toBe(byId('combobox'));
496
+ });
497
+ test('data-arrowtab="enable" overrides widget detection', () => {
498
+ setup({
499
+ html: `
500
+ <div role="toolbar" data-arrowtab="enable">
501
+ <button id="a" data-rect="0 0 100 40">a</button>
502
+ <button id="b" data-rect="120 0 100 40">b</button>
503
+ </div>
504
+ `,
505
+ });
506
+ byId('a').focus();
507
+ press({ key: 'ArrowRight' });
508
+ expect(document.activeElement).toBe(byId('b'));
509
+ });
510
+ });
511
+ describe('scopes', () => {
512
+ test('navigation stays inside an open modal', () => {
513
+ setup({
514
+ html: `
515
+ <button id="behind" data-rect="0 100 100 40">behind</button>
516
+ <div role="dialog" aria-modal="true">
517
+ <button id="a" data-rect="0 0 100 40">a</button>
518
+ </div>
519
+ `,
520
+ });
521
+ byId('a').focus();
522
+ press({ key: 'ArrowDown' });
523
+ expect(document.activeElement).toBe(byId('a'));
524
+ });
525
+ test('navigation stays inside a non-modal dialog', () => {
526
+ setup({
527
+ html: `
528
+ <button id="behind" data-rect="0 100 100 40">behind</button>
529
+ <div role="dialog">
530
+ <button id="a" data-rect="0 0 100 40">a</button>
531
+ </div>
532
+ `,
533
+ });
534
+ byId('a').focus();
535
+ press({ key: 'ArrowDown' });
536
+ expect(document.activeElement).toBe(byId('a'));
537
+ });
538
+ test('focus behind a modal jumps into the modal', () => {
539
+ setup({
540
+ html: `
541
+ <button id="behind" data-rect="0 0 100 40">behind</button>
542
+ <div role="dialog" aria-modal="true">
543
+ <button id="a" data-rect="0 300 100 40">a</button>
544
+ <button id="b" data-rect="0 360 100 40">b</button>
545
+ </div>
546
+ `,
547
+ });
548
+ byId('behind').focus();
549
+ press({ key: 'ArrowUp' });
550
+ expect(document.activeElement).toBe(byId('a'));
551
+ });
552
+ test('data-arrowtab="wrap" jumps to the opposite edge', () => {
553
+ setup({
554
+ html: `
555
+ <div data-arrowtab="wrap">
556
+ <button id="a" data-rect="0 0 100 40">a</button>
557
+ <button id="b" data-rect="120 0 100 40">b</button>
558
+ <button id="c" data-rect="0 60 100 40">c</button>
559
+ <button id="d" data-rect="120 60 100 40">d</button>
560
+ </div>
561
+ <button data-rect="0 120 100 40">outside</button>
562
+ `,
563
+ });
564
+ byId('d').focus();
565
+ press({ key: 'ArrowDown' });
566
+ expect(document.activeElement).toBe(byId('b'));
567
+ press({ key: 'ArrowRight' });
568
+ expect(document.activeElement).toBe(byId('a'));
569
+ });
570
+ test('a modal wins over the scope focus is in', () => {
571
+ setup({
572
+ html: `
573
+ <div data-arrowtab="scope">
574
+ <button id="behind" data-rect="0 0 100 40">behind</button>
575
+ <button data-rect="0 60 100 40">behind 2</button>
576
+ </div>
577
+ <div role="dialog" aria-modal="true">
578
+ <button id="a" data-rect="0 300 100 40">a</button>
579
+ </div>
580
+ `,
581
+ });
582
+ byId('behind').focus();
583
+ press({ key: 'ArrowDown' });
584
+ expect(document.activeElement).toBe(byId('a'));
585
+ });
586
+ test('finds modals inside shadow roots', () => {
587
+ setup({
588
+ html: `
589
+ <button id="behind" data-rect="0 0 100 40">behind</button>
590
+ <button data-rect="0 60 100 40">behind 2</button>
591
+ <div id="host"></div>
592
+ `,
593
+ });
594
+ const shadow = byId('host').attachShadow({ mode: 'open' });
595
+ shadow.innerHTML = `
596
+ <div role="dialog" aria-modal="true">
597
+ <button id="inner" data-rect="0 300 100 40">inner</button>
598
+ </div>
599
+ `;
600
+ byId('behind').focus();
601
+ press({ key: 'ArrowDown' });
602
+ expect(shadow.activeElement).toBe(shadow.getElementById('inner'));
603
+ });
604
+ test("text fields behind a modal don't keep the arrow keys", () => {
605
+ setup({
606
+ html: `
607
+ <input id="behind" value="hello" data-rect="0 0 100 40" />
608
+ <div role="dialog" aria-modal="true">
609
+ <button id="a" data-rect="0 300 100 40">a</button>
610
+ </div>
611
+ `,
612
+ });
613
+ const input = byId('behind');
614
+ input.focus();
615
+ input.setSelectionRange(2, 2);
616
+ press({ key: 'ArrowRight' });
617
+ expect(document.activeElement).toBe(byId('a'));
618
+ });
619
+ test('data-arrowtab="scope" keeps focus inside', () => {
620
+ setup({
621
+ html: `
622
+ <div data-arrowtab="scope">
623
+ <button id="a" data-rect="0 0 100 40">a</button>
624
+ </div>
625
+ <button data-rect="0 60 100 40">outside</button>
626
+ `,
627
+ });
628
+ byId('a').focus();
629
+ press({ key: 'ArrowDown' });
630
+ expect(document.activeElement).toBe(byId('a'));
631
+ });
632
+ });
633
+ describe('focus recovery', () => {
634
+ test('navigates from the last position if the focused element was removed', () => {
635
+ setup({
636
+ html: `
637
+ <button id="top" data-rect="0 0 100 40">top</button>
638
+ <button id="removed" data-rect="0 100 100 40">removed</button>
639
+ <button id="bottom" data-rect="0 200 100 40">bottom</button>
640
+ `,
641
+ });
642
+ byId('removed').focus();
643
+ byId('removed').remove();
644
+ document.activeElement?.blur();
645
+ press({ key: 'ArrowDown' });
646
+ expect(document.activeElement).toBe(byId('bottom'));
647
+ });
648
+ test('autoDetectHistory refocuses the opener when a dialog closes', async () => {
649
+ setup({
650
+ html: `<button id="opener" data-rect="0 0 100 40">open</button>`,
651
+ options: { autoDetectHistory: true },
652
+ });
653
+ byId('opener').focus();
654
+ const portal = document.createElement('div');
655
+ portal.innerHTML = `
656
+ <div role="dialog">
657
+ <button id="inside" data-rect="0 100 100 40">inside</button>
658
+ </div>
659
+ `;
660
+ document.body.append(portal);
661
+ byId('inside').focus();
662
+ await nextTick();
663
+ portal.remove();
664
+ document.activeElement?.blur();
665
+ await nextTick();
666
+ expect(document.activeElement).toBe(byId('opener'));
667
+ });
668
+ test('autoDetectHistory leaves focus alone if the dialog restored it', async () => {
669
+ setup({
670
+ html: `
671
+ <button id="opener" data-rect="0 0 100 40">open</button>
672
+ <button id="other" data-rect="0 60 100 40">other</button>
673
+ `,
674
+ options: { autoDetectHistory: true },
675
+ });
676
+ byId('opener').focus();
677
+ const dialog = document.createElement('div');
678
+ dialog.setAttribute('role', 'dialog');
679
+ dialog.innerHTML = `<button id="inside" data-rect="0 100 100 40">inside</button>`;
680
+ document.body.append(dialog);
681
+ byId('inside').focus();
682
+ await nextTick();
683
+ dialog.remove();
684
+ byId('other').focus();
685
+ await nextTick();
686
+ expect(document.activeElement).toBe(byId('other'));
687
+ });
688
+ });
689
+ //# sourceMappingURL=initArrowTab.test.js.map