@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.2

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 (154) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -21
  3. package/README.md +206 -139
  4. package/dist/auto.d.ts +6 -6
  5. package/dist/auto.js +2 -2
  6. package/dist/doctor/cli.mjs +62 -25
  7. package/dist/doctor/cli.mjs.map +1 -1
  8. package/dist/index.d.ts +6 -6
  9. package/dist/index.js +2 -2
  10. package/dist/mp.d.ts +3 -3
  11. package/dist/race.d.ts +1 -1
  12. package/dist/rooms.d.ts +3 -3
  13. package/dist/shared/bridge.js +9 -3
  14. package/dist/shared/bridge.js.map +1 -1
  15. package/dist/shared/client-version.gen.js +1 -1
  16. package/dist/shared/daily.d.ts +10 -4
  17. package/dist/shared/daily.d.ts.map +1 -1
  18. package/dist/shared/daily.js +12 -2
  19. package/dist/shared/daily.js.map +1 -1
  20. package/dist/shared/globals.js +8 -3
  21. package/dist/shared/globals.js.map +1 -1
  22. package/dist/shared/index.d.ts +314 -35
  23. package/dist/shared/index.d.ts.map +1 -1
  24. package/dist/shared/input.js +7 -2
  25. package/dist/shared/input.js.map +1 -1
  26. package/dist/shared/latest-state.js +1 -1
  27. package/dist/shared/mp.js +10 -2
  28. package/dist/shared/mp.js.map +1 -1
  29. package/dist/shared/multiplayer-types.d.ts +15 -2
  30. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  31. package/dist/shared/race-types.d.ts +1 -1
  32. package/dist/shared/race.js +1 -1
  33. package/dist/shared/room-types.d.ts +28 -3
  34. package/dist/shared/room-types.d.ts.map +1 -1
  35. package/dist/shared/rooms.d.ts.map +1 -1
  36. package/dist/shared/rooms.js +10 -2
  37. package/dist/shared/rooms.js.map +1 -1
  38. package/dist/shared/runtime-opener.js +1 -1
  39. package/dist/shared/social.d.ts +23 -8
  40. package/dist/shared/social.d.ts.map +1 -1
  41. package/dist/shared/social.js +6 -1
  42. package/dist/shared/social.js.map +1 -1
  43. package/dist/shared/src.js +1588 -388
  44. package/dist/shared/src.js.map +1 -1
  45. package/dist/shared/wire-types.d.ts +6 -2
  46. package/dist/shared/wire-types.d.ts.map +1 -1
  47. package/dist/social.d.ts +5 -5
  48. package/dist/social.js +1 -1
  49. package/package.json +89 -89
  50. package/src/auto.ts +39 -39
  51. package/src/core/connection.ts +394 -299
  52. package/src/core/errors.ts +147 -147
  53. package/src/core/game-data.ts +40 -38
  54. package/src/core/hub-economy.ts +216 -179
  55. package/src/core/hub-progress.ts +202 -205
  56. package/src/core/hub.ts +133 -131
  57. package/src/core/menu-control.ts +61 -61
  58. package/src/core/offline/events.ts +50 -50
  59. package/src/core/offline/favorites.ts +21 -21
  60. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  61. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  62. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  63. package/src/core/offline/hub-kinds/index.ts +35 -0
  64. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  65. package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
  66. package/src/core/offline/kind-adapters.ts +48 -0
  67. package/src/core/offline/local-inventory.ts +64 -0
  68. package/src/core/offline/local-store.ts +259 -261
  69. package/src/core/offline/offline-api.ts +65 -0
  70. package/src/core/offline/outbox-entry.ts +59 -0
  71. package/src/core/offline/outbox-kinds.ts +78 -0
  72. package/src/core/offline/outbox-merge.ts +39 -0
  73. package/src/core/offline/outbox-replayer.ts +141 -0
  74. package/src/core/offline/outbox-store.ts +85 -0
  75. package/src/core/offline/outbox.ts +232 -0
  76. package/src/core/offline/queue.ts +126 -171
  77. package/src/core/offline/replay-lease.ts +87 -0
  78. package/src/core/offline/replay-policy.ts +52 -0
  79. package/src/core/page.ts +74 -74
  80. package/src/core/rest/endpoint-types.ts +22 -22
  81. package/src/core/rest/rest-client.ts +163 -152
  82. package/src/core/rest/routes.gen.ts +108 -87
  83. package/src/core/storage/json-guards.ts +80 -74
  84. package/src/core/storage/key-value.ts +66 -66
  85. package/src/core/ticket.ts +43 -43
  86. package/src/core/types.ts +134 -134
  87. package/src/core/version/client-version.gen.ts +1 -1
  88. package/src/core/version/hub-version.ts +62 -62
  89. package/src/core/version/semver.ts +97 -97
  90. package/src/daily/daily-system.ts +168 -161
  91. package/src/daily/page-daily.ts +36 -36
  92. package/src/daily/types.ts +68 -67
  93. package/src/daily.ts +10 -10
  94. package/src/doctor/checks.ts +86 -76
  95. package/src/doctor/cli.ts +98 -106
  96. package/src/doctor/hub-version-fetch.ts +43 -0
  97. package/src/doctor/tsconfig.json +12 -12
  98. package/src/globals.ts +115 -110
  99. package/src/index.ts +55 -50
  100. package/src/input/device-state.ts +146 -146
  101. package/src/input/edges.ts +55 -55
  102. package/src/input/gamepad.ts +61 -61
  103. package/src/input/input-events.ts +32 -32
  104. package/src/input/input-registry.ts +171 -164
  105. package/src/input/input-system.ts +393 -393
  106. package/src/input/legacy-controls.ts +64 -64
  107. package/src/input/nav-state.ts +191 -191
  108. package/src/input/navigator.ts +171 -171
  109. package/src/input/on-screen-controls.ts +101 -101
  110. package/src/input/page-control.ts +43 -43
  111. package/src/input/page-input.ts +193 -193
  112. package/src/input/player-remaps.ts +77 -77
  113. package/src/input/source-values.ts +116 -116
  114. package/src/input/spatial.ts +47 -47
  115. package/src/input/types.ts +331 -331
  116. package/src/input/zero-config.ts +177 -177
  117. package/src/input.ts +22 -22
  118. package/src/mp.ts +10 -10
  119. package/src/overlay/controls-panel.ts +163 -163
  120. package/src/overlay/dom.ts +46 -46
  121. package/src/overlay/layout-clusters.ts +162 -162
  122. package/src/overlay/layout-geometry.ts +136 -136
  123. package/src/overlay/layout.ts +203 -203
  124. package/src/overlay/listener-scope.ts +23 -23
  125. package/src/overlay/on-screen-keyboard.ts +137 -137
  126. package/src/overlay/overlay.ts +208 -208
  127. package/src/overlay/styles.ts +9 -9
  128. package/src/overlay/virtual-controls.ts +262 -262
  129. package/src/race.ts +10 -10
  130. package/src/realtime/bridge.ts +126 -121
  131. package/src/realtime/cross-bundle.ts +29 -29
  132. package/src/realtime/fallback-notice.ts +48 -48
  133. package/src/realtime/hub-link.ts +65 -65
  134. package/src/realtime/latest-state.ts +33 -33
  135. package/src/realtime/multiplayer-forwarder.ts +40 -28
  136. package/src/realtime/multiplayer-types.ts +66 -52
  137. package/src/realtime/page-runtime.ts +60 -60
  138. package/src/realtime/race-forwarder.ts +59 -59
  139. package/src/realtime/race-types.ts +132 -132
  140. package/src/realtime/room-types.ts +151 -125
  141. package/src/realtime/rooms-forwarder.ts +91 -79
  142. package/src/realtime/runtime-api.ts +60 -49
  143. package/src/realtime/runtime-opener.ts +61 -61
  144. package/src/realtime/social-forwarder.ts +77 -72
  145. package/src/realtime/social-types.ts +86 -78
  146. package/src/realtime/types.ts +53 -53
  147. package/src/realtime/wire-types.ts +79 -72
  148. package/src/rooms.ts +10 -10
  149. package/src/social.ts +18 -18
  150. package/src/types.ts +13 -13
  151. package/src/uistack/page-stack.ts +45 -45
  152. package/src/uistack/play-suspension.ts +82 -82
  153. package/src/uistack/types.ts +76 -76
  154. 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
+ }