@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.1
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 +9 -0
- package/LICENSE +21 -21
- package/README.md +191 -139
- package/dist/doctor/cli.mjs +49 -19
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/globals.js +8 -3
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1 -1
- package/package.json +89 -89
- package/src/auto.ts +39 -39
- package/src/core/connection.ts +299 -299
- package/src/core/errors.ts +147 -147
- package/src/core/game-data.ts +38 -38
- package/src/core/hub-economy.ts +179 -179
- package/src/core/hub-progress.ts +205 -205
- package/src/core/hub.ts +131 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- package/src/core/offline/local-store.ts +261 -261
- package/src/core/offline/queue.ts +171 -171
- package/src/core/page.ts +74 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +152 -152
- package/src/core/storage/json-guards.ts +74 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- package/src/core/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +161 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +67 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +78 -76
- package/src/doctor/cli.ts +98 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/globals.ts +115 -110
- package/src/index.ts +50 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +164 -164
- package/src/input/input-system.ts +393 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/nav-state.ts +191 -191
- package/src/input/navigator.ts +171 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +177 -177
- package/src/input.ts +22 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +208 -208
- package/src/overlay/styles.ts +9 -9
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +121 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +28 -28
- package/src/realtime/multiplayer-types.ts +52 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +125 -125
- package/src/realtime/rooms-forwarder.ts +79 -79
- package/src/realtime/runtime-api.ts +49 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +72 -72
- package/src/realtime/social-types.ts +78 -78
- package/src/realtime/types.ts +53 -53
- package/src/realtime/wire-types.ts +72 -72
- package/src/rooms.ts +10 -10
- package/src/social.ts +18 -18
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `game.controls` (`window.__HUB__.controls`): the gamepad-to-keyboard map a
|
|
3
|
-
* zero-config game declares. It comes from the page, so it is read field by
|
|
4
|
-
* field; anything malformed falls back to the defaults.
|
|
5
|
-
*/
|
|
6
|
-
import { isObject, readField } from '../realtime/cross-bundle';
|
|
7
|
-
|
|
8
|
-
/** One player's resolved map: a `KeyboardEvent.code` per direction and per button index. */
|
|
9
|
-
export interface ResolvedControlMap {
|
|
10
|
-
up: string;
|
|
11
|
-
down: string;
|
|
12
|
-
left: string;
|
|
13
|
-
right: string;
|
|
14
|
-
buttons: Map<number, string>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** The parsed `game.controls`: one map per player (pads past the end use the first). */
|
|
18
|
-
export type LegacyControls = readonly ResolvedControlMap[];
|
|
19
|
-
|
|
20
|
-
/** The defaults: arrows, A=Space, B=Enter, X=Z, Y=X, Select=Escape, Start=Enter. */
|
|
21
|
-
const DEFAULT_BUTTONS: ReadonlyArray<readonly [number, string]> = [[0, 'Space'], [1, 'Enter'], [2, 'KeyZ'], [3, 'KeyX'], [9, 'Enter'], [8, 'Escape']];
|
|
22
|
-
|
|
23
|
-
/** A text field of the map, or the default. */
|
|
24
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
25
|
-
function textOr(map: unknown, key: string, fallback: string): string {
|
|
26
|
-
const field = readField(map, key);
|
|
27
|
-
return typeof field === 'string' && field !== '' ? field : fallback;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** One player's map over the defaults. */
|
|
31
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
32
|
-
function resolveMap(map: unknown): ResolvedControlMap {
|
|
33
|
-
const buttons = new Map<number, string>(DEFAULT_BUTTONS);
|
|
34
|
-
const declared = readField(map, 'buttons');
|
|
35
|
-
if (isObject(declared)) {
|
|
36
|
-
for (const [index, code] of Object.entries(declared)) {
|
|
37
|
-
if (typeof code === 'string' && code !== '' && /^\d+$/.test(index)) {
|
|
38
|
-
buttons.set(Number(index), code);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return {
|
|
43
|
-
up: textOr(map, 'up', 'ArrowUp'),
|
|
44
|
-
down: textOr(map, 'down', 'ArrowDown'),
|
|
45
|
-
left: textOr(map, 'left', 'ArrowLeft'),
|
|
46
|
-
right: textOr(map, 'right', 'ArrowRight'),
|
|
47
|
-
buttons,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Parse `game.controls` (a shared map, or per-player maps in `players`). */
|
|
52
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
53
|
-
export function parseLegacyControls(raw: unknown): LegacyControls {
|
|
54
|
-
const players = readField(raw, 'players');
|
|
55
|
-
if (Array.isArray(players) && players.length > 0) {
|
|
56
|
-
return players.map(resolveMap);
|
|
57
|
-
}
|
|
58
|
-
return [resolveMap(raw)];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** The map for the pad at `padIndex`. */
|
|
62
|
-
export function controlMapFor(controls: LegacyControls, padIndex: number): ResolvedControlMap {
|
|
63
|
-
return controls[padIndex] ?? controls[0] ?? resolveMap(null);
|
|
64
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `game.controls` (`window.__HUB__.controls`): the gamepad-to-keyboard map a
|
|
3
|
+
* zero-config game declares. It comes from the page, so it is read field by
|
|
4
|
+
* field; anything malformed falls back to the defaults.
|
|
5
|
+
*/
|
|
6
|
+
import { isObject, readField } from '../realtime/cross-bundle';
|
|
7
|
+
|
|
8
|
+
/** One player's resolved map: a `KeyboardEvent.code` per direction and per button index. */
|
|
9
|
+
export interface ResolvedControlMap {
|
|
10
|
+
up: string;
|
|
11
|
+
down: string;
|
|
12
|
+
left: string;
|
|
13
|
+
right: string;
|
|
14
|
+
buttons: Map<number, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The parsed `game.controls`: one map per player (pads past the end use the first). */
|
|
18
|
+
export type LegacyControls = readonly ResolvedControlMap[];
|
|
19
|
+
|
|
20
|
+
/** The defaults: arrows, A=Space, B=Enter, X=Z, Y=X, Select=Escape, Start=Enter. */
|
|
21
|
+
const DEFAULT_BUTTONS: ReadonlyArray<readonly [number, string]> = [[0, 'Space'], [1, 'Enter'], [2, 'KeyZ'], [3, 'KeyX'], [9, 'Enter'], [8, 'Escape']];
|
|
22
|
+
|
|
23
|
+
/** A text field of the map, or the default. */
|
|
24
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
25
|
+
function textOr(map: unknown, key: string, fallback: string): string {
|
|
26
|
+
const field = readField(map, key);
|
|
27
|
+
return typeof field === 'string' && field !== '' ? field : fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One player's map over the defaults. */
|
|
31
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
32
|
+
function resolveMap(map: unknown): ResolvedControlMap {
|
|
33
|
+
const buttons = new Map<number, string>(DEFAULT_BUTTONS);
|
|
34
|
+
const declared = readField(map, 'buttons');
|
|
35
|
+
if (isObject(declared)) {
|
|
36
|
+
for (const [index, code] of Object.entries(declared)) {
|
|
37
|
+
if (typeof code === 'string' && code !== '' && /^\d+$/.test(index)) {
|
|
38
|
+
buttons.set(Number(index), code);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
up: textOr(map, 'up', 'ArrowUp'),
|
|
44
|
+
down: textOr(map, 'down', 'ArrowDown'),
|
|
45
|
+
left: textOr(map, 'left', 'ArrowLeft'),
|
|
46
|
+
right: textOr(map, 'right', 'ArrowRight'),
|
|
47
|
+
buttons,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Parse `game.controls` (a shared map, or per-player maps in `players`). */
|
|
52
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page
|
|
53
|
+
export function parseLegacyControls(raw: unknown): LegacyControls {
|
|
54
|
+
const players = readField(raw, 'players');
|
|
55
|
+
if (Array.isArray(players) && players.length > 0) {
|
|
56
|
+
return players.map(resolveMap);
|
|
57
|
+
}
|
|
58
|
+
return [resolveMap(raw)];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The map for the pad at `padIndex`. */
|
|
62
|
+
export function controlMapFor(controls: LegacyControls, padIndex: number): ResolvedControlMap {
|
|
63
|
+
return controls[padIndex] ?? controls[0] ?? resolveMap(null);
|
|
64
|
+
}
|
package/src/input/nav-state.ts
CHANGED
|
@@ -1,191 +1,191 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One navigable list: the highlighted element, stepping and spatial moves,
|
|
3
|
-
* and activating it. The list is re-queried on every interaction because the
|
|
4
|
-
* DOM can change under it.
|
|
5
|
-
*/
|
|
6
|
-
import type { Direction } from './gamepad';
|
|
7
|
-
import { findNearestInDirection, rectOf } from './spatial';
|
|
8
|
-
import type { NavBlurEvent, NavElement, NavElementKind, NavFocusEvent, NavTarget, Rect } from './types';
|
|
9
|
-
|
|
10
|
-
/** What a navigable list draws with (the overlay). */
|
|
11
|
-
export interface NavHighlighter {
|
|
12
|
-
/** Ring the highlighted element. */
|
|
13
|
-
showHighlight(bounds: Rect): void;
|
|
14
|
-
/** Remove the ring. */
|
|
15
|
-
hideHighlight(): void;
|
|
16
|
-
/** Open the on-screen keyboard for a text field. */
|
|
17
|
-
openKeyboard(target: HTMLInputElement | HTMLTextAreaElement | null): void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Where a list reports focus changes and activations. */
|
|
21
|
-
export interface NavListeners {
|
|
22
|
-
/** An element gained the highlight. */
|
|
23
|
-
onFocus(event: NavFocusEvent): void;
|
|
24
|
-
/** An element lost the highlight. */
|
|
25
|
-
onBlur(event: NavBlurEvent): void;
|
|
26
|
-
/** The player activated an element. */
|
|
27
|
-
onAct(event: NavFocusEvent): void;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'password', 'number']);
|
|
31
|
-
|
|
32
|
-
/** What kind of element a DOM node is, for `act`. */
|
|
33
|
-
function kindOf(element: HTMLElement): NavElementKind {
|
|
34
|
-
if (element instanceof HTMLInputElement) {
|
|
35
|
-
if (element.type === 'range') {
|
|
36
|
-
return 'range';
|
|
37
|
-
}
|
|
38
|
-
if (TEXT_INPUT_TYPES.has(element.type)) {
|
|
39
|
-
return 'text';
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return element instanceof HTMLTextAreaElement ? 'text' : 'button';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** A navigable element's bounds. */
|
|
46
|
-
function boundsOf(element: NavElement): Rect {
|
|
47
|
-
if (element.getBounds !== undefined) {
|
|
48
|
-
return element.getBounds();
|
|
49
|
-
}
|
|
50
|
-
return element.el === undefined ? { x: 0, y: 0, width: 0, height: 0 } : rectOf(element.el);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The text field a navigable element edits, if it is one. */
|
|
54
|
-
function textFieldOf(element: NavElement): HTMLInputElement | HTMLTextAreaElement | null {
|
|
55
|
-
return element.el instanceof HTMLInputElement || element.el instanceof HTMLTextAreaElement ? element.el : null;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The highlight state of one navigable list. */
|
|
59
|
-
export class NavState {
|
|
60
|
-
private elements: NavElement[] = [];
|
|
61
|
-
private index = -1;
|
|
62
|
-
private readonly queryElements: () => NavTarget[];
|
|
63
|
-
private readonly shouldWrap: boolean;
|
|
64
|
-
|
|
65
|
-
constructor(queryElements: () => NavTarget[], shouldWrap = true) {
|
|
66
|
-
this.queryElements = queryElements;
|
|
67
|
-
this.shouldWrap = shouldWrap;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Re-read the list, highlighting the first element if nothing is. */
|
|
71
|
-
requery(highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
72
|
-
this.elements = this.resolve();
|
|
73
|
-
if (this.index >= this.elements.length) {
|
|
74
|
-
this.index = this.elements.length - 1;
|
|
75
|
-
}
|
|
76
|
-
if (this.index < 0 && this.elements.length > 0) {
|
|
77
|
-
this.index = 0;
|
|
78
|
-
this.focusCurrent(listeners);
|
|
79
|
-
}
|
|
80
|
-
this.position(highlighter);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Drop the highlight. */
|
|
84
|
-
blur(highlighter: NavHighlighter | null): void {
|
|
85
|
-
this.elements[this.index]?.onBlur?.();
|
|
86
|
-
this.index = -1;
|
|
87
|
-
highlighter?.hideHighlight();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Step to the next (+1) or previous (-1) element. */
|
|
91
|
-
step(delta: number, highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
92
|
-
this.elements = this.resolve();
|
|
93
|
-
const count = this.elements.length;
|
|
94
|
-
if (count === 0) {
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
this.blurCurrent(listeners);
|
|
98
|
-
let next = this.index + delta;
|
|
99
|
-
if (next < 0) {
|
|
100
|
-
next = this.shouldWrap ? count - 1 : 0;
|
|
101
|
-
}
|
|
102
|
-
if (next >= count) {
|
|
103
|
-
next = this.shouldWrap ? 0 : count - 1;
|
|
104
|
-
}
|
|
105
|
-
this.moveTo(next, highlighter, listeners);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Move to the nearest element in `direction`; up and down spill over to the previous and next column. */
|
|
109
|
-
moveInDirection(direction: Direction, highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
110
|
-
this.elements = this.resolve();
|
|
111
|
-
if (this.elements.length === 0) {
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
if (this.index < 0) {
|
|
115
|
-
this.moveTo(0, highlighter, listeners);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
const rects = this.elements.map(boundsOf);
|
|
119
|
-
let target = findNearestInDirection(rects, this.index, direction);
|
|
120
|
-
// Nothing directly that way: up falls through to the left, down to the right, so columns never dead-end.
|
|
121
|
-
const spillover = { up: 'left', down: 'right', left: null, right: null } as const;
|
|
122
|
-
const fallback = spillover[direction];
|
|
123
|
-
if (target < 0 && fallback !== null) {
|
|
124
|
-
target = findNearestInDirection(rects, this.index, fallback);
|
|
125
|
-
}
|
|
126
|
-
if (target >= 0) {
|
|
127
|
-
this.blurCurrent(listeners);
|
|
128
|
-
this.moveTo(target, highlighter, listeners);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Activate the highlighted element: open the keyboard for text, click anything else. */
|
|
133
|
-
act(highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
134
|
-
this.elements = this.resolve();
|
|
135
|
-
const current = this.elements[this.index];
|
|
136
|
-
if (current === undefined) {
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (current.kind === 'text') {
|
|
140
|
-
highlighter?.openKeyboard(textFieldOf(current));
|
|
141
|
-
} else {
|
|
142
|
-
current.el?.click();
|
|
143
|
-
}
|
|
144
|
-
current.onAct?.();
|
|
145
|
-
listeners.onAct({ id: current.id, el: current.el, kind: current.kind, index: this.index });
|
|
146
|
-
// Acting can change the elements.
|
|
147
|
-
this.elements = this.resolve();
|
|
148
|
-
this.position(highlighter);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Move the ring to the highlighted element (or hide it). */
|
|
152
|
-
position(highlighter: NavHighlighter | null): void {
|
|
153
|
-
if (highlighter === null) {
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
const current = this.elements[this.index];
|
|
157
|
-
if (current === undefined) {
|
|
158
|
-
highlighter.hideHighlight();
|
|
159
|
-
} else {
|
|
160
|
-
highlighter.showHighlight(boundsOf(current));
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
private resolve(): NavElement[] {
|
|
165
|
-
return this.queryElements().map((target) => (target instanceof HTMLElement ? { el: target, kind: kindOf(target) } : target));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
private moveTo(index: number, highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
169
|
-
this.index = index;
|
|
170
|
-
this.focusCurrent(listeners);
|
|
171
|
-
this.position(highlighter);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
private focusCurrent(listeners: NavListeners): void {
|
|
175
|
-
const current = this.elements[this.index];
|
|
176
|
-
if (current === undefined) {
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
current.el?.focus({ preventScroll: false });
|
|
180
|
-
current.onFocus?.();
|
|
181
|
-
listeners.onFocus({ id: current.id, el: current.el, kind: current.kind, index: this.index });
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
private blurCurrent(listeners: NavListeners): void {
|
|
185
|
-
const current = this.elements[this.index];
|
|
186
|
-
if (current?.onBlur !== undefined) {
|
|
187
|
-
current.onBlur();
|
|
188
|
-
listeners.onBlur({ id: current.id, index: this.index });
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* One navigable list: the highlighted element, stepping and spatial moves,
|
|
3
|
+
* and activating it. The list is re-queried on every interaction because the
|
|
4
|
+
* DOM can change under it.
|
|
5
|
+
*/
|
|
6
|
+
import type { Direction } from './gamepad';
|
|
7
|
+
import { findNearestInDirection, rectOf } from './spatial';
|
|
8
|
+
import type { NavBlurEvent, NavElement, NavElementKind, NavFocusEvent, NavTarget, Rect } from './types';
|
|
9
|
+
|
|
10
|
+
/** What a navigable list draws with (the overlay). */
|
|
11
|
+
export interface NavHighlighter {
|
|
12
|
+
/** Ring the highlighted element. */
|
|
13
|
+
showHighlight(bounds: Rect): void;
|
|
14
|
+
/** Remove the ring. */
|
|
15
|
+
hideHighlight(): void;
|
|
16
|
+
/** Open the on-screen keyboard for a text field. */
|
|
17
|
+
openKeyboard(target: HTMLInputElement | HTMLTextAreaElement | null): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Where a list reports focus changes and activations. */
|
|
21
|
+
export interface NavListeners {
|
|
22
|
+
/** An element gained the highlight. */
|
|
23
|
+
onFocus(event: NavFocusEvent): void;
|
|
24
|
+
/** An element lost the highlight. */
|
|
25
|
+
onBlur(event: NavBlurEvent): void;
|
|
26
|
+
/** The player activated an element. */
|
|
27
|
+
onAct(event: NavFocusEvent): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TEXT_INPUT_TYPES = new Set(['text', 'search', 'email', 'password', 'number']);
|
|
31
|
+
|
|
32
|
+
/** What kind of element a DOM node is, for `act`. */
|
|
33
|
+
function kindOf(element: HTMLElement): NavElementKind {
|
|
34
|
+
if (element instanceof HTMLInputElement) {
|
|
35
|
+
if (element.type === 'range') {
|
|
36
|
+
return 'range';
|
|
37
|
+
}
|
|
38
|
+
if (TEXT_INPUT_TYPES.has(element.type)) {
|
|
39
|
+
return 'text';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return element instanceof HTMLTextAreaElement ? 'text' : 'button';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A navigable element's bounds. */
|
|
46
|
+
function boundsOf(element: NavElement): Rect {
|
|
47
|
+
if (element.getBounds !== undefined) {
|
|
48
|
+
return element.getBounds();
|
|
49
|
+
}
|
|
50
|
+
return element.el === undefined ? { x: 0, y: 0, width: 0, height: 0 } : rectOf(element.el);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The text field a navigable element edits, if it is one. */
|
|
54
|
+
function textFieldOf(element: NavElement): HTMLInputElement | HTMLTextAreaElement | null {
|
|
55
|
+
return element.el instanceof HTMLInputElement || element.el instanceof HTMLTextAreaElement ? element.el : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The highlight state of one navigable list. */
|
|
59
|
+
export class NavState {
|
|
60
|
+
private elements: NavElement[] = [];
|
|
61
|
+
private index = -1;
|
|
62
|
+
private readonly queryElements: () => NavTarget[];
|
|
63
|
+
private readonly shouldWrap: boolean;
|
|
64
|
+
|
|
65
|
+
constructor(queryElements: () => NavTarget[], shouldWrap = true) {
|
|
66
|
+
this.queryElements = queryElements;
|
|
67
|
+
this.shouldWrap = shouldWrap;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Re-read the list, highlighting the first element if nothing is. */
|
|
71
|
+
requery(highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
72
|
+
this.elements = this.resolve();
|
|
73
|
+
if (this.index >= this.elements.length) {
|
|
74
|
+
this.index = this.elements.length - 1;
|
|
75
|
+
}
|
|
76
|
+
if (this.index < 0 && this.elements.length > 0) {
|
|
77
|
+
this.index = 0;
|
|
78
|
+
this.focusCurrent(listeners);
|
|
79
|
+
}
|
|
80
|
+
this.position(highlighter);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Drop the highlight. */
|
|
84
|
+
blur(highlighter: NavHighlighter | null): void {
|
|
85
|
+
this.elements[this.index]?.onBlur?.();
|
|
86
|
+
this.index = -1;
|
|
87
|
+
highlighter?.hideHighlight();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Step to the next (+1) or previous (-1) element. */
|
|
91
|
+
step(delta: number, highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
92
|
+
this.elements = this.resolve();
|
|
93
|
+
const count = this.elements.length;
|
|
94
|
+
if (count === 0) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.blurCurrent(listeners);
|
|
98
|
+
let next = this.index + delta;
|
|
99
|
+
if (next < 0) {
|
|
100
|
+
next = this.shouldWrap ? count - 1 : 0;
|
|
101
|
+
}
|
|
102
|
+
if (next >= count) {
|
|
103
|
+
next = this.shouldWrap ? 0 : count - 1;
|
|
104
|
+
}
|
|
105
|
+
this.moveTo(next, highlighter, listeners);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Move to the nearest element in `direction`; up and down spill over to the previous and next column. */
|
|
109
|
+
moveInDirection(direction: Direction, highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
110
|
+
this.elements = this.resolve();
|
|
111
|
+
if (this.elements.length === 0) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (this.index < 0) {
|
|
115
|
+
this.moveTo(0, highlighter, listeners);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const rects = this.elements.map(boundsOf);
|
|
119
|
+
let target = findNearestInDirection(rects, this.index, direction);
|
|
120
|
+
// Nothing directly that way: up falls through to the left, down to the right, so columns never dead-end.
|
|
121
|
+
const spillover = { up: 'left', down: 'right', left: null, right: null } as const;
|
|
122
|
+
const fallback = spillover[direction];
|
|
123
|
+
if (target < 0 && fallback !== null) {
|
|
124
|
+
target = findNearestInDirection(rects, this.index, fallback);
|
|
125
|
+
}
|
|
126
|
+
if (target >= 0) {
|
|
127
|
+
this.blurCurrent(listeners);
|
|
128
|
+
this.moveTo(target, highlighter, listeners);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Activate the highlighted element: open the keyboard for text, click anything else. */
|
|
133
|
+
act(highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
134
|
+
this.elements = this.resolve();
|
|
135
|
+
const current = this.elements[this.index];
|
|
136
|
+
if (current === undefined) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (current.kind === 'text') {
|
|
140
|
+
highlighter?.openKeyboard(textFieldOf(current));
|
|
141
|
+
} else {
|
|
142
|
+
current.el?.click();
|
|
143
|
+
}
|
|
144
|
+
current.onAct?.();
|
|
145
|
+
listeners.onAct({ id: current.id, el: current.el, kind: current.kind, index: this.index });
|
|
146
|
+
// Acting can change the elements.
|
|
147
|
+
this.elements = this.resolve();
|
|
148
|
+
this.position(highlighter);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Move the ring to the highlighted element (or hide it). */
|
|
152
|
+
position(highlighter: NavHighlighter | null): void {
|
|
153
|
+
if (highlighter === null) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const current = this.elements[this.index];
|
|
157
|
+
if (current === undefined) {
|
|
158
|
+
highlighter.hideHighlight();
|
|
159
|
+
} else {
|
|
160
|
+
highlighter.showHighlight(boundsOf(current));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private resolve(): NavElement[] {
|
|
165
|
+
return this.queryElements().map((target) => (target instanceof HTMLElement ? { el: target, kind: kindOf(target) } : target));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private moveTo(index: number, highlighter: NavHighlighter | null, listeners: NavListeners): void {
|
|
169
|
+
this.index = index;
|
|
170
|
+
this.focusCurrent(listeners);
|
|
171
|
+
this.position(highlighter);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private focusCurrent(listeners: NavListeners): void {
|
|
175
|
+
const current = this.elements[this.index];
|
|
176
|
+
if (current === undefined) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
current.el?.focus({ preventScroll: false });
|
|
180
|
+
current.onFocus?.();
|
|
181
|
+
listeners.onFocus({ id: current.id, el: current.el, kind: current.kind, index: this.index });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private blurCurrent(listeners: NavListeners): void {
|
|
185
|
+
const current = this.elements[this.index];
|
|
186
|
+
if (current?.onBlur !== undefined) {
|
|
187
|
+
current.onBlur();
|
|
188
|
+
listeners.onBlur({ id: current.id, index: this.index });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|