@furystack/shades 13.0.0 → 13.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.
- package/CHANGELOG.md +52 -0
- package/esm/models/render-options.d.ts +5 -2
- package/esm/models/render-options.d.ts.map +1 -1
- package/esm/services/index.d.ts +1 -0
- package/esm/services/index.d.ts.map +1 -1
- package/esm/services/index.js +1 -0
- package/esm/services/index.js.map +1 -1
- package/esm/services/spatial-navigation-service.d.ts +88 -0
- package/esm/services/spatial-navigation-service.d.ts.map +1 -0
- package/esm/services/spatial-navigation-service.js +523 -0
- package/esm/services/spatial-navigation-service.js.map +1 -0
- package/esm/services/spatial-navigation-service.spec.d.ts +2 -0
- package/esm/services/spatial-navigation-service.spec.d.ts.map +1 -0
- package/esm/services/spatial-navigation-service.spec.js +1133 -0
- package/esm/services/spatial-navigation-service.spec.js.map +1 -0
- package/package.json +2 -2
- package/src/models/render-options.ts +5 -2
- package/src/services/index.ts +1 -0
- package/src/services/spatial-navigation-service.spec.ts +1396 -0
- package/src/services/spatial-navigation-service.ts +597 -0
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
import { Injector } from '@furystack/inject';
|
|
2
|
+
import { usingAsync } from '@furystack/utils';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { SpatialNavigationService, configureSpatialNavigation } from './spatial-navigation-service.js';
|
|
5
|
+
const mockRect = (el, rect) => {
|
|
6
|
+
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
|
7
|
+
left: rect.left,
|
|
8
|
+
top: rect.top,
|
|
9
|
+
right: rect.left + rect.width,
|
|
10
|
+
bottom: rect.top + rect.height,
|
|
11
|
+
width: rect.width,
|
|
12
|
+
height: rect.height,
|
|
13
|
+
x: rect.left,
|
|
14
|
+
y: rect.top,
|
|
15
|
+
toJSON: () => ({}),
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
const createButton = (id, rect) => {
|
|
19
|
+
const btn = document.createElement('button');
|
|
20
|
+
btn.id = id;
|
|
21
|
+
btn.textContent = id;
|
|
22
|
+
mockRect(btn, rect);
|
|
23
|
+
btn.scrollIntoView = vi.fn();
|
|
24
|
+
return btn;
|
|
25
|
+
};
|
|
26
|
+
const pressKey = (key) => {
|
|
27
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
|
28
|
+
};
|
|
29
|
+
describe('SpatialNavigationService', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
document.body.innerHTML = '';
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
document.body.innerHTML = '';
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
});
|
|
37
|
+
it('Should be constructed via injector', async () => {
|
|
38
|
+
await usingAsync(new Injector(), async (i) => {
|
|
39
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
40
|
+
expect(s).toBeInstanceOf(SpatialNavigationService);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
it('Should be enabled by default', async () => {
|
|
44
|
+
await usingAsync(new Injector(), async (i) => {
|
|
45
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
46
|
+
expect(s.enabled.getValue()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
it('Should have null activeSection initially', async () => {
|
|
50
|
+
await usingAsync(new Injector(), async (i) => {
|
|
51
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
52
|
+
expect(s.activeSection.getValue()).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('focus movement', () => {
|
|
56
|
+
it('Should move focus to the right', async () => {
|
|
57
|
+
await usingAsync(new Injector(), async (i) => {
|
|
58
|
+
const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 });
|
|
59
|
+
const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 });
|
|
60
|
+
document.body.append(left, right);
|
|
61
|
+
left.focus();
|
|
62
|
+
expect(document.activeElement).toBe(left);
|
|
63
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
64
|
+
s.moveFocus('right');
|
|
65
|
+
expect(document.activeElement).toBe(right);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
it('Should move focus to the left', async () => {
|
|
69
|
+
await usingAsync(new Injector(), async (i) => {
|
|
70
|
+
const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 });
|
|
71
|
+
const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 });
|
|
72
|
+
document.body.append(left, right);
|
|
73
|
+
right.focus();
|
|
74
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
75
|
+
s.moveFocus('left');
|
|
76
|
+
expect(document.activeElement).toBe(left);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
it('Should move focus down', async () => {
|
|
80
|
+
await usingAsync(new Injector(), async (i) => {
|
|
81
|
+
const top = createButton('top', { left: 0, top: 0, width: 50, height: 50 });
|
|
82
|
+
const bottom = createButton('bottom', { left: 0, top: 100, width: 50, height: 50 });
|
|
83
|
+
document.body.append(top, bottom);
|
|
84
|
+
top.focus();
|
|
85
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
86
|
+
s.moveFocus('down');
|
|
87
|
+
expect(document.activeElement).toBe(bottom);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
it('Should move focus up', async () => {
|
|
91
|
+
await usingAsync(new Injector(), async (i) => {
|
|
92
|
+
const top = createButton('top', { left: 0, top: 0, width: 50, height: 50 });
|
|
93
|
+
const bottom = createButton('bottom', { left: 0, top: 100, width: 50, height: 50 });
|
|
94
|
+
document.body.append(top, bottom);
|
|
95
|
+
bottom.focus();
|
|
96
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
97
|
+
s.moveFocus('up');
|
|
98
|
+
expect(document.activeElement).toBe(top);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
it('Should select nearest element by Euclidean distance', async () => {
|
|
102
|
+
await usingAsync(new Injector(), async (i) => {
|
|
103
|
+
const origin = createButton('origin', { left: 0, top: 0, width: 50, height: 50 });
|
|
104
|
+
const near = createButton('near', { left: 100, top: 10, width: 50, height: 50 });
|
|
105
|
+
const far = createButton('far', { left: 300, top: 10, width: 50, height: 50 });
|
|
106
|
+
document.body.append(origin, near, far);
|
|
107
|
+
origin.focus();
|
|
108
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
109
|
+
s.moveFocus('right');
|
|
110
|
+
expect(document.activeElement).toBe(near);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
it('Should be a no-op when no candidate exists in the direction', async () => {
|
|
114
|
+
await usingAsync(new Injector(), async (i) => {
|
|
115
|
+
const only = createButton('only', { left: 0, top: 0, width: 50, height: 50 });
|
|
116
|
+
document.body.append(only);
|
|
117
|
+
only.focus();
|
|
118
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
119
|
+
s.moveFocus('right');
|
|
120
|
+
expect(document.activeElement).toBe(only);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
it('Should call scrollIntoView on the target element', async () => {
|
|
124
|
+
await usingAsync(new Injector(), async (i) => {
|
|
125
|
+
const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 });
|
|
126
|
+
const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 });
|
|
127
|
+
document.body.append(left, right);
|
|
128
|
+
left.focus();
|
|
129
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
130
|
+
s.moveFocus('right');
|
|
131
|
+
expect(right.scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe('initial focus', () => {
|
|
136
|
+
it('Should focus first element when no element is focused', async () => {
|
|
137
|
+
await usingAsync(new Injector(), async (i) => {
|
|
138
|
+
const btn = createButton('first', { left: 0, top: 0, width: 50, height: 50 });
|
|
139
|
+
document.body.append(btn);
|
|
140
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
141
|
+
s.moveFocus('down');
|
|
142
|
+
expect(document.activeElement).toBe(btn);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
it('Should focus first element in first section when no element is focused', async () => {
|
|
146
|
+
await usingAsync(new Injector(), async (i) => {
|
|
147
|
+
const section = document.createElement('div');
|
|
148
|
+
section.setAttribute('data-nav-section', 'main');
|
|
149
|
+
const btn = createButton('first', { left: 0, top: 0, width: 50, height: 50 });
|
|
150
|
+
section.append(btn);
|
|
151
|
+
document.body.append(section);
|
|
152
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
153
|
+
s.moveFocus('down');
|
|
154
|
+
expect(document.activeElement).toBe(btn);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
it('Should be a no-op when there are no focusable elements', async () => {
|
|
158
|
+
await usingAsync(new Injector(), async (i) => {
|
|
159
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
160
|
+
s.moveFocus('down');
|
|
161
|
+
expect(document.activeElement).toBe(document.body);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe('keydown event handling', () => {
|
|
166
|
+
it('Should move focus on arrow key press', async () => {
|
|
167
|
+
await usingAsync(new Injector(), async (i) => {
|
|
168
|
+
const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 });
|
|
169
|
+
const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 });
|
|
170
|
+
document.body.append(left, right);
|
|
171
|
+
left.focus();
|
|
172
|
+
i.getInstance(SpatialNavigationService);
|
|
173
|
+
pressKey('ArrowRight');
|
|
174
|
+
expect(document.activeElement).toBe(right);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
it('Should activate focused element on Enter', async () => {
|
|
178
|
+
await usingAsync(new Injector(), async (i) => {
|
|
179
|
+
const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 });
|
|
180
|
+
const clickHandler = vi.fn();
|
|
181
|
+
btn.addEventListener('click', clickHandler);
|
|
182
|
+
document.body.append(btn);
|
|
183
|
+
btn.focus();
|
|
184
|
+
i.getInstance(SpatialNavigationService);
|
|
185
|
+
pressKey('Enter');
|
|
186
|
+
expect(clickHandler).toHaveBeenCalledTimes(1);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
it('Should not handle events when disabled', async () => {
|
|
190
|
+
await usingAsync(new Injector(), async (i) => {
|
|
191
|
+
const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 });
|
|
192
|
+
const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 });
|
|
193
|
+
document.body.append(left, right);
|
|
194
|
+
left.focus();
|
|
195
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
196
|
+
s.enabled.setValue(false);
|
|
197
|
+
pressKey('ArrowRight');
|
|
198
|
+
expect(document.activeElement).toBe(left);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
it('Should skip events that are already defaultPrevented', async () => {
|
|
202
|
+
await usingAsync(new Injector(), async (i) => {
|
|
203
|
+
const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 });
|
|
204
|
+
const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 });
|
|
205
|
+
document.body.append(left, right);
|
|
206
|
+
left.focus();
|
|
207
|
+
i.getInstance(SpatialNavigationService);
|
|
208
|
+
const preventer = (ev) => ev.preventDefault();
|
|
209
|
+
window.addEventListener('keydown', preventer, { capture: true });
|
|
210
|
+
try {
|
|
211
|
+
pressKey('ArrowRight');
|
|
212
|
+
expect(document.activeElement).toBe(left);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
window.removeEventListener('keydown', preventer, { capture: true });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe('input passthrough', () => {
|
|
221
|
+
it('Should not intercept arrow keys on text input when cursor is mid-text', async () => {
|
|
222
|
+
await usingAsync(new Injector(), async (i) => {
|
|
223
|
+
const input = document.createElement('input');
|
|
224
|
+
input.type = 'text';
|
|
225
|
+
input.value = 'hello';
|
|
226
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
227
|
+
const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 });
|
|
228
|
+
document.body.append(input, btn);
|
|
229
|
+
input.focus();
|
|
230
|
+
input.setSelectionRange(2, 2);
|
|
231
|
+
i.getInstance(SpatialNavigationService);
|
|
232
|
+
pressKey('ArrowRight');
|
|
233
|
+
expect(document.activeElement).toBe(input);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
it('Should escape text input with ArrowRight when cursor is at end', async () => {
|
|
237
|
+
await usingAsync(new Injector(), async (i) => {
|
|
238
|
+
const input = document.createElement('input');
|
|
239
|
+
input.type = 'text';
|
|
240
|
+
input.value = 'hello';
|
|
241
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
242
|
+
input.scrollIntoView = vi.fn();
|
|
243
|
+
const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 });
|
|
244
|
+
document.body.append(input, btn);
|
|
245
|
+
input.focus();
|
|
246
|
+
input.setSelectionRange(5, 5);
|
|
247
|
+
i.getInstance(SpatialNavigationService);
|
|
248
|
+
pressKey('ArrowRight');
|
|
249
|
+
expect(document.activeElement).toBe(btn);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
it('Should escape text input with ArrowLeft when cursor is at start', async () => {
|
|
253
|
+
await usingAsync(new Injector(), async (i) => {
|
|
254
|
+
const input = document.createElement('input');
|
|
255
|
+
input.type = 'text';
|
|
256
|
+
input.value = 'hello';
|
|
257
|
+
mockRect(input, { left: 100, top: 0, width: 200, height: 30 });
|
|
258
|
+
input.scrollIntoView = vi.fn();
|
|
259
|
+
const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 });
|
|
260
|
+
document.body.append(btn, input);
|
|
261
|
+
input.focus();
|
|
262
|
+
input.setSelectionRange(0, 0);
|
|
263
|
+
i.getInstance(SpatialNavigationService);
|
|
264
|
+
pressKey('ArrowLeft');
|
|
265
|
+
expect(document.activeElement).toBe(btn);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
it('Should not intercept arrow keys on textarea when cursor is mid-text', async () => {
|
|
269
|
+
await usingAsync(new Injector(), async (i) => {
|
|
270
|
+
const textarea = document.createElement('textarea');
|
|
271
|
+
textarea.value = 'line1\nline2';
|
|
272
|
+
mockRect(textarea, { left: 0, top: 0, width: 200, height: 100 });
|
|
273
|
+
const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 });
|
|
274
|
+
document.body.append(textarea, btn);
|
|
275
|
+
textarea.focus();
|
|
276
|
+
textarea.setSelectionRange(3, 3);
|
|
277
|
+
i.getInstance(SpatialNavigationService);
|
|
278
|
+
pressKey('ArrowDown');
|
|
279
|
+
expect(document.activeElement).toBe(textarea);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
it('Should not intercept arrow keys on select', async () => {
|
|
283
|
+
await usingAsync(new Injector(), async (i) => {
|
|
284
|
+
const select = document.createElement('select');
|
|
285
|
+
mockRect(select, { left: 0, top: 0, width: 200, height: 30 });
|
|
286
|
+
const btn = createButton('btn', { left: 0, top: 100, width: 50, height: 50 });
|
|
287
|
+
document.body.append(select, btn);
|
|
288
|
+
select.focus();
|
|
289
|
+
i.getInstance(SpatialNavigationService);
|
|
290
|
+
pressKey('ArrowDown');
|
|
291
|
+
expect(document.activeElement).toBe(select);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
it('Should not intercept arrow keys on contenteditable', async () => {
|
|
295
|
+
await usingAsync(new Injector(), async (i) => {
|
|
296
|
+
const div = document.createElement('div');
|
|
297
|
+
div.contentEditable = 'true';
|
|
298
|
+
div.tabIndex = 0;
|
|
299
|
+
mockRect(div, { left: 0, top: 0, width: 200, height: 100 });
|
|
300
|
+
const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 });
|
|
301
|
+
document.body.append(div, btn);
|
|
302
|
+
div.focus();
|
|
303
|
+
i.getInstance(SpatialNavigationService);
|
|
304
|
+
pressKey('ArrowRight');
|
|
305
|
+
expect(document.activeElement).toBe(div);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
it('Should not intercept arrow keys on children of contenteditable', async () => {
|
|
309
|
+
await usingAsync(new Injector(), async (i) => {
|
|
310
|
+
const div = document.createElement('div');
|
|
311
|
+
div.contentEditable = 'true';
|
|
312
|
+
div.tabIndex = 0;
|
|
313
|
+
mockRect(div, { left: 0, top: 0, width: 200, height: 100 });
|
|
314
|
+
const span = document.createElement('span');
|
|
315
|
+
span.tabIndex = 0;
|
|
316
|
+
span.scrollIntoView = vi.fn();
|
|
317
|
+
mockRect(span, { left: 10, top: 10, width: 50, height: 20 });
|
|
318
|
+
div.append(span);
|
|
319
|
+
const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 });
|
|
320
|
+
document.body.append(div, btn);
|
|
321
|
+
span.focus();
|
|
322
|
+
i.getInstance(SpatialNavigationService);
|
|
323
|
+
pressKey('ArrowRight');
|
|
324
|
+
expect(document.activeElement).toBe(span);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
it('Should not intercept Enter on children of contenteditable', async () => {
|
|
328
|
+
await usingAsync(new Injector(), async (i) => {
|
|
329
|
+
const div = document.createElement('div');
|
|
330
|
+
div.contentEditable = 'true';
|
|
331
|
+
div.tabIndex = 0;
|
|
332
|
+
mockRect(div, { left: 0, top: 0, width: 200, height: 100 });
|
|
333
|
+
const span = document.createElement('span');
|
|
334
|
+
span.tabIndex = 0;
|
|
335
|
+
mockRect(span, { left: 10, top: 10, width: 50, height: 20 });
|
|
336
|
+
div.append(span);
|
|
337
|
+
const clickHandler = vi.fn();
|
|
338
|
+
span.addEventListener('click', clickHandler);
|
|
339
|
+
document.body.append(div);
|
|
340
|
+
span.focus();
|
|
341
|
+
i.getInstance(SpatialNavigationService);
|
|
342
|
+
pressKey('Enter');
|
|
343
|
+
expect(clickHandler).not.toHaveBeenCalled();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
it('Should intercept arrow keys on button-type input', async () => {
|
|
347
|
+
await usingAsync(new Injector(), async (i) => {
|
|
348
|
+
const input = document.createElement('input');
|
|
349
|
+
input.type = 'button';
|
|
350
|
+
mockRect(input, { left: 0, top: 0, width: 50, height: 30 });
|
|
351
|
+
input.scrollIntoView = vi.fn();
|
|
352
|
+
const btn = createButton('btn', { left: 100, top: 0, width: 50, height: 50 });
|
|
353
|
+
document.body.append(input, btn);
|
|
354
|
+
input.focus();
|
|
355
|
+
i.getInstance(SpatialNavigationService);
|
|
356
|
+
pressKey('ArrowRight');
|
|
357
|
+
expect(document.activeElement).toBe(btn);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
it('Should not intercept Left/Right on range input (slider adjustment)', async () => {
|
|
361
|
+
await usingAsync(new Injector(), async (i) => {
|
|
362
|
+
const input = document.createElement('input');
|
|
363
|
+
input.type = 'range';
|
|
364
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
365
|
+
const btn = createButton('btn', { left: 0, top: 100, width: 50, height: 50 });
|
|
366
|
+
document.body.append(input, btn);
|
|
367
|
+
input.focus();
|
|
368
|
+
i.getInstance(SpatialNavigationService);
|
|
369
|
+
pressKey('ArrowLeft');
|
|
370
|
+
expect(document.activeElement).toBe(input);
|
|
371
|
+
pressKey('ArrowRight');
|
|
372
|
+
expect(document.activeElement).toBe(input);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
it('Should intercept Up/Down on range input for spatial navigation', async () => {
|
|
376
|
+
await usingAsync(new Injector(), async (i) => {
|
|
377
|
+
const input = document.createElement('input');
|
|
378
|
+
input.type = 'range';
|
|
379
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
380
|
+
input.scrollIntoView = vi.fn();
|
|
381
|
+
const btn = createButton('btn', { left: 0, top: 100, width: 50, height: 50 });
|
|
382
|
+
document.body.append(input, btn);
|
|
383
|
+
input.focus();
|
|
384
|
+
i.getInstance(SpatialNavigationService);
|
|
385
|
+
pressKey('ArrowDown');
|
|
386
|
+
expect(document.activeElement).toBe(btn);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
it('Should intercept arrow keys on color input', async () => {
|
|
390
|
+
await usingAsync(new Injector(), async (i) => {
|
|
391
|
+
const input = document.createElement('input');
|
|
392
|
+
input.type = 'color';
|
|
393
|
+
mockRect(input, { left: 0, top: 0, width: 50, height: 50 });
|
|
394
|
+
input.scrollIntoView = vi.fn();
|
|
395
|
+
const btn = createButton('btn', { left: 100, top: 0, width: 50, height: 50 });
|
|
396
|
+
document.body.append(input, btn);
|
|
397
|
+
input.focus();
|
|
398
|
+
i.getInstance(SpatialNavigationService);
|
|
399
|
+
pressKey('ArrowRight');
|
|
400
|
+
expect(document.activeElement).toBe(btn);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
it('Should intercept arrow keys on file input', async () => {
|
|
404
|
+
await usingAsync(new Injector(), async (i) => {
|
|
405
|
+
const input = document.createElement('input');
|
|
406
|
+
input.type = 'file';
|
|
407
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
408
|
+
input.scrollIntoView = vi.fn();
|
|
409
|
+
const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 });
|
|
410
|
+
document.body.append(input, btn);
|
|
411
|
+
input.focus();
|
|
412
|
+
i.getInstance(SpatialNavigationService);
|
|
413
|
+
pressKey('ArrowRight');
|
|
414
|
+
expect(document.activeElement).toBe(btn);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
it('Should not intercept arrow keys on radio input', async () => {
|
|
418
|
+
await usingAsync(new Injector(), async (i) => {
|
|
419
|
+
const radio = document.createElement('input');
|
|
420
|
+
radio.type = 'radio';
|
|
421
|
+
radio.name = 'group';
|
|
422
|
+
mockRect(radio, { left: 0, top: 0, width: 20, height: 20 });
|
|
423
|
+
const btn = createButton('btn', { left: 100, top: 0, width: 50, height: 50 });
|
|
424
|
+
document.body.append(radio, btn);
|
|
425
|
+
radio.focus();
|
|
426
|
+
i.getInstance(SpatialNavigationService);
|
|
427
|
+
pressKey('ArrowDown');
|
|
428
|
+
expect(document.activeElement).toBe(radio);
|
|
429
|
+
pressKey('ArrowRight');
|
|
430
|
+
expect(document.activeElement).toBe(radio);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
it('Should not intercept Up/Down on number input (increment/decrement)', async () => {
|
|
434
|
+
await usingAsync(new Injector(), async (i) => {
|
|
435
|
+
const input = document.createElement('input');
|
|
436
|
+
input.type = 'number';
|
|
437
|
+
input.value = '5';
|
|
438
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
439
|
+
const btn = createButton('btn', { left: 0, top: 100, width: 50, height: 50 });
|
|
440
|
+
document.body.append(input, btn);
|
|
441
|
+
input.focus();
|
|
442
|
+
i.getInstance(SpatialNavigationService);
|
|
443
|
+
pressKey('ArrowUp');
|
|
444
|
+
expect(document.activeElement).toBe(input);
|
|
445
|
+
pressKey('ArrowDown');
|
|
446
|
+
expect(document.activeElement).toBe(input);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
it('Should intercept Left/Right on number input for spatial navigation', async () => {
|
|
450
|
+
await usingAsync(new Injector(), async (i) => {
|
|
451
|
+
const input = document.createElement('input');
|
|
452
|
+
input.type = 'number';
|
|
453
|
+
input.value = '5';
|
|
454
|
+
mockRect(input, { left: 100, top: 0, width: 200, height: 30 });
|
|
455
|
+
input.scrollIntoView = vi.fn();
|
|
456
|
+
const btnLeft = createButton('btn-left', { left: 0, top: 0, width: 50, height: 30 });
|
|
457
|
+
const btnRight = createButton('btn-right', { left: 400, top: 0, width: 50, height: 30 });
|
|
458
|
+
document.body.append(btnLeft, input, btnRight);
|
|
459
|
+
input.focus();
|
|
460
|
+
i.getInstance(SpatialNavigationService);
|
|
461
|
+
pressKey('ArrowRight');
|
|
462
|
+
expect(document.activeElement).toBe(btnRight);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
it('Should not intercept arrow keys on date input', async () => {
|
|
466
|
+
await usingAsync(new Injector(), async (i) => {
|
|
467
|
+
const input = document.createElement('input');
|
|
468
|
+
input.type = 'date';
|
|
469
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
470
|
+
const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 });
|
|
471
|
+
document.body.append(input, btn);
|
|
472
|
+
input.focus();
|
|
473
|
+
i.getInstance(SpatialNavigationService);
|
|
474
|
+
pressKey('ArrowRight');
|
|
475
|
+
expect(document.activeElement).toBe(input);
|
|
476
|
+
pressKey('ArrowUp');
|
|
477
|
+
expect(document.activeElement).toBe(input);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
it('Should not intercept arrow keys on time input', async () => {
|
|
481
|
+
await usingAsync(new Injector(), async (i) => {
|
|
482
|
+
const input = document.createElement('input');
|
|
483
|
+
input.type = 'time';
|
|
484
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
485
|
+
const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 });
|
|
486
|
+
document.body.append(input, btn);
|
|
487
|
+
input.focus();
|
|
488
|
+
i.getInstance(SpatialNavigationService);
|
|
489
|
+
pressKey('ArrowDown');
|
|
490
|
+
expect(document.activeElement).toBe(input);
|
|
491
|
+
pressKey('ArrowLeft');
|
|
492
|
+
expect(document.activeElement).toBe(input);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
it('Should escape empty text input on any arrow key', async () => {
|
|
496
|
+
await usingAsync(new Injector(), async (i) => {
|
|
497
|
+
const input = document.createElement('input');
|
|
498
|
+
input.type = 'text';
|
|
499
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
500
|
+
input.scrollIntoView = vi.fn();
|
|
501
|
+
const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 });
|
|
502
|
+
document.body.append(input, btn);
|
|
503
|
+
input.focus();
|
|
504
|
+
i.getInstance(SpatialNavigationService);
|
|
505
|
+
pressKey('ArrowRight');
|
|
506
|
+
expect(document.activeElement).toBe(btn);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
it('Should not intercept Enter on text input', async () => {
|
|
510
|
+
await usingAsync(new Injector(), async (i) => {
|
|
511
|
+
const input = document.createElement('input');
|
|
512
|
+
input.type = 'text';
|
|
513
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
514
|
+
const clickHandler = vi.fn();
|
|
515
|
+
input.addEventListener('click', clickHandler);
|
|
516
|
+
document.body.append(input);
|
|
517
|
+
input.focus();
|
|
518
|
+
i.getInstance(SpatialNavigationService);
|
|
519
|
+
pressKey('Enter');
|
|
520
|
+
expect(clickHandler).not.toHaveBeenCalled();
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
it('Should not intercept Enter on textarea', async () => {
|
|
524
|
+
await usingAsync(new Injector(), async (i) => {
|
|
525
|
+
const textarea = document.createElement('textarea');
|
|
526
|
+
textarea.value = 'hello';
|
|
527
|
+
mockRect(textarea, { left: 0, top: 0, width: 200, height: 100 });
|
|
528
|
+
const clickHandler = vi.fn();
|
|
529
|
+
textarea.addEventListener('click', clickHandler);
|
|
530
|
+
document.body.append(textarea);
|
|
531
|
+
textarea.focus();
|
|
532
|
+
i.getInstance(SpatialNavigationService);
|
|
533
|
+
pressKey('Enter');
|
|
534
|
+
expect(clickHandler).not.toHaveBeenCalled();
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
it('Should activate Enter on button-type input', async () => {
|
|
538
|
+
await usingAsync(new Injector(), async (i) => {
|
|
539
|
+
const input = document.createElement('input');
|
|
540
|
+
input.type = 'button';
|
|
541
|
+
mockRect(input, { left: 0, top: 0, width: 50, height: 30 });
|
|
542
|
+
const clickHandler = vi.fn();
|
|
543
|
+
input.addEventListener('click', clickHandler);
|
|
544
|
+
document.body.append(input);
|
|
545
|
+
input.focus();
|
|
546
|
+
i.getInstance(SpatialNavigationService);
|
|
547
|
+
pressKey('Enter');
|
|
548
|
+
expect(clickHandler).toHaveBeenCalledTimes(1);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
describe('data-spatial-nav-passthrough', () => {
|
|
553
|
+
it('Should not intercept arrow keys inside a passthrough container', async () => {
|
|
554
|
+
await usingAsync(new Injector(), async (i) => {
|
|
555
|
+
const container = document.createElement('div');
|
|
556
|
+
container.setAttribute('data-spatial-nav-passthrough', '');
|
|
557
|
+
const inner = document.createElement('input');
|
|
558
|
+
inner.type = 'button';
|
|
559
|
+
mockRect(inner, { left: 0, top: 0, width: 50, height: 30 });
|
|
560
|
+
container.append(inner);
|
|
561
|
+
const btn = createButton('btn', { left: 100, top: 0, width: 50, height: 50 });
|
|
562
|
+
document.body.append(container, btn);
|
|
563
|
+
inner.focus();
|
|
564
|
+
i.getInstance(SpatialNavigationService);
|
|
565
|
+
pressKey('ArrowRight');
|
|
566
|
+
expect(document.activeElement).toBe(inner);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
it('Should not intercept Enter inside a passthrough container', async () => {
|
|
570
|
+
await usingAsync(new Injector(), async (i) => {
|
|
571
|
+
const container = document.createElement('div');
|
|
572
|
+
container.setAttribute('data-spatial-nav-passthrough', '');
|
|
573
|
+
const inner = createButton('inner', { left: 0, top: 0, width: 50, height: 50 });
|
|
574
|
+
const clickHandler = vi.fn();
|
|
575
|
+
inner.addEventListener('click', clickHandler);
|
|
576
|
+
container.append(inner);
|
|
577
|
+
document.body.append(container);
|
|
578
|
+
inner.focus();
|
|
579
|
+
i.getInstance(SpatialNavigationService);
|
|
580
|
+
pressKey('Enter');
|
|
581
|
+
expect(clickHandler).not.toHaveBeenCalled();
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
it('Should still intercept keys outside a passthrough container', async () => {
|
|
585
|
+
await usingAsync(new Injector(), async (i) => {
|
|
586
|
+
const container = document.createElement('div');
|
|
587
|
+
container.setAttribute('data-spatial-nav-passthrough', '');
|
|
588
|
+
const inner = document.createElement('input');
|
|
589
|
+
inner.type = 'button';
|
|
590
|
+
mockRect(inner, { left: 200, top: 0, width: 50, height: 30 });
|
|
591
|
+
container.append(inner);
|
|
592
|
+
const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 });
|
|
593
|
+
const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 });
|
|
594
|
+
document.body.append(left, right, container);
|
|
595
|
+
left.focus();
|
|
596
|
+
i.getInstance(SpatialNavigationService);
|
|
597
|
+
pressKey('ArrowRight');
|
|
598
|
+
expect(document.activeElement).toBe(right);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
describe('section navigation', () => {
|
|
603
|
+
it('Should scope navigation within the active section', async () => {
|
|
604
|
+
await usingAsync(new Injector(), async (i) => {
|
|
605
|
+
const section1 = document.createElement('div');
|
|
606
|
+
section1.setAttribute('data-nav-section', 'sidebar');
|
|
607
|
+
mockRect(section1, { left: 0, top: 0, width: 200, height: 400 });
|
|
608
|
+
const btn1 = createButton('sidebar-btn', { left: 10, top: 10, width: 50, height: 50 });
|
|
609
|
+
section1.append(btn1);
|
|
610
|
+
const section2 = document.createElement('div');
|
|
611
|
+
section2.setAttribute('data-nav-section', 'main');
|
|
612
|
+
mockRect(section2, { left: 250, top: 0, width: 500, height: 400 });
|
|
613
|
+
const btn2 = createButton('main-btn1', { left: 260, top: 10, width: 50, height: 50 });
|
|
614
|
+
const btn3 = createButton('main-btn2', { left: 260, top: 100, width: 50, height: 50 });
|
|
615
|
+
section2.append(btn2, btn3);
|
|
616
|
+
document.body.append(section1, section2);
|
|
617
|
+
btn2.focus();
|
|
618
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
619
|
+
s.moveFocus('down');
|
|
620
|
+
expect(document.activeElement).toBe(btn3);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
it('Should update activeSection when focus moves', async () => {
|
|
624
|
+
await usingAsync(new Injector(), async (i) => {
|
|
625
|
+
const section = document.createElement('div');
|
|
626
|
+
section.setAttribute('data-nav-section', 'main');
|
|
627
|
+
const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 });
|
|
628
|
+
section.append(btn);
|
|
629
|
+
document.body.append(section);
|
|
630
|
+
btn.focus();
|
|
631
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
632
|
+
s.moveFocus('down');
|
|
633
|
+
expect(s.activeSection.getValue()).toBe('main');
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
describe('cross-section navigation', () => {
|
|
638
|
+
it('Should navigate to adjacent section when no candidate in current section', async () => {
|
|
639
|
+
await usingAsync(new Injector(), async (i) => {
|
|
640
|
+
const section1 = document.createElement('div');
|
|
641
|
+
section1.setAttribute('data-nav-section', 'left');
|
|
642
|
+
mockRect(section1, { left: 0, top: 0, width: 200, height: 400 });
|
|
643
|
+
const btn1 = createButton('left-btn', { left: 10, top: 10, width: 50, height: 50 });
|
|
644
|
+
section1.append(btn1);
|
|
645
|
+
const section2 = document.createElement('div');
|
|
646
|
+
section2.setAttribute('data-nav-section', 'right');
|
|
647
|
+
mockRect(section2, { left: 250, top: 0, width: 200, height: 400 });
|
|
648
|
+
const btn2 = createButton('right-btn', { left: 260, top: 10, width: 50, height: 50 });
|
|
649
|
+
btn2.scrollIntoView = vi.fn();
|
|
650
|
+
section2.append(btn2);
|
|
651
|
+
document.body.append(section1, section2);
|
|
652
|
+
btn1.focus();
|
|
653
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
654
|
+
s.moveFocus('right');
|
|
655
|
+
expect(document.activeElement).toBe(btn2);
|
|
656
|
+
expect(s.activeSection.getValue()).toBe('right');
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
it('Should not navigate cross-section when disabled', async () => {
|
|
660
|
+
await usingAsync(new Injector(), async (i) => {
|
|
661
|
+
configureSpatialNavigation(i, { crossSectionNavigation: false });
|
|
662
|
+
const section1 = document.createElement('div');
|
|
663
|
+
section1.setAttribute('data-nav-section', 'left');
|
|
664
|
+
mockRect(section1, { left: 0, top: 0, width: 200, height: 400 });
|
|
665
|
+
const btn1 = createButton('left-btn', { left: 10, top: 10, width: 50, height: 50 });
|
|
666
|
+
section1.append(btn1);
|
|
667
|
+
const section2 = document.createElement('div');
|
|
668
|
+
section2.setAttribute('data-nav-section', 'right');
|
|
669
|
+
mockRect(section2, { left: 250, top: 0, width: 200, height: 400 });
|
|
670
|
+
const btn2 = createButton('right-btn', { left: 260, top: 10, width: 50, height: 50 });
|
|
671
|
+
section2.append(btn2);
|
|
672
|
+
document.body.append(section1, section2);
|
|
673
|
+
btn1.focus();
|
|
674
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
675
|
+
s.moveFocus('right');
|
|
676
|
+
expect(document.activeElement).toBe(btn1);
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
describe('focus memory', () => {
|
|
681
|
+
it('Should remember and restore focus when returning to a section', async () => {
|
|
682
|
+
await usingAsync(new Injector(), async (i) => {
|
|
683
|
+
const section1 = document.createElement('div');
|
|
684
|
+
section1.setAttribute('data-nav-section', 'left');
|
|
685
|
+
mockRect(section1, { left: 0, top: 0, width: 200, height: 400 });
|
|
686
|
+
const btn1a = createButton('left-btn-a', { left: 10, top: 10, width: 50, height: 50 });
|
|
687
|
+
const btn1b = createButton('left-btn-b', { left: 10, top: 100, width: 50, height: 50 });
|
|
688
|
+
section1.append(btn1a, btn1b);
|
|
689
|
+
const section2 = document.createElement('div');
|
|
690
|
+
section2.setAttribute('data-nav-section', 'right');
|
|
691
|
+
mockRect(section2, { left: 250, top: 0, width: 200, height: 400 });
|
|
692
|
+
const btn2 = createButton('right-btn', { left: 260, top: 100, width: 50, height: 50 });
|
|
693
|
+
btn2.scrollIntoView = vi.fn();
|
|
694
|
+
section2.append(btn2);
|
|
695
|
+
document.body.append(section1, section2);
|
|
696
|
+
btn1b.focus();
|
|
697
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
698
|
+
// Navigate away from section1
|
|
699
|
+
s.moveFocus('right');
|
|
700
|
+
expect(document.activeElement).toBe(btn2);
|
|
701
|
+
// Navigate back to section1 - should restore focus to btn1b
|
|
702
|
+
btn1b.scrollIntoView = vi.fn();
|
|
703
|
+
s.moveFocus('left');
|
|
704
|
+
expect(document.activeElement).toBe(btn1b);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
it('Should fall back to nearest element when remembered element is removed', async () => {
|
|
708
|
+
await usingAsync(new Injector(), async (i) => {
|
|
709
|
+
const section1 = document.createElement('div');
|
|
710
|
+
section1.setAttribute('data-nav-section', 'left');
|
|
711
|
+
mockRect(section1, { left: 0, top: 0, width: 200, height: 400 });
|
|
712
|
+
const btn1 = createButton('left-btn', { left: 10, top: 10, width: 50, height: 50 });
|
|
713
|
+
section1.append(btn1);
|
|
714
|
+
const section2 = document.createElement('div');
|
|
715
|
+
section2.setAttribute('data-nav-section', 'right');
|
|
716
|
+
mockRect(section2, { left: 250, top: 0, width: 200, height: 400 });
|
|
717
|
+
const btn2a = createButton('right-btn-a', { left: 260, top: 10, width: 50, height: 50 });
|
|
718
|
+
btn2a.scrollIntoView = vi.fn();
|
|
719
|
+
const btn2b = createButton('right-btn-b', { left: 260, top: 100, width: 50, height: 50 });
|
|
720
|
+
btn2b.scrollIntoView = vi.fn();
|
|
721
|
+
section2.append(btn2a, btn2b);
|
|
722
|
+
document.body.append(section1, section2);
|
|
723
|
+
// Focus btn2a, navigate to section1
|
|
724
|
+
btn2a.focus();
|
|
725
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
726
|
+
s.moveFocus('left');
|
|
727
|
+
// Remove btn2a from DOM
|
|
728
|
+
btn2a.remove();
|
|
729
|
+
// Navigate back - should focus btn2b (nearest remaining)
|
|
730
|
+
s.moveFocus('right');
|
|
731
|
+
expect(document.activeElement).toBe(btn2b);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
it('Should fall back to nearest element when remembered element becomes disabled', async () => {
|
|
735
|
+
await usingAsync(new Injector(), async (i) => {
|
|
736
|
+
const section1 = document.createElement('div');
|
|
737
|
+
section1.setAttribute('data-nav-section', 'left');
|
|
738
|
+
mockRect(section1, { left: 0, top: 0, width: 200, height: 400 });
|
|
739
|
+
const btn1 = createButton('left-btn', { left: 10, top: 10, width: 50, height: 50 });
|
|
740
|
+
section1.append(btn1);
|
|
741
|
+
const section2 = document.createElement('div');
|
|
742
|
+
section2.setAttribute('data-nav-section', 'right');
|
|
743
|
+
mockRect(section2, { left: 250, top: 0, width: 200, height: 400 });
|
|
744
|
+
const btn2a = createButton('right-btn-a', { left: 260, top: 10, width: 50, height: 50 });
|
|
745
|
+
btn2a.scrollIntoView = vi.fn();
|
|
746
|
+
const btn2b = createButton('right-btn-b', { left: 260, top: 100, width: 50, height: 50 });
|
|
747
|
+
btn2b.scrollIntoView = vi.fn();
|
|
748
|
+
section2.append(btn2a, btn2b);
|
|
749
|
+
document.body.append(section1, section2);
|
|
750
|
+
btn2a.focus();
|
|
751
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
752
|
+
s.moveFocus('left');
|
|
753
|
+
// Disable btn2a after navigating away
|
|
754
|
+
btn2a.disabled = true;
|
|
755
|
+
// Navigate back - should skip disabled btn2a and focus btn2b
|
|
756
|
+
s.moveFocus('right');
|
|
757
|
+
expect(document.activeElement).toBe(btn2b);
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
describe('enabled toggle', () => {
|
|
762
|
+
it('Should stop handling keys when disabled', async () => {
|
|
763
|
+
await usingAsync(new Injector(), async (i) => {
|
|
764
|
+
const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 });
|
|
765
|
+
const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 });
|
|
766
|
+
document.body.append(left, right);
|
|
767
|
+
left.focus();
|
|
768
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
769
|
+
s.enabled.setValue(false);
|
|
770
|
+
pressKey('ArrowRight');
|
|
771
|
+
expect(document.activeElement).toBe(left);
|
|
772
|
+
s.enabled.setValue(true);
|
|
773
|
+
pressKey('ArrowRight');
|
|
774
|
+
expect(document.activeElement).toBe(right);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
describe('disposal', () => {
|
|
779
|
+
it('Should remove keydown listener on dispose', async () => {
|
|
780
|
+
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
|
781
|
+
await usingAsync(new Injector(), async (i) => {
|
|
782
|
+
i.getInstance(SpatialNavigationService);
|
|
783
|
+
});
|
|
784
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
|
|
785
|
+
});
|
|
786
|
+
it('Should not handle events after disposal', async () => {
|
|
787
|
+
const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 });
|
|
788
|
+
const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 });
|
|
789
|
+
document.body.append(left, right);
|
|
790
|
+
left.focus();
|
|
791
|
+
await usingAsync(new Injector(), async (i) => {
|
|
792
|
+
i.getInstance(SpatialNavigationService);
|
|
793
|
+
});
|
|
794
|
+
pressKey('ArrowRight');
|
|
795
|
+
expect(document.activeElement).toBe(left);
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
describe('backspace and escape', () => {
|
|
799
|
+
it('Should call history.back() on Backspace when configured', async () => {
|
|
800
|
+
await usingAsync(new Injector(), async (i) => {
|
|
801
|
+
configureSpatialNavigation(i, { backspaceGoesBack: true });
|
|
802
|
+
const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 });
|
|
803
|
+
document.body.append(btn);
|
|
804
|
+
btn.focus();
|
|
805
|
+
const backSpy = vi.spyOn(history, 'back').mockImplementation(() => { });
|
|
806
|
+
i.getInstance(SpatialNavigationService);
|
|
807
|
+
pressKey('Backspace');
|
|
808
|
+
expect(backSpy).toHaveBeenCalledTimes(1);
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
it('Should not call history.back() on Backspace by default', async () => {
|
|
812
|
+
await usingAsync(new Injector(), async (i) => {
|
|
813
|
+
const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 });
|
|
814
|
+
document.body.append(btn);
|
|
815
|
+
btn.focus();
|
|
816
|
+
const backSpy = vi.spyOn(history, 'back').mockImplementation(() => { });
|
|
817
|
+
i.getInstance(SpatialNavigationService);
|
|
818
|
+
pressKey('Backspace');
|
|
819
|
+
expect(backSpy).not.toHaveBeenCalled();
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
it('Should not call history.back() on Backspace when focused on a text input', async () => {
|
|
823
|
+
await usingAsync(new Injector(), async (i) => {
|
|
824
|
+
configureSpatialNavigation(i, { backspaceGoesBack: true });
|
|
825
|
+
const input = document.createElement('input');
|
|
826
|
+
input.type = 'text';
|
|
827
|
+
input.value = 'hello';
|
|
828
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
829
|
+
document.body.append(input);
|
|
830
|
+
input.focus();
|
|
831
|
+
const backSpy = vi.spyOn(history, 'back').mockImplementation(() => { });
|
|
832
|
+
i.getInstance(SpatialNavigationService);
|
|
833
|
+
pressKey('Backspace');
|
|
834
|
+
expect(backSpy).not.toHaveBeenCalled();
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
it('Should not call history.back() on Backspace when focused on a textarea', async () => {
|
|
838
|
+
await usingAsync(new Injector(), async (i) => {
|
|
839
|
+
configureSpatialNavigation(i, { backspaceGoesBack: true });
|
|
840
|
+
const textarea = document.createElement('textarea');
|
|
841
|
+
textarea.value = 'some text';
|
|
842
|
+
mockRect(textarea, { left: 0, top: 0, width: 200, height: 100 });
|
|
843
|
+
document.body.append(textarea);
|
|
844
|
+
textarea.focus();
|
|
845
|
+
const backSpy = vi.spyOn(history, 'back').mockImplementation(() => { });
|
|
846
|
+
i.getInstance(SpatialNavigationService);
|
|
847
|
+
pressKey('Backspace');
|
|
848
|
+
expect(backSpy).not.toHaveBeenCalled();
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
it('Should move to parent section on Escape when configured', async () => {
|
|
852
|
+
await usingAsync(new Injector(), async (i) => {
|
|
853
|
+
configureSpatialNavigation(i, { escapeGoesToParentSection: true });
|
|
854
|
+
const outer = document.createElement('div');
|
|
855
|
+
outer.setAttribute('data-nav-section', 'outer');
|
|
856
|
+
const outerBtn = createButton('outer-btn', { left: 10, top: 10, width: 50, height: 50 });
|
|
857
|
+
const inner = document.createElement('div');
|
|
858
|
+
inner.setAttribute('data-nav-section', 'inner');
|
|
859
|
+
const innerBtn = createButton('inner-btn', { left: 10, top: 200, width: 50, height: 50 });
|
|
860
|
+
inner.append(innerBtn);
|
|
861
|
+
outer.append(outerBtn, inner);
|
|
862
|
+
document.body.append(outer);
|
|
863
|
+
innerBtn.focus();
|
|
864
|
+
i.getInstance(SpatialNavigationService);
|
|
865
|
+
pressKey('Escape');
|
|
866
|
+
expect(document.activeElement).toBe(outerBtn);
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
it('Should blur a range input on Escape', async () => {
|
|
870
|
+
await usingAsync(new Injector(), async (i) => {
|
|
871
|
+
const input = document.createElement('input');
|
|
872
|
+
input.type = 'range';
|
|
873
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
874
|
+
document.body.append(input);
|
|
875
|
+
input.focus();
|
|
876
|
+
expect(document.activeElement).toBe(input);
|
|
877
|
+
i.getInstance(SpatialNavigationService);
|
|
878
|
+
pressKey('Escape');
|
|
879
|
+
expect(document.activeElement).toBe(document.body);
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
it('Should blur a radio input on Escape', async () => {
|
|
883
|
+
await usingAsync(new Injector(), async (i) => {
|
|
884
|
+
const radio = document.createElement('input');
|
|
885
|
+
radio.type = 'radio';
|
|
886
|
+
radio.name = 'group';
|
|
887
|
+
mockRect(radio, { left: 0, top: 0, width: 20, height: 20 });
|
|
888
|
+
document.body.append(radio);
|
|
889
|
+
radio.focus();
|
|
890
|
+
expect(document.activeElement).toBe(radio);
|
|
891
|
+
i.getInstance(SpatialNavigationService);
|
|
892
|
+
pressKey('Escape');
|
|
893
|
+
expect(document.activeElement).toBe(document.body);
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
it('Should blur a date input on Escape', async () => {
|
|
897
|
+
await usingAsync(new Injector(), async (i) => {
|
|
898
|
+
const input = document.createElement('input');
|
|
899
|
+
input.type = 'date';
|
|
900
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
901
|
+
document.body.append(input);
|
|
902
|
+
input.focus();
|
|
903
|
+
expect(document.activeElement).toBe(input);
|
|
904
|
+
i.getInstance(SpatialNavigationService);
|
|
905
|
+
pressKey('Escape');
|
|
906
|
+
expect(document.activeElement).toBe(document.body);
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
it('Should blur a time input on Escape', async () => {
|
|
910
|
+
await usingAsync(new Injector(), async (i) => {
|
|
911
|
+
const input = document.createElement('input');
|
|
912
|
+
input.type = 'time';
|
|
913
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
914
|
+
document.body.append(input);
|
|
915
|
+
input.focus();
|
|
916
|
+
expect(document.activeElement).toBe(input);
|
|
917
|
+
i.getInstance(SpatialNavigationService);
|
|
918
|
+
pressKey('Escape');
|
|
919
|
+
expect(document.activeElement).toBe(document.body);
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
it('Should blur a number input on Escape', async () => {
|
|
923
|
+
await usingAsync(new Injector(), async (i) => {
|
|
924
|
+
const input = document.createElement('input');
|
|
925
|
+
input.type = 'number';
|
|
926
|
+
input.value = '5';
|
|
927
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
928
|
+
document.body.append(input);
|
|
929
|
+
input.focus();
|
|
930
|
+
expect(document.activeElement).toBe(input);
|
|
931
|
+
i.getInstance(SpatialNavigationService);
|
|
932
|
+
pressKey('Escape');
|
|
933
|
+
expect(document.activeElement).toBe(document.body);
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
it('Should blur a text input on Escape', async () => {
|
|
937
|
+
await usingAsync(new Injector(), async (i) => {
|
|
938
|
+
const input = document.createElement('input');
|
|
939
|
+
input.type = 'text';
|
|
940
|
+
input.value = 'hello';
|
|
941
|
+
mockRect(input, { left: 0, top: 0, width: 200, height: 30 });
|
|
942
|
+
document.body.append(input);
|
|
943
|
+
input.focus();
|
|
944
|
+
expect(document.activeElement).toBe(input);
|
|
945
|
+
i.getInstance(SpatialNavigationService);
|
|
946
|
+
pressKey('Escape');
|
|
947
|
+
expect(document.activeElement).toBe(document.body);
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
it('Should blur a textarea on Escape', async () => {
|
|
951
|
+
await usingAsync(new Injector(), async (i) => {
|
|
952
|
+
const textarea = document.createElement('textarea');
|
|
953
|
+
textarea.value = 'hello';
|
|
954
|
+
mockRect(textarea, { left: 0, top: 0, width: 200, height: 100 });
|
|
955
|
+
document.body.append(textarea);
|
|
956
|
+
textarea.focus();
|
|
957
|
+
expect(document.activeElement).toBe(textarea);
|
|
958
|
+
i.getInstance(SpatialNavigationService);
|
|
959
|
+
pressKey('Escape');
|
|
960
|
+
expect(document.activeElement).toBe(document.body);
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
it('Should not blur a button on Escape', async () => {
|
|
964
|
+
await usingAsync(new Injector(), async (i) => {
|
|
965
|
+
const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 });
|
|
966
|
+
document.body.append(btn);
|
|
967
|
+
btn.focus();
|
|
968
|
+
expect(document.activeElement).toBe(btn);
|
|
969
|
+
i.getInstance(SpatialNavigationService);
|
|
970
|
+
pressKey('Escape');
|
|
971
|
+
expect(document.activeElement).toBe(btn);
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
describe('data-spatial-nav-target', () => {
|
|
976
|
+
it('Should treat elements with data-spatial-nav-target as focusable candidates', async () => {
|
|
977
|
+
await usingAsync(new Injector(), async (i) => {
|
|
978
|
+
const origin = createButton('origin', { left: 0, top: 0, width: 50, height: 50 });
|
|
979
|
+
const target = document.createElement('div');
|
|
980
|
+
target.setAttribute('data-spatial-nav-target', '');
|
|
981
|
+
target.tabIndex = -1;
|
|
982
|
+
target.id = 'nav-target';
|
|
983
|
+
mockRect(target, { left: 100, top: 0, width: 50, height: 50 });
|
|
984
|
+
target.scrollIntoView = vi.fn();
|
|
985
|
+
document.body.append(origin, target);
|
|
986
|
+
origin.focus();
|
|
987
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
988
|
+
s.moveFocus('right');
|
|
989
|
+
expect(document.activeElement).toBe(target);
|
|
990
|
+
});
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
describe('overflow-aware visibility', () => {
|
|
994
|
+
it('Should skip candidates whose center is outside their overflow container', async () => {
|
|
995
|
+
await usingAsync(new Injector(), async (i) => {
|
|
996
|
+
const container = document.createElement('div');
|
|
997
|
+
Object.defineProperty(container, 'computedStyleMap', { value: () => new Map() });
|
|
998
|
+
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
|
999
|
+
if (el === container) {
|
|
1000
|
+
return { overflow: 'hidden', overflowX: 'hidden', overflowY: 'visible' };
|
|
1001
|
+
}
|
|
1002
|
+
return { overflow: 'visible', overflowX: 'visible', overflowY: 'visible' };
|
|
1003
|
+
});
|
|
1004
|
+
mockRect(container, { left: 0, top: 0, width: 200, height: 50 });
|
|
1005
|
+
const visible = createButton('visible', { left: 10, top: 10, width: 50, height: 30 });
|
|
1006
|
+
const hidden = createButton('hidden', { left: 300, top: 10, width: 50, height: 30 });
|
|
1007
|
+
container.append(visible, hidden);
|
|
1008
|
+
const origin = createButton('origin', { left: 0, top: 100, width: 50, height: 50 });
|
|
1009
|
+
document.body.append(container, origin);
|
|
1010
|
+
origin.focus();
|
|
1011
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
1012
|
+
s.moveFocus('up');
|
|
1013
|
+
expect(document.activeElement).toBe(visible);
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
describe('focus trap', () => {
|
|
1018
|
+
it('Should block cross-section navigation when a trap is active', async () => {
|
|
1019
|
+
await usingAsync(new Injector(), async (i) => {
|
|
1020
|
+
const section1 = document.createElement('div');
|
|
1021
|
+
section1.setAttribute('data-nav-section', 'modal');
|
|
1022
|
+
mockRect(section1, { left: 0, top: 0, width: 400, height: 400 });
|
|
1023
|
+
const btn1 = createButton('modal-btn', { left: 10, top: 10, width: 50, height: 50 });
|
|
1024
|
+
section1.append(btn1);
|
|
1025
|
+
const section2 = document.createElement('div');
|
|
1026
|
+
section2.setAttribute('data-nav-section', 'background');
|
|
1027
|
+
mockRect(section2, { left: 500, top: 0, width: 200, height: 400 });
|
|
1028
|
+
const btn2 = createButton('bg-btn', { left: 510, top: 10, width: 50, height: 50 });
|
|
1029
|
+
section2.append(btn2);
|
|
1030
|
+
document.body.append(section1, section2);
|
|
1031
|
+
btn1.focus();
|
|
1032
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
1033
|
+
s.pushFocusTrap('modal');
|
|
1034
|
+
s.moveFocus('right');
|
|
1035
|
+
expect(document.activeElement).toBe(btn1);
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
it('Should set activeSection when pushing a trap', async () => {
|
|
1039
|
+
await usingAsync(new Injector(), async (i) => {
|
|
1040
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
1041
|
+
s.pushFocusTrap('dialog');
|
|
1042
|
+
expect(s.activeSection.getValue()).toBe('dialog');
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
it('Should restore previous section on pop', async () => {
|
|
1046
|
+
await usingAsync(new Injector(), async (i) => {
|
|
1047
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
1048
|
+
s.activeSection.setValue('main');
|
|
1049
|
+
s.pushFocusTrap('dialog');
|
|
1050
|
+
expect(s.activeSection.getValue()).toBe('dialog');
|
|
1051
|
+
s.popFocusTrap('dialog', 'main');
|
|
1052
|
+
expect(s.activeSection.getValue()).toBe('main');
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
it('Should support nested traps — topmost wins', async () => {
|
|
1056
|
+
await usingAsync(new Injector(), async (i) => {
|
|
1057
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
1058
|
+
s.pushFocusTrap('outer-dialog');
|
|
1059
|
+
s.pushFocusTrap('inner-dialog');
|
|
1060
|
+
expect(s.activeSection.getValue()).toBe('inner-dialog');
|
|
1061
|
+
s.popFocusTrap('inner-dialog');
|
|
1062
|
+
expect(s.activeSection.getValue()).toBe('outer-dialog');
|
|
1063
|
+
s.popFocusTrap('outer-dialog', 'main');
|
|
1064
|
+
expect(s.activeSection.getValue()).toBe('main');
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
1067
|
+
it('Should allow within-section navigation when trap is active', async () => {
|
|
1068
|
+
await usingAsync(new Injector(), async (i) => {
|
|
1069
|
+
const section = document.createElement('div');
|
|
1070
|
+
section.setAttribute('data-nav-section', 'modal');
|
|
1071
|
+
mockRect(section, { left: 0, top: 0, width: 400, height: 400 });
|
|
1072
|
+
const btn1 = createButton('btn1', { left: 10, top: 10, width: 50, height: 50 });
|
|
1073
|
+
const btn2 = createButton('btn2', { left: 10, top: 100, width: 50, height: 50 });
|
|
1074
|
+
section.append(btn1, btn2);
|
|
1075
|
+
document.body.append(section);
|
|
1076
|
+
btn1.focus();
|
|
1077
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
1078
|
+
s.pushFocusTrap('modal');
|
|
1079
|
+
s.moveFocus('down');
|
|
1080
|
+
expect(document.activeElement).toBe(btn2);
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
it('Should focus within trapped section when activeElement is document.body', async () => {
|
|
1084
|
+
await usingAsync(new Injector(), async (i) => {
|
|
1085
|
+
const section1 = document.createElement('div');
|
|
1086
|
+
section1.setAttribute('data-nav-section', 'sidebar');
|
|
1087
|
+
mockRect(section1, { left: 0, top: 0, width: 200, height: 400 });
|
|
1088
|
+
const sidebarBtn = createButton('sidebar-btn', { left: 10, top: 10, width: 50, height: 50 });
|
|
1089
|
+
section1.append(sidebarBtn);
|
|
1090
|
+
const section2 = document.createElement('div');
|
|
1091
|
+
section2.setAttribute('data-nav-section', 'modal');
|
|
1092
|
+
mockRect(section2, { left: 300, top: 50, width: 400, height: 300 });
|
|
1093
|
+
const modalBtn = createButton('modal-btn', { left: 310, top: 60, width: 50, height: 50 });
|
|
1094
|
+
section2.append(modalBtn);
|
|
1095
|
+
document.body.append(section1, section2);
|
|
1096
|
+
document.activeElement?.blur();
|
|
1097
|
+
expect(document.activeElement).toBe(document.body);
|
|
1098
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
1099
|
+
s.pushFocusTrap('modal');
|
|
1100
|
+
s.moveFocus('down');
|
|
1101
|
+
expect(document.activeElement).toBe(modalBtn);
|
|
1102
|
+
expect(s.activeSection.getValue()).toBe('modal');
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
it('Should clear trap stack on disposal', async () => {
|
|
1106
|
+
await usingAsync(new Injector(), async (i) => {
|
|
1107
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
1108
|
+
s.pushFocusTrap('dialog');
|
|
1109
|
+
});
|
|
1110
|
+
// After disposal, creating a new instance should have no traps
|
|
1111
|
+
await usingAsync(new Injector(), async (i) => {
|
|
1112
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
1113
|
+
expect(s.activeSection.getValue()).toBeNull();
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
describe('configureSpatialNavigation', () => {
|
|
1118
|
+
it('Should configure the service with custom options', async () => {
|
|
1119
|
+
await usingAsync(new Injector(), async (i) => {
|
|
1120
|
+
configureSpatialNavigation(i, { initiallyEnabled: false });
|
|
1121
|
+
const s = i.getInstance(SpatialNavigationService);
|
|
1122
|
+
expect(s.enabled.getValue()).toBe(false);
|
|
1123
|
+
});
|
|
1124
|
+
});
|
|
1125
|
+
it('Should throw if called after service is instantiated', async () => {
|
|
1126
|
+
await usingAsync(new Injector(), async (i) => {
|
|
1127
|
+
i.getInstance(SpatialNavigationService);
|
|
1128
|
+
expect(() => configureSpatialNavigation(i, {})).toThrow('configureSpatialNavigation must be called before the SpatialNavigationService is instantiated');
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
});
|
|
1133
|
+
//# sourceMappingURL=spatial-navigation-service.spec.js.map
|