@gilchrist/spacnav 1.0.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/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # Spatnav — HTMLElement bindings for generic spatial navigation
2
+
3
+ Spatial navigation is a means of navigating focusable elements in
4
+ an interface based on their positions, relative to one another.
5
+ Pressing up should move you upward, right should move you rightward, and
6
+ so on.
7
+
8
+ Spacnav, short for Spatial Navigation, is a series of function
9
+ bindings for `HTMLElement`s which provide spatial navigation
10
+ functionality. It was initially written for my web-based
11
+ roguelike game, but I split off the code into its own project.
12
+ Currently, this library's development is coupled to that game
13
+ project, but I am intending to isolate the code more over time.
14
+
15
+ There is a W3C spec draft the for the feature, but no official
16
+ spatial navigation functionality exists in browsers. This
17
+ implementation does not follow the W3C draft, and currently, it
18
+ does not implement any CSS properties to control spatial
19
+ navigation. It also does not set or define any event handling
20
+ functions. The developer must call the spatial navigation methods
21
+ and implement event handling themselves. Over time, it may get
22
+ reworked and move closer to that specification.
23
+
24
+ ## Initialization
25
+
26
+ The library does not automatically bind functions to
27
+ `HTMLElement`. Instead, call the default export,
28
+ `initSpacnavElementMethods()`. This function is idempotent.
29
+
30
+ ```html
31
+ <script>
32
+ import initSpacnavElementMethods from "spatnav.js";
33
+ initSpacnavElementMethods();
34
+ </script>
35
+ ```
36
+
37
+ ## Using spatnav methods
38
+
39
+ This module adds several methods to `HTMLElement`.
40
+
41
+ ### `isSelectable()`
42
+
43
+ Returns `true` if the element can be focused via spatial
44
+ navigation. By default, this includes `<button>` elements, or any
45
+ element with the class `button` or `selectable`, or the
46
+ attributes `selectable` or `button`.
47
+
48
+ *note*: At a later point, these classes and attributes may be
49
+ replaced with CSS properties, similar to the W3C specification
50
+ draft.
51
+
52
+ ### `findSpacnavElement(direction_or_theta)`
53
+
54
+ Finds the lowest-scoring candidate for spatial navigation in a
55
+ given direction.
56
+
57
+ - `direction_or_theta`: Can be a numeric angle in radians or a
58
+ directional vector in the form of a 2-element `Array`: `[dx, dy]`.
59
+ - Returns the `HTMLElement` with the lowest score, or `null` if
60
+ no suitable candidate is found.
61
+
62
+ ### `handleSelect(previous_selection)`
63
+
64
+ Standard handler for selecting an element. It calls
65
+ `handleDeselect()` on the `previous_selection` (if provided),
66
+ focuses the current element, and scrolls it into view.
67
+
68
+ ### `handleDeselect()`
69
+
70
+ Standard handler for deselecting an element. By default, it calls
71
+ `this.blur()`.
72
+
73
+ ### `isSpacnavContainer()`
74
+
75
+ Returns `true` if the element is marked as a spatial navigation
76
+ container (has class `spacnav-container`, `spacnav-context`, or
77
+ `menu`). Containers help organize and limit the scope of spatial
78
+ navigation.
79
+
80
+ *note*: The `menu` class will be removed from this module,
81
+ as it is specific to the game code this module is based on.
82
+
83
+ ### `getSpacnavContainer()`
84
+
85
+ Returns the closest parent element that is a spatial navigation
86
+ container.
87
+
88
+ ### `iterSpacnavCandidates()`
89
+
90
+ A generator that yields all selectable elements or nested
91
+ containers within the current container. It does not recurse into
92
+ nested containers but yields the containers themselves if they
93
+ are selectable.
94
+
95
+ ### `getSpacnavDistanceTo(direction, candidate)`
96
+
97
+ Calculates a "distance" score to a candidate element in a
98
+ specific direction. This is used internally by
99
+ `findSpacnavElement` to determine the best next element. Lower
100
+ scores are better. It returns `null` if the candidate is not in
101
+ the specified direction or exceeds the angular threshold.
102
+
103
+ ## Configuration
104
+
105
+ ### `spacnav-threshold` attribute
106
+
107
+ You can control how "wide" the search beam is by setting the
108
+ `spacnav-threshold` attribute on an element or any of its
109
+ parents.
110
+
111
+ - Value can be in radians (e.g., `1.57`) or degrees (e.g., `90deg`).
112
+ - Default is `2π/3` (approx 120 degrees).
113
+
114
+ ## Tests
115
+
116
+ Automated testing is done with vitest and playwright in a
117
+ headless Chrome instance. To run tests, install NPM packages and
118
+ run the NPM test script:
119
+ ```bash
120
+ npm i
121
+ npm test
122
+ ```
123
+
124
+ ## Credits
125
+
126
+ `spacnav` was developed by [Gilchrist
127
+ Pitts](https://gilchrist.tech), initially for a web-based game
128
+ project.
129
+
130
+ Originally, it was made as an attempt to modify the algorithm in
131
+ the CSS Spatial Navigation Module Level 1 specification draft:
132
+ * [https://drafts.csswg.org/css-nav-1/](https://drafts.csswg.org/css-nav-1/)
133
+
134
+ ## License
135
+
136
+ MIT
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@gilchrist/spacnav",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "spacnav.js",
6
+ "scripts": {
7
+ "test": "vitest run",
8
+ "dev": "vitest"
9
+ },
10
+ "keywords": [
11
+ "spatial",
12
+ "navigation",
13
+ "browser"
14
+ ],
15
+ "author": "Gilchrist Pitts",
16
+ "license": "MIT",
17
+ "description": "HTMLElement bindings for spatial navigation",
18
+ "devDependencies": {
19
+ "@vitest/browser-playwright": "^4.1.2",
20
+ "vitest": "^4.1.2"
21
+ }
22
+ }
package/spatnav.js ADDED
@@ -0,0 +1,631 @@
1
+ function pointInRect (x, y, rx, ry, rw, rh) {
2
+ return (
3
+ rx <= x && x < rx + rw &&
4
+ ry <= y && y < ry + rh
5
+ );
6
+ }
7
+
8
+
9
+ function minWhere (func) {
10
+ let min = arguments[1];
11
+ let min_where = func(min);
12
+
13
+ for (let i=2; i < arguments.length; i++) {
14
+ const where = func(arguments[i]);
15
+
16
+ if (where < min_where) {
17
+ min = arguments[i];
18
+ min_where = where;
19
+ }
20
+ }
21
+
22
+ return min;
23
+ }
24
+
25
+
26
+ function distance2 (x1, y1, x2, y2) {
27
+ switch (arguments.length) {
28
+ case 2:
29
+ return (
30
+ Math.pow(arguments[1][0] - arguments[0][0], 2) +
31
+ Math.pow(arguments[1][1] - arguments[0][1], 2)
32
+ );
33
+ case 4:
34
+ return (
35
+ Math.pow(x2 - x1, 2) +
36
+ Math.pow(y2 - y1, 2)
37
+ );
38
+ default:
39
+ throw new TypeError(`distance2() requires 2 or 4 arguments, got ${arguments.length}`);
40
+ }
41
+ }
42
+
43
+
44
+ function parseArgsThetaOrVector (theta_or_dx, dy) {
45
+ let dx;
46
+ let dt;
47
+
48
+ switch (arguments.length) {
49
+ case 1:
50
+ if (typeof theta_or_dx === "number") {
51
+ if (Number.isNaN(theta_or_dx)) {
52
+ throw new TypeError("First argument is NaN");
53
+ }
54
+
55
+ dx = Math.cos(theta_or_dx);
56
+ dy = Math.sin(theta_or_dx);
57
+ dt = theta_or_dx;
58
+
59
+ } else if (Array.isArray(theta_or_dx)) {
60
+ [dx, dy] = theta_or_dx;
61
+ dt = Math.atan2(dy, dx);
62
+
63
+ } else {
64
+ throw new TypeError(
65
+ `Expected single argument to be a finite number or Array of finite numbers, got ${
66
+ theta_or_dx?.constructor?.name ?? typeof theta_or_dx
67
+ }`
68
+ );
69
+ }
70
+ break;
71
+
72
+ case 2:
73
+ dx = theta_or_dx;
74
+ dt = Math.atan2(dy, dx);
75
+ break;
76
+
77
+ default:
78
+ throw new TypeError(`expects one or two arguments, got ${arguments.length}`);
79
+ }
80
+
81
+ return { dx, dy, dt };
82
+ }
83
+
84
+
85
+ export function getElementSpacnavThreshold (element) {
86
+ let default_threshold = 2*Math.PI/3;
87
+
88
+ if ("spacnav-threshold" in element.attributes) {
89
+ let threshold_attr = element.getAttribute("spacnav-threshold");
90
+ let threshold_float = parseFloat(threshold_attr); // note: this ignores degrees suffix
91
+
92
+ // An empty spacnav_threshold resets the default value
93
+ if (threshold_attr == "") {
94
+ return default_threshold;
95
+ }
96
+
97
+ if (! Number.isFinite(threshold_float)) {
98
+ throw new TypeError(`Expected spacnav-threshold attribute to contain a finite float, got ${
99
+ threshold_float?.constructor?.name ?? typeof threshold_float
100
+ }`);
101
+ }
102
+
103
+ if (threshold_attr.endsWith("deg")) {
104
+ return threshold_float / 180 * Math.PI;
105
+ } else {
106
+ return threshold_float;
107
+ }
108
+
109
+ } else {
110
+ // The spacnav-threshold attribute was not found in this
111
+ // element. Traverse parent containers looking for a defined
112
+ // threshold, or go with the 90-degree default value.
113
+
114
+ if (element.parentElement) {
115
+ return getElementSpacnavThreshold(element.parentElement);
116
+ } else {
117
+ return default_threshold;
118
+ }
119
+
120
+ }
121
+ }
122
+
123
+
124
+ export function isSelectable () {
125
+ return (
126
+ this.tagName === "BUTTON" ||
127
+ this.classList.contains("button") ||
128
+ this.classList.contains("selectable") ||
129
+ this.hasAttribute("selectable") ||
130
+ this.hasAttribute("button")
131
+ );
132
+ };
133
+
134
+
135
+ export function getSpacnavContainer () /* HTMLElement */ {
136
+ let element;
137
+
138
+ for (
139
+ element = this.parentElement;
140
+ element;
141
+ element = element.parentElement
142
+ ) if (
143
+ element.isSpacnavContainer()
144
+ ){
145
+ return element;
146
+ }
147
+
148
+ return null;
149
+ };
150
+
151
+
152
+ export function getSpacnavContext () /* HTMLElement */ {
153
+ // Get the selection context element. All spatial navigation
154
+ // logic should be constrained to the current selection
155
+ // context.
156
+
157
+ let element;
158
+
159
+ for (
160
+ element = this.parentElement;
161
+ element;
162
+ element = element.parentElement
163
+ ) if (
164
+ element.isSpacnavContext()
165
+ ){
166
+ return element;
167
+ }
168
+
169
+ if (document.body.contains(this)) {
170
+ return document.body;
171
+ }
172
+
173
+ return null;
174
+ };
175
+
176
+
177
+ export function isSpacnavContext () {
178
+ return (
179
+ this.classList.contains("spacnav-context") ||
180
+ this.classList.contains("menu")
181
+ );
182
+ };
183
+
184
+
185
+ export function isSpacnavContainer () {
186
+ return (
187
+ this.classList.contains("spacnav-container") ||
188
+ this.classList.contains("spacnav-context") ||
189
+ this.classList.contains("menu")
190
+ );
191
+ };
192
+
193
+
194
+ export function handleDeselect () {
195
+ this.blur();
196
+ };
197
+
198
+
199
+ export function handleSelect (previous_selection) {
200
+ if (! (previous_selection instanceof HTMLElement || previous_selection == null)) {
201
+ throw new TypeError(`Expected previous_selection to be nullish or an HTMLElement, got ${
202
+ previous_selection?.constructor?.name ?? typeof previous_selection
203
+ }`);
204
+ }
205
+
206
+ previous_selection?.handleDeselect();
207
+
208
+ this.focus();
209
+ this.scrollIntoView({
210
+ behavior: "smooth",
211
+ block: "center",
212
+ inline: "center",
213
+ });
214
+ };
215
+
216
+ export function findSpacnavElement (theta_or_dx, dy) /* HTMLElement */ {
217
+ const found = this.findSpacnavElementWithinContext(null, ...arguments);
218
+
219
+ if (found) {
220
+ return found;
221
+ }
222
+
223
+ return this.getSpacnavContainer()?.findSpacnavElementWithinContext(null, ...arguments) ?? null;
224
+ };
225
+
226
+ export function findSpacnavElementWithinContext (selection_context, theta_or_dx, dy) /* HTMLElement */ {
227
+ if (arguments.length < 2 || arguments.length > 3) {
228
+ throw new TypeError(`Expected 2-3 arguments, got ${arguments.length}`);
229
+ }
230
+
231
+ var { dx, dy, dt } = parseArgsThetaOrVector(...Array.from(arguments).slice(1));
232
+
233
+ let selection_container;
234
+ let reference_selection_container;
235
+
236
+ if (selection_context === null) {
237
+ selection_context = this.getSpacnavContext();
238
+ selection_container = this.getSpacnavContainer();
239
+ reference_selection_container = selection_container;
240
+
241
+ if (!selection_container)
242
+ return null;
243
+
244
+ } else {
245
+ selection_container = selection_context;
246
+ reference_selection_container = this.getSpacnavContainer();
247
+ }
248
+
249
+ const rect = this.getBoundingClientRect();
250
+
251
+ // Start raycasting from the intersection of the raycasting
252
+ // vector and this element's perimeter.
253
+ let start_x = rect.x + rect.width /2;
254
+ let start_y = rect.y + rect.height/2;
255
+
256
+ // Determine where the ray leaves this element's bounding box.
257
+ // TODO: use math to calculate this instead of raycasting
258
+
259
+ while (pointInRect(
260
+ start_x + dx, start_y + dy,
261
+ rect.x, rect.y, rect.width, rect.height,
262
+ )) {
263
+ start_x += dx;
264
+ start_y += dy;
265
+ }
266
+
267
+ const searched_selection_containers = new Set();
268
+
269
+ let step_length = 8;
270
+ let max_steps = 2000/step_length; // TODO: could fail on large screen sizes. Maybe depend on viewport size?
271
+ let element = null;
272
+ let num_steps = 0;
273
+
274
+ // Check up the selection container chain
275
+
276
+ let min_distance = null;
277
+ let min_candidate = null;
278
+
279
+ for (
280
+ let checked = new Set([ this ]);
281
+
282
+ selection_container && (
283
+ ! selection_context ||
284
+ selection_container === selection_context ||
285
+ selection_context.contains(selection_container)
286
+ );
287
+
288
+ /* handled below: if selection_container === selection_context, this is the last iteration */
289
+
290
+ checked.add(selection_container),
291
+ selection_container = selection_container.getSpacnavContainer()
292
+ ){
293
+ for (let candidate of selection_container.iterSpacnavCandidates()) {
294
+ if (checked.has(candidate)) {
295
+ continue;
296
+ }
297
+
298
+ const distance = this.getSpacnavDistanceTo(dt, candidate);
299
+
300
+ if (distance === null) {
301
+ continue;
302
+ }
303
+
304
+ const is_same_selection_container = (candidate.getSpacnavContainer() === reference_selection_container);
305
+
306
+ if (candidate.hasAttribute("spacnav-internal") && !is_same_selection_container) {
307
+ continue;
308
+ }
309
+
310
+ if (!min_candidate || distance < min_distance) {
311
+ let new_min_candidate = candidate;
312
+
313
+ if (candidate.isSpacnavContainer()) {
314
+ new_min_candidate = this.findSpacnavElementWithinContext(candidate, dx, dy);
315
+
316
+ if (candidate.isSelectable()) {
317
+ new_min_candidate ??= candidate;
318
+ }
319
+ }
320
+
321
+ if (new_min_candidate) {
322
+ min_distance = distance;
323
+ min_candidate = new_min_candidate;
324
+ }
325
+ }
326
+ }
327
+
328
+ checked.add(selection_container);
329
+
330
+ if (selection_container === selection_context) {
331
+ break;
332
+ }
333
+
334
+ if (min_candidate) {
335
+ break;
336
+ }
337
+ }
338
+
339
+ switch (min_candidate?.getAttribute("spacnav-refer")) {
340
+ case "score":
341
+ // TODO: how does the logic of this work with non-selectable selection containers?
342
+ min_candidate = this.getSpacnavDistanceToWithinContext(min_candidate, dx, dy);
343
+ break;
344
+
345
+ case "first":
346
+ min_candidate = candidate.querySelector("selectable, button");
347
+ throw new Error("[spacnav-refer=first] not yet implemented");
348
+ }
349
+
350
+ return min_candidate; // Return the best candidate after checking all containers
351
+ };
352
+
353
+
354
+ export function * iterSpacnavContainers () {
355
+ if (!this.isSpacnavContainer()) {
356
+ return null;
357
+ }
358
+
359
+ for (
360
+ let next, el = this.firstElementChild;
361
+ el && el !== this;
362
+ el = next
363
+ ){
364
+ if (el.isSpacnavContainer()) {
365
+ yield el;
366
+ next = el.nextElementSibling;
367
+ } else if (el.isSelectable()) {
368
+ next = el.nextElementSibling;
369
+ } else {
370
+ next = el.firstElementChild ??
371
+ el.nextElementSibling;
372
+ }
373
+
374
+ // Search for an uncle element (sibling of the parent)
375
+ for (
376
+ let parent = el.parentElement ;
377
+ !next && parent && parent !== this;
378
+ parent = parent.parentElement
379
+ ){
380
+ next = parent.nextElementSibling;
381
+ }
382
+ }
383
+ }
384
+
385
+
386
+ export function * iterSpacnavCandidates () /* yields HTMLElement */ {
387
+ if (!this.isSpacnavContainer()) {
388
+ return null;
389
+ }
390
+
391
+ for (
392
+ let next, el = this.firstElementChild;
393
+ el && el !== this;
394
+ el = next
395
+ ){
396
+ if (
397
+ el.isSpacnavContainer() ||
398
+ el.isSelectable()
399
+ ){
400
+ yield el;
401
+ next = el.nextElementSibling;
402
+
403
+ } else {
404
+ next = el.firstElementChild ??
405
+ el.nextElementSibling ;
406
+ }
407
+
408
+ // Search for an uncle element (sibling of the parent)
409
+ for (
410
+ let parent = el.parentElement ;
411
+ !next && parent && parent !== this;
412
+ parent = parent.parentElement
413
+ ){
414
+ next = parent.nextElementSibling;
415
+ }
416
+ }
417
+ };
418
+
419
+
420
+ export function getSpacnavDistanceTo (direction, candidate) {
421
+ switch (arguments.length) {
422
+ case 3:
423
+ direction = [arguments[0], arguments[1]];
424
+ candidate = arguments[2];
425
+ case 2:
426
+ break;
427
+
428
+ default:
429
+ throw new TypeError(`getSpacnavDistanceTo() expects 2-3 arguments, got ${arguments.length}`);
430
+ }
431
+
432
+ if ( ! (candidate instanceof HTMLElement)) {
433
+ throw new TypeError(`getSpacnavDistanceTo() expects candidate to be an HTMLElement, got ${
434
+ candidate?.constructor?.name ?? typeof candidate
435
+ }`);
436
+ }
437
+
438
+ // Variable naming convention: this function uses naming
439
+ // conventions which are usually frowned upon in programming
440
+ // contexts, with the names being notably short and having
441
+ // single-leter components. However, due to the amount of
442
+ // math, it is used with a local naming convention.
443
+
444
+ // Variables beginning with a capital letter refer to a
445
+ // thing within the spatial navigation's evaluatory context.
446
+ //
447
+ // The important ones here are as follows:
448
+
449
+ // R Reference, `this`, the element from whom the spatial
450
+ // navigation originates.
451
+
452
+ // C Candidate, the element whose spatial navigation
453
+ // distance from the Reference is being evaluated.
454
+
455
+ // B Beam, an infinite, monodirectional line with an
456
+ // origin point on the reference, pointing in the direction
457
+ // of the spatial navigation. The origin is on the
458
+ // perimeter of the reference.
459
+
460
+ // P Projection, which refers to a point on the
461
+ // candidate's perimeter, as well as the trajectory to
462
+ // that point.
463
+
464
+ // Beam's directional x-y vector and theta (angle)
465
+ let Bdx, Bdy, Bdt;
466
+
467
+ if (Array.isArray(direction)) {
468
+ if (direction.length !== 2) {
469
+ throw new TypeError(`getSpacnavDistanceTo() expects an array with a length of two, got ${direction.length}`);
470
+ }
471
+
472
+ [Bdx, Bdy] = direction;
473
+
474
+ if (! Number.isFinite(Bdx) || ! Number.isFinite(Bdy)) {
475
+ throw new TypeError("getSpacnavDistanceTo() expects an array with two finite numbers");
476
+ }
477
+
478
+ Bdt = Math.atan2(Bdy, Bdx);
479
+
480
+ } else if (typeof direction === "number") {
481
+ if (! Number.isFinite(direction)) {
482
+ throw new TypeError("getSpacnavDistanceTo() expects a number denoting direction, but it is not finite");
483
+ }
484
+
485
+ Bdt = direction;
486
+ Bdx = Math.cos(Bdt);
487
+ Bdy = Math.sin(Bdt);
488
+
489
+ } else {
490
+ throw new TypeError(`getSpacnavDistance() to expects direction to by an Array of two finite numbers (a directional vector) or a finite number (angle in radians), got ${
491
+ direction?.constructor?.name ?? typeof direction
492
+ }`);
493
+ }
494
+
495
+ // Candidate and reference rectangular bounds
496
+ const Rr = this.getBoundingClientRect();
497
+ const Cr = candidate.getBoundingClientRect();
498
+ const Rw = Rr.width, Rh = Rr.height; // Size shorthands
499
+ const Cw = Cr.width, Ch = Cr.height;
500
+ const Rcx = Rr.x + Rw/2, Rcy = Rr.y + Rh/2; // Rect centers
501
+ const Ccx = Cr.x + Cw/2, Ccy = Cr.y + Ch/2;
502
+
503
+ /*
504
+ Calculate a point on the perimeter of the reference,
505
+ opposite the direction of the beam, and intersecting with
506
+ the center of the reference, like so:
507
+
508
+ ----------
509
+ | |
510
+ --B---Rc---|---->
511
+ | |
512
+ ----------
513
+ ^^^^^
514
+ RBd: distance between the reference center and beam origin
515
+ */
516
+
517
+ // Distance between reference center and beam origin
518
+ const RBd = Math.min(Math.abs(Rw/(Bdx || 0.0000001)), Math.abs(Rh/(Bdy || 0.0000001)))/2;
519
+ const Bx = Rcx - RBd*Bdx;
520
+ const By = Rcy - RBd*Bdy;
521
+
522
+ /*
523
+ ----------
524
+ | | ---------
525
+ --B---Rc---|---->P |--->
526
+ | | | Cc |
527
+ ---------- | |
528
+ ---------
529
+ */
530
+
531
+ // Candidate rectangle's edge shorthands
532
+ const C_top = Cr.top , C_bot = Cr.bottom ;
533
+ const C_lft = Cr.left, C_rgt = Cr.right ;
534
+
535
+ if (!Number.isFinite(Bdx)) throw new Error("");
536
+
537
+ // t-values along each edge (when extrapolated to being an
538
+ // infinite line), where the beam intersects that edge.
539
+ const C_lft_t = (Bx - C_lft) / (Bdx || 0.000001), C_rgt_t = (Bx - C_rgt) / (Bdx || 0.000001) ;
540
+ const C_top_t = (By - C_top) / (Bdy || 0.000001), C_bot_t = (By - C_bot) / (Bdy || 0.000001) ;
541
+
542
+ // Find the candidate edge intersection t-values, horizontal and vertical, which are nearest to
543
+ const Peh_t = minWhere(Math.abs, ...[C_top_t, C_bot_t]);
544
+ const Pev_t = minWhere(Math.abs, ...[C_lft_t, C_rgt_t]);
545
+
546
+ // Candidate horizontal and vertical edge intersection positions
547
+ const Phy = (Peh_t === C_top_t ? C_top : C_bot);
548
+ const Pvx = (Pev_t === C_lft_t ? C_lft : C_rgt);
549
+ const Phx = Bx - Peh_t*Bdx;
550
+ const Pvy = By - Pev_t*Bdy;
551
+
552
+ const Ph_in_bounds = (C_lft <= Phx && Phx <= C_rgt);
553
+ const Pv_in_bounds = (C_top <= Pvy && Pvy <= C_bot);
554
+ const P_use_nearest_intersection = !(Ph_in_bounds ^ Pv_in_bounds);
555
+
556
+ let Px, Py;
557
+
558
+ if (P_use_nearest_intersection) {
559
+ [Px, Py] = minWhere(
560
+ ([px, py]) => distance2(px, py, Bx, By),
561
+ [Phx, Phy], [Pvx, Pvy],
562
+ );
563
+
564
+ if (Px == undefined || Py == undefined) {
565
+ return null;
566
+ }
567
+
568
+ } else if (Ph_in_bounds) {
569
+ Px = Phx;
570
+ Py = Phy;
571
+
572
+ } else /* Pv_in_bounds */ {
573
+ Px = Pvx;
574
+ Py = Pvy;
575
+ }
576
+
577
+ Px = Math.min(C_rgt, Math.max(C_lft, Px));
578
+ Py = Math.min(C_bot, Math.max(C_top, Py));
579
+
580
+ const projected_angular_displacement = Math.abs( Math.PI * Math.sin(
581
+ Math.abs(Math.atan2(Py-By, Px-Bx) - Bdt) / 2
582
+ ));
583
+
584
+ const threshold = getElementSpacnavThreshold(this);
585
+
586
+ const projected_euclidean_distance = Math.sqrt(distance2(Bx, By, Px, Py));
587
+
588
+ const Bd = (Px-Bx)*Bdx + (Py-By)*Bdy;
589
+
590
+ if (
591
+ projected_euclidean_distance == 0 ||
592
+ Bd < 0 ||
593
+ projected_angular_displacement > threshold
594
+ ){
595
+ return null;
596
+ }
597
+
598
+ const projected_angular_displacement_weight_curving = 1.6;
599
+ const projected_angular_displacement_weight = Math.pow(
600
+ Math.abs(projected_angular_displacement - threshold),
601
+ projected_angular_displacement_weight_curving
602
+ );
603
+
604
+ const beam_distance_weight = 0.5;
605
+
606
+ const distance = (
607
+ Bd * beam_distance_weight +
608
+ projected_euclidean_distance / (1+projected_angular_displacement_weight)
609
+ );
610
+
611
+ if (!Number.isFinite(distance)) {
612
+ throw new Error(`Distance is not finite, got ${distance}. Something should be debugged.`);
613
+ }
614
+ return distance;
615
+ };
616
+
617
+
618
+ export function initSpacnavElementMethods () /* void */ {
619
+ HTMLElement.prototype.isSelectable = isSelectable;
620
+ HTMLElement.prototype.getSpacnavContainer = getSpacnavContainer;
621
+ HTMLElement.prototype.getSpacnavContext = getSpacnavContext;
622
+ HTMLElement.prototype.isSpacnavContext = isSpacnavContext;
623
+ HTMLElement.prototype.isSpacnavContainer = isSpacnavContainer;
624
+ HTMLElement.prototype.handleDeselect = handleDeselect;
625
+ HTMLElement.prototype.handleSelect = handleSelect;
626
+ HTMLElement.prototype.findSpacnavElement = findSpacnavElement;
627
+ HTMLElement.prototype.findSpacnavElementWithinContext = findSpacnavElementWithinContext;
628
+ HTMLElement.prototype.iterSpacnavContainers = iterSpacnavContainers;
629
+ HTMLElement.prototype.iterSpacnavCandidates = iterSpacnavCandidates;
630
+ HTMLElement.prototype.getSpacnavDistanceTo = getSpacnavDistanceTo;
631
+ }
@@ -0,0 +1,341 @@
1
+ import { describe, test, it, expect, beforeEach, vi } from "vitest";
2
+ import { initSpacnavElementMethods } from "./spatnav.js";
3
+
4
+ // Set up a basic DOM environment for each test
5
+ beforeEach(() => {
6
+ document.body.innerHTML = (`
7
+ <style>
8
+ *, *::before, *::after { box-sizing: inherit; }
9
+
10
+ body {
11
+ box-sizing: content-box;
12
+ margin: 0;
13
+ }
14
+ </style>
15
+ `);
16
+
17
+ document.body.classList.add("spacnav-container");
18
+
19
+ // Initialize the spatial navigation methods on HTMLElement.prototype
20
+ initSpacnavElementMethods();
21
+ });
22
+
23
+
24
+ function makeRectElement(rect={}, isSelectable = false, is_selection_container = false) {
25
+ const element = document.createElement("div");
26
+
27
+ element.style.position = "absolute";
28
+ element.style.left = `${rect.left ?? rect.x ?? 0 }px`;
29
+ element.style.top = `${rect.top ?? rect.y ?? 0 }px`;
30
+ element.style.width = `${rect.width ?? 10 }px`;
31
+ element.style.height = `${rect.height ?? 10 }px`;
32
+ element.style.overflow = "hidden";
33
+
34
+ element.textContent = rect.text;
35
+
36
+ if (isSelectable) {
37
+ element.setAttribute("selectable", "");
38
+ }
39
+ if (is_selection_container) {
40
+ element.classList.add("spacnav-container");
41
+ }
42
+
43
+ document.body.appendChild(element);
44
+ return element;
45
+ }
46
+
47
+
48
+ test("Element rect positioning (test internal)", () => {
49
+ const rect = { x: 100, y: 200, width: 400, height: 300 };
50
+ const el = makeRectElement(rect);
51
+ expect(el.getBoundingClientRect()).toMatchObject(rect);
52
+ });
53
+
54
+
55
+ test("HTMLElement.prototype.isSelectable", () => {
56
+ expect(document.createElement("div").isSelectable()).toBe(false);
57
+ expect(document.createElement("button").isSelectable()).toBe(true);
58
+
59
+ const div_class_button = document.createElement("div");
60
+ div_class_button.classList.add("button");
61
+ expect(div_class_button.isSelectable()).toBe(true);
62
+
63
+ const div_attribute_button = document.createElement("div");
64
+ div_attribute_button.toggleAttribute("button");
65
+
66
+ expect(div_attribute_button.isSelectable()).toBe(true);
67
+ });
68
+
69
+
70
+ describe("HTMLElement.prototype.isSpacnavContainer", () => {
71
+ it("should return true for an element with class 'spacnav-container'", () => {
72
+ const div = document.createElement("div");
73
+ div.classList.add("spacnav-container");
74
+ expect(div.isSpacnavContainer()).toBe(true);
75
+ });
76
+
77
+ it("should return true for an element with class 'menu'", () => {
78
+ const div = document.createElement("div");
79
+ div.classList.add("menu");
80
+ expect(div.isSpacnavContainer()).toBe(true);
81
+ });
82
+
83
+ it("should return false for a non-container element", () => {
84
+ const div = document.createElement("div");
85
+ expect(div.isSpacnavContainer()).toBe(false);
86
+ });
87
+ });
88
+
89
+
90
+ describe("HTMLElement.prototype.getSpacnavContainer", () => {
91
+ it("should return the parent selection container", () => {
92
+ const container = document.createElement("div");
93
+ container.classList.add("spacnav-container");
94
+ const child = document.createElement("div");
95
+ container.appendChild(child);
96
+
97
+ expect(child.getSpacnavContainer()).toBe(container);
98
+ });
99
+
100
+ it("should return the closest selection container when nested", () => {
101
+ const grandparent = makeRectElement({}, false, true);
102
+ const parent = document.createElement("div");
103
+ const child = makeRectElement({}, true, false);
104
+
105
+ grandparent.appendChild(parent);
106
+ parent.appendChild(child);
107
+
108
+ expect(child.getSpacnavContainer()).toBe(grandparent);
109
+ });
110
+
111
+ it("should return null if no selection container is found", () => {
112
+ const div = document.createElement("div");
113
+ expect(div.getSpacnavContainer()).toBe(null);
114
+ });
115
+ });
116
+
117
+
118
+ describe("HTMLElement.prototype.handleSelect and handleDeselect", () => {
119
+ it("handleSelect should focus the element and scroll it into view", () => {
120
+ const element = document.createElement("button");
121
+
122
+ const focus_spy = vi.spyOn(element, "focus");
123
+ const scroll_spy = vi.spyOn(element, "scrollIntoView");
124
+
125
+ element.handleSelect(null);
126
+
127
+ expect(focus_spy).toHaveBeenCalledTimes(1);
128
+ expect(scroll_spy).toHaveBeenCalledWith({
129
+ behavior: "smooth",
130
+ block: "center",
131
+ inline: "center",
132
+ });
133
+
134
+ focus_spy.mockRestore();
135
+ scroll_spy.mockRestore();
136
+ });
137
+
138
+ it("handleDeselect should blur the element", () => {
139
+ const element = document.createElement("button");
140
+
141
+ const blur_spy = vi.spyOn(element, "blur");
142
+ element.handleDeselect();
143
+
144
+ expect(blur_spy).toHaveBeenCalledTimes(1);
145
+ blur_spy.mockRestore();
146
+ });
147
+
148
+ it("handleSelect should call handleDeselect on previous selection if provided", () => {
149
+ const previous_selection = document.createElement("button");
150
+ const current_selection = document.createElement("button");
151
+
152
+ const deselect_spy = vi.spyOn(previous_selection, "handleDeselect");
153
+
154
+ current_selection.handleSelect(previous_selection);
155
+
156
+ expect(deselect_spy).toHaveBeenCalledTimes(1);
157
+ deselect_spy.mockRestore();
158
+ });
159
+ });
160
+
161
+
162
+ describe("HTMLElement.prototype.iterSpacnavCandidates", () => {
163
+ it("yields selectable direct children of the selection container", () => {
164
+ const container = makeRectElement({}, false, true);
165
+ const button_1 = document.createElement("button");
166
+ const div_1 = document.createElement("div"); div_1.toggleAttribute("selectable");
167
+ const button_2 = document.createElement("button");
168
+
169
+ container.appendChild(button_1);
170
+ container.appendChild(div_1);
171
+ container.appendChild(button_2);
172
+
173
+ const candidates = Array.from(container.iterSpacnavCandidates());
174
+ expect(candidates).toEqual([button_1, div_1, button_2]);
175
+ });
176
+
177
+ it("yields selectable elements inside nested non-selection container elements", () => {
178
+ const root_container = document.createElement("div");
179
+ document.body.appendChild(root_container);
180
+
181
+ root_container.outerHTML = `
182
+ <div id="container" class="spacnav-container">
183
+ <div id="div_1">
184
+ <button id="button_1"></button>
185
+ <button id="button_2"></button>
186
+ <div id="div_2">
187
+ <button id="button_3"></button>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ `;
192
+
193
+ document.body.appendChild(root_container);
194
+
195
+ const candidates = Array.from(window.container.iterSpacnavCandidates());
196
+
197
+ expect(candidates).toEqual([
198
+ window.button_1,
199
+ window.button_2,
200
+ window.button_3
201
+ ]);
202
+ });
203
+
204
+ it("does not traverse into nested selection containers, but yields the container itself if selectable", () => {
205
+ document.body.innerHTML = `
206
+ <div id="container" class="spacnav-container">
207
+ <div id="selectable_container" class="spacnav-container selectable">
208
+ <button></button>
209
+ </div>
210
+ </div>
211
+ `;
212
+
213
+ const candidates = Array.from(
214
+ window.container.iterSpacnavCandidates()
215
+ );
216
+ expect(candidates).toEqual([window.selectable_container]);
217
+ });
218
+
219
+ it("should not traverse into nested selection containers if the container is not selectable", () => {
220
+ const container = document.createElement("div");
221
+ const non_selectable_container = makeRectElement({}, false, true);
222
+ const button_inside = document.createElement("button");
223
+ non_selectable_container.appendChild(button_inside);
224
+
225
+ container.appendChild(non_selectable_container);
226
+
227
+ const candidates = Array.from(container.iterSpacnavCandidates());
228
+ expect(candidates).toEqual([]);
229
+ });
230
+
231
+ it("should handle empty container", () => {
232
+ const container = document.createElement("div");
233
+ const candidates = Array.from(container.iterSpacnavCandidates());
234
+ expect(candidates).toEqual([]);
235
+ });
236
+ });
237
+
238
+
239
+ describe("HTMLElement.prototype.findSpacnavElement", () => {
240
+ it("should return null if no selection container is found", () => {
241
+ const ref_element = makeRectElement();
242
+ const found_element = ref_element.findSpacnavElement(0);
243
+ expect(found_element).toBeNull();
244
+ });
245
+
246
+ it("returns the candidate with the minimum spatial navigation distance", () => {
247
+ const ref_element = makeRectElement({ text: "reference" }, true);
248
+
249
+ makeRectElement({ text: "further away", x: 50 }, true);
250
+ makeRectElement({ text: "behind", x: -10 }, true);
251
+ makeRectElement({ text: "not selectable", x: 10 }, false);
252
+ makeRectElement({ text: "up", y: 10 }, true);
253
+ makeRectElement({ text: "diagonal", x: 10, y: 20 }, true);
254
+
255
+ const closest_element = makeRectElement({ text: "closest", x: 20 }, true);
256
+
257
+ const found_element = ref_element.findSpacnavElement(0);
258
+ expect(found_element?.textContent).toBe("closest");
259
+ });
260
+
261
+ it("should consider candidates from parent selection containers when none are found", () => {
262
+ const ref_element = makeRectElement();
263
+ const child_container = makeRectElement({ x: -10, y: -10, width: 30, height: 30 }, false, true);
264
+ const parent_container = makeRectElement({ x: -100, y: -100, width: 200, height: 200 }, false, true);
265
+ const candidate_in_child = makeRectElement({ x: -20, y: 0, width: 10, height: 10 }, true);
266
+ const candidate_in_parent = makeRectElement({ x: 10, y: 0, width: 10, height: 10 }, true);
267
+
268
+ child_container.appendChild(ref_element);
269
+ parent_container.appendChild(child_container);
270
+ child_container.appendChild(candidate_in_child);
271
+ parent_container.appendChild(candidate_in_parent);
272
+
273
+ const found_element = ref_element.findSpacnavElement(0);
274
+ expect(found_element).toEqual(candidate_in_parent);
275
+ });
276
+
277
+ it("navigates to selectable items inside sibling selection containers, from an element inside another container", () => {
278
+ const sibling_container = makeRectElement({
279
+ text: "sibling-container",
280
+ x: 10, y: 30, w: 80, h: 45,
281
+ }, false, true);
282
+
283
+ const sibling_intermediate_wrapper = makeRectElement({
284
+ text: "sibling-intermediate-wrapper",
285
+ x: 10, y: 30, w: 80, h: 45,
286
+ }, false, false);
287
+
288
+ const reference_container = makeRectElement(
289
+ { text: "reference", x: 100, width: 100, height: 100 }, false, true
290
+ );
291
+
292
+ const child_a = makeRectElement({ text: "c1", x: 15, y: 35, w: 70, h: 15 }, true)
293
+ const child_b = makeRectElement({ text: "c2", x: 15, y: 55, w: 70, h: 15 }, true)
294
+
295
+ const ref = makeRectElement(
296
+ { text: "reference", x: 100, height: 100 }, true
297
+ );
298
+
299
+ sibling_container.appendChild(sibling_intermediate_wrapper);
300
+ sibling_intermediate_wrapper.appendChild(child_a);
301
+ sibling_intermediate_wrapper.appendChild(child_b);
302
+ reference_container.appendChild(ref);
303
+
304
+ const found = ref.findSpacnavElement(Math.PI);
305
+
306
+ expect(found, "expected to find one of the child elements").not.toBe(null);
307
+ expect(found.textContent.startsWith("c")).toBe(true);
308
+ expect(sibling_container.contains(found)).toBe(true);
309
+ });
310
+
311
+ it("navigates to selectable items inside sibling selection containers", () => {
312
+ const sibling_container = makeRectElement({
313
+ text: "sibling-container",
314
+ x: 10, y: 30, w: 80, h: 45,
315
+ }, false, true);
316
+
317
+ const child_a = makeRectElement({ text: "c1", x: 15, y: 35, w: 70, h: 15 }, true)
318
+ const child_b = makeRectElement({ text: "c2", x: 15, y: 55, w: 70, h: 15 }, true)
319
+
320
+ sibling_container.appendChild(child_a);
321
+ sibling_container.appendChild(child_b);
322
+
323
+ const ref = makeRectElement(
324
+ { text: "reference", x: 100, height: 100 }, true
325
+ );
326
+
327
+ const found = ref.findSpacnavElement(Math.PI);
328
+
329
+ expect(found).not.toBe(null);
330
+ expect(found.textContent.startsWith("c")).toBe(true);
331
+ expect(sibling_container.contains(found)).toBe(true);
332
+
333
+ expect(
334
+ child_a.findSpacnavElement(-2*Math.PI/3)
335
+ ).toBeFalsy();
336
+
337
+ expect(
338
+ child_a.findSpacnavElement(0)
339
+ ).toBe(ref);
340
+ });
341
+ });
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import { playwright } from "@vitest/browser-playwright";
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ browser: {
7
+ enabled: true,
8
+ headless: true,
9
+ screenshotFailures: false,
10
+ provider: playwright(),
11
+ instances: [ { browser: "chromium" } ],
12
+ },
13
+ },
14
+ });
15
+