@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 +136 -0
- package/package.json +22 -0
- package/spatnav.js +631 -0
- package/spatnav.test.js +341 -0
- package/vitest.config.js +15 -0
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
|
+
}
|
package/spatnav.test.js
ADDED
|
@@ -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
|
+
});
|
package/vitest.config.js
ADDED
|
@@ -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
|
+
|