@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -21
  3. package/README.md +191 -139
  4. package/dist/doctor/cli.mjs +49 -19
  5. package/dist/doctor/cli.mjs.map +1 -1
  6. package/dist/shared/client-version.gen.js +1 -1
  7. package/dist/shared/daily.js +1 -1
  8. package/dist/shared/globals.js +8 -3
  9. package/dist/shared/globals.js.map +1 -1
  10. package/dist/shared/index.d.ts +1 -1
  11. package/package.json +89 -89
  12. package/src/auto.ts +39 -39
  13. package/src/core/connection.ts +299 -299
  14. package/src/core/errors.ts +147 -147
  15. package/src/core/game-data.ts +38 -38
  16. package/src/core/hub-economy.ts +179 -179
  17. package/src/core/hub-progress.ts +205 -205
  18. package/src/core/hub.ts +131 -131
  19. package/src/core/menu-control.ts +61 -61
  20. package/src/core/offline/events.ts +50 -50
  21. package/src/core/offline/favorites.ts +21 -21
  22. package/src/core/offline/local-store.ts +261 -261
  23. package/src/core/offline/queue.ts +171 -171
  24. package/src/core/page.ts +74 -74
  25. package/src/core/rest/endpoint-types.ts +22 -22
  26. package/src/core/rest/rest-client.ts +152 -152
  27. package/src/core/storage/json-guards.ts +74 -74
  28. package/src/core/storage/key-value.ts +66 -66
  29. package/src/core/ticket.ts +43 -43
  30. package/src/core/types.ts +134 -134
  31. package/src/core/version/client-version.gen.ts +1 -1
  32. package/src/core/version/hub-version.ts +62 -62
  33. package/src/core/version/semver.ts +97 -97
  34. package/src/daily/daily-system.ts +161 -161
  35. package/src/daily/page-daily.ts +36 -36
  36. package/src/daily/types.ts +67 -67
  37. package/src/daily.ts +10 -10
  38. package/src/doctor/checks.ts +78 -76
  39. package/src/doctor/cli.ts +98 -106
  40. package/src/doctor/hub-version-fetch.ts +43 -0
  41. package/src/doctor/tsconfig.json +12 -12
  42. package/src/globals.ts +115 -110
  43. package/src/index.ts +50 -50
  44. package/src/input/device-state.ts +146 -146
  45. package/src/input/edges.ts +55 -55
  46. package/src/input/gamepad.ts +61 -61
  47. package/src/input/input-events.ts +32 -32
  48. package/src/input/input-registry.ts +164 -164
  49. package/src/input/input-system.ts +393 -393
  50. package/src/input/legacy-controls.ts +64 -64
  51. package/src/input/nav-state.ts +191 -191
  52. package/src/input/navigator.ts +171 -171
  53. package/src/input/on-screen-controls.ts +101 -101
  54. package/src/input/page-control.ts +43 -43
  55. package/src/input/page-input.ts +193 -193
  56. package/src/input/player-remaps.ts +77 -77
  57. package/src/input/source-values.ts +116 -116
  58. package/src/input/spatial.ts +47 -47
  59. package/src/input/types.ts +331 -331
  60. package/src/input/zero-config.ts +177 -177
  61. package/src/input.ts +22 -22
  62. package/src/mp.ts +10 -10
  63. package/src/overlay/controls-panel.ts +163 -163
  64. package/src/overlay/dom.ts +46 -46
  65. package/src/overlay/layout-clusters.ts +162 -162
  66. package/src/overlay/layout-geometry.ts +136 -136
  67. package/src/overlay/layout.ts +203 -203
  68. package/src/overlay/listener-scope.ts +23 -23
  69. package/src/overlay/on-screen-keyboard.ts +137 -137
  70. package/src/overlay/overlay.ts +208 -208
  71. package/src/overlay/styles.ts +9 -9
  72. package/src/overlay/virtual-controls.ts +262 -262
  73. package/src/race.ts +10 -10
  74. package/src/realtime/bridge.ts +121 -121
  75. package/src/realtime/cross-bundle.ts +29 -29
  76. package/src/realtime/fallback-notice.ts +48 -48
  77. package/src/realtime/hub-link.ts +65 -65
  78. package/src/realtime/latest-state.ts +33 -33
  79. package/src/realtime/multiplayer-forwarder.ts +28 -28
  80. package/src/realtime/multiplayer-types.ts +52 -52
  81. package/src/realtime/page-runtime.ts +60 -60
  82. package/src/realtime/race-forwarder.ts +59 -59
  83. package/src/realtime/race-types.ts +132 -132
  84. package/src/realtime/room-types.ts +125 -125
  85. package/src/realtime/rooms-forwarder.ts +79 -79
  86. package/src/realtime/runtime-api.ts +49 -49
  87. package/src/realtime/runtime-opener.ts +61 -61
  88. package/src/realtime/social-forwarder.ts +72 -72
  89. package/src/realtime/social-types.ts +78 -78
  90. package/src/realtime/types.ts +53 -53
  91. package/src/realtime/wire-types.ts +72 -72
  92. package/src/rooms.ts +10 -10
  93. package/src/social.ts +18 -18
  94. package/src/types.ts +13 -13
  95. package/src/uistack/page-stack.ts +45 -45
  96. package/src/uistack/play-suspension.ts +82 -82
  97. package/src/uistack/types.ts +76 -76
  98. 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
+ }
@@ -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
+ }