@dloizides/game-input 1.4.0 → 1.5.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 +45 -36
- package/dist/index.d.mts +58 -1
- package/dist/index.d.ts +58 -1
- package/dist/index.js +376 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +364 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,36 +1,45 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `@dloizides/game-input` are documented here.
|
|
4
|
-
|
|
5
|
-
## 1.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- `
|
|
32
|
-
|
|
33
|
-
- `
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@dloizides/game-input` are documented here.
|
|
4
|
+
|
|
5
|
+
## 1.5.1
|
|
6
|
+
|
|
7
|
+
- Pad back dispatches Escape at the focused element so a dialog host that handles Escape and stops propagation still closes (NAV-1).
|
|
8
|
+
|
|
9
|
+
## 1.5.0
|
|
10
|
+
|
|
11
|
+
- `createMenuNav`: one menu focus engine for arrow keys (capture phase) and pad actions; top-most dialog owns focus, focus restored on close, sliders/selects step on Left/Right, text fields keep arrows until Escape/B, `data-nav-input` on <html> (NAV-1 "Every game menu works with arrow keys and controller").
|
|
12
|
+
- `MENU_FOCUSABLE_SELECTOR`, `menuRoot`, `focusables`, `pickTarget` exported (pickTarget moved from Surge).
|
|
13
|
+
|
|
14
|
+
## 1.4.0
|
|
15
|
+
|
|
16
|
+
GI-HAPTICS-1 "Haptics vibrate only after a user activation".
|
|
17
|
+
|
|
18
|
+
- `createHaptics` no longer calls `navigator.vibrate` before a user gesture (Chrome blocks it and logs an
|
|
19
|
+
intervention). Gate: `navigator.userActivation.hasBeenActive` where present, else a capture-phase latch on
|
|
20
|
+
the first `pointerdown` / `keydown` / `touchstart` of `activationTarget` (new option, default `globalThis`).
|
|
21
|
+
- The enable-confirmation tick follows the same gate. Disabled and unsupported stay silent no-ops.
|
|
22
|
+
- New exports: `ActivationTarget` type, `USER_ACTIVATION_EVENTS`.
|
|
23
|
+
|
|
24
|
+
## 1.3.0
|
|
25
|
+
|
|
26
|
+
Standard Gamepad action poller (first consumer: NextGame console-dashboard UI).
|
|
27
|
+
|
|
28
|
+
- `stepPad(prev, next, repeat, now)` — pure per-frame reducer: face/shoulder buttons fire on the
|
|
29
|
+
rising edge by W3C Standard POSITION; D-pad or left stick (0.5 dead-zone, dominant axis) fires
|
|
30
|
+
once, repeats after 380 ms, then every 110 ms; release resets the repeat.
|
|
31
|
+
- `createGamepadPoller({ getPads, requestFrame, cancelFrame, onAction })` — injectable rAF loop over
|
|
32
|
+
the first connected pad; a disappearing pad resets state; `stop()` is safe from inside `onAction`.
|
|
33
|
+
- `PAD_ACTIONS`, `STANDARD_BUTTON`, `IDLE_REPEAT` + types `PadAction`, `PadSnapshot`, `RepeatState`,
|
|
34
|
+
`PadLike`, `GamepadPollerOptions`.
|
|
35
|
+
|
|
36
|
+
## 1.0.0
|
|
37
|
+
|
|
38
|
+
Initial release — the haptics primitive of the C4 games shared kit.
|
|
39
|
+
|
|
40
|
+
- `createHaptics(options)` — player-toggleable, always-safe Web Vibration API wrapper
|
|
41
|
+
(localStorage-gated, structural/injectable `navigator` + `storage`, never throws into the game loop).
|
|
42
|
+
- `HAPTIC_PATTERNS` — shared semantic vibration vocabulary (tap/select/lock/bump/navigate/success/error/loss/reveal).
|
|
43
|
+
- Zero runtime dependencies; isomorphic; dual CJS/ESM; 100% test coverage.
|
|
44
|
+
|
|
45
|
+
Extracted from Aurora's `src/input/haptics.ts`. First consumers: Aurora (re-export shim) and Morphe (new adoption).
|
package/dist/index.d.mts
CHANGED
|
@@ -70,6 +70,63 @@ declare function stepPad(prev: PadSnapshot | null, next: PadSnapshot, repeat: Re
|
|
|
70
70
|
repeat: RepeatState;
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
interface MenuNavOptions {
|
|
74
|
+
/** The game's base menu root when no dialog is open; null = no menu (e.g. during play). */
|
|
75
|
+
baseRoot: () => HTMLElement | null;
|
|
76
|
+
/** True while input must not move focus (e.g. a remap capture). Default: never. */
|
|
77
|
+
suspended?: () => boolean;
|
|
78
|
+
/** Where focus goes when a root opens. Default: [data-menu-default], else the first control. */
|
|
79
|
+
initialFocus?: (root: HTMLElement) => HTMLElement | null;
|
|
80
|
+
}
|
|
81
|
+
interface MenuNav {
|
|
82
|
+
/** Feed one pad action from stepPad (the game keeps its own poller). */
|
|
83
|
+
pad(action: PadAction): void;
|
|
84
|
+
/** Re-evaluate the root and place focus (call after the game opens a screen). */
|
|
85
|
+
refresh(): void;
|
|
86
|
+
dispose(): void;
|
|
87
|
+
}
|
|
88
|
+
/** One focus engine for every game menu: arrow keys (capture phase), pad actions, dialogs on top, sliders and text fields. */
|
|
89
|
+
declare function createMenuNav(options: MenuNavOptions): MenuNav;
|
|
90
|
+
|
|
91
|
+
/** Every control a menu walker must be able to reach (shared with @dloizides/game-e2e). */
|
|
92
|
+
declare const MENU_FOCUSABLE_SELECTOR: string;
|
|
93
|
+
declare const DIALOG_SELECTOR = "[role=\"dialog\"]";
|
|
94
|
+
declare const MENU_DEFAULT_ATTR = "data-menu-default";
|
|
95
|
+
declare const NAV_INPUT_ATTR = "data-nav-input";
|
|
96
|
+
declare const NAV_INPUT: {
|
|
97
|
+
readonly key: "key";
|
|
98
|
+
readonly pad: "pad";
|
|
99
|
+
};
|
|
100
|
+
type NavInput = (typeof NAV_INPUT)[keyof typeof NAV_INPUT];
|
|
101
|
+
|
|
102
|
+
/** True when the element has a layout box and no hidden, inert or aria-hidden ancestor. */
|
|
103
|
+
declare function isShown(el: Element): boolean;
|
|
104
|
+
/** The visible, enabled controls inside root, in DOM order. */
|
|
105
|
+
declare function focusables(root: HTMLElement): HTMLElement[];
|
|
106
|
+
/** The [data-menu-default] control, else the first control, else null. */
|
|
107
|
+
declare function defaultInitialFocus(root: HTMLElement): HTMLElement | null;
|
|
108
|
+
|
|
109
|
+
/** Top-most visible dialog (aria-modal preferred, last in DOM wins), else the visible base root, else null. */
|
|
110
|
+
declare function menuRoot(baseRoot: () => HTMLElement | null): HTMLElement | null;
|
|
111
|
+
|
|
112
|
+
interface Box {
|
|
113
|
+
readonly x: number;
|
|
114
|
+
readonly y: number;
|
|
115
|
+
readonly w: number;
|
|
116
|
+
readonly h: number;
|
|
117
|
+
}
|
|
118
|
+
declare const NAV_DIRS: {
|
|
119
|
+
readonly up: "arrowup";
|
|
120
|
+
readonly down: "arrowdown";
|
|
121
|
+
readonly left: "arrowleft";
|
|
122
|
+
readonly right: "arrowright";
|
|
123
|
+
};
|
|
124
|
+
type NavDir = (typeof NAV_DIRS)[keyof typeof NAV_DIRS];
|
|
125
|
+
/** The direction an arrow key names (case-insensitive), or null for any other key. */
|
|
126
|
+
declare const navDir: (key: string) => NavDir | null;
|
|
127
|
+
/** Index of the nearest box from `from` in `dir`; wraps in list order when none lies that way; -1 for no boxes. */
|
|
128
|
+
declare function pickTarget(boxes: readonly Box[], from: number, dir: NavDir): number;
|
|
129
|
+
|
|
73
130
|
/**
|
|
74
131
|
* A vibration pattern: a single duration in ms, or an on/off pattern
|
|
75
132
|
* (`[wait, on, off, on, ...]`) exactly as the Web Vibration API expects.
|
|
@@ -151,4 +208,4 @@ type HapticIntent = 'tap' | 'select' | 'lock' | 'bump' | 'navigate' | 'success'
|
|
|
151
208
|
*/
|
|
152
209
|
declare const HAPTIC_PATTERNS: Record<HapticIntent, VibrationPattern>;
|
|
153
210
|
|
|
154
|
-
export { type ActivationTarget, DEFAULT_ENABLE_CONFIRMATION, DEFAULT_HAPTICS_STORAGE_KEY, type GamepadPollerOptions, HAPTIC_PATTERNS, type HapticIntent, type Haptics, type HapticsOptions, type HapticsStorage, IDLE_REPEAT, PAD_ACTIONS, type PadAction, type PadLike, type PadSnapshot, type RepeatState, STANDARD_BUTTON, USER_ACTIVATION_EVENTS, type VibrationCapable, type VibrationPattern, createGamepadPoller, createHaptics, stepPad };
|
|
211
|
+
export { type ActivationTarget, DEFAULT_ENABLE_CONFIRMATION, DEFAULT_HAPTICS_STORAGE_KEY, DIALOG_SELECTOR, type GamepadPollerOptions, HAPTIC_PATTERNS, type HapticIntent, type Haptics, type HapticsOptions, type HapticsStorage, IDLE_REPEAT, MENU_DEFAULT_ATTR, MENU_FOCUSABLE_SELECTOR, type MenuNav, type MenuNavOptions, NAV_DIRS, NAV_INPUT, NAV_INPUT_ATTR, type Box as NavBox, type NavDir, type NavInput, PAD_ACTIONS, type PadAction, type PadLike, type PadSnapshot, type RepeatState, STANDARD_BUTTON, USER_ACTIVATION_EVENTS, type VibrationCapable, type VibrationPattern, createGamepadPoller, createHaptics, createMenuNav, defaultInitialFocus, focusables, isShown, menuRoot, navDir, pickTarget, stepPad };
|
package/dist/index.d.ts
CHANGED
|
@@ -70,6 +70,63 @@ declare function stepPad(prev: PadSnapshot | null, next: PadSnapshot, repeat: Re
|
|
|
70
70
|
repeat: RepeatState;
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
interface MenuNavOptions {
|
|
74
|
+
/** The game's base menu root when no dialog is open; null = no menu (e.g. during play). */
|
|
75
|
+
baseRoot: () => HTMLElement | null;
|
|
76
|
+
/** True while input must not move focus (e.g. a remap capture). Default: never. */
|
|
77
|
+
suspended?: () => boolean;
|
|
78
|
+
/** Where focus goes when a root opens. Default: [data-menu-default], else the first control. */
|
|
79
|
+
initialFocus?: (root: HTMLElement) => HTMLElement | null;
|
|
80
|
+
}
|
|
81
|
+
interface MenuNav {
|
|
82
|
+
/** Feed one pad action from stepPad (the game keeps its own poller). */
|
|
83
|
+
pad(action: PadAction): void;
|
|
84
|
+
/** Re-evaluate the root and place focus (call after the game opens a screen). */
|
|
85
|
+
refresh(): void;
|
|
86
|
+
dispose(): void;
|
|
87
|
+
}
|
|
88
|
+
/** One focus engine for every game menu: arrow keys (capture phase), pad actions, dialogs on top, sliders and text fields. */
|
|
89
|
+
declare function createMenuNav(options: MenuNavOptions): MenuNav;
|
|
90
|
+
|
|
91
|
+
/** Every control a menu walker must be able to reach (shared with @dloizides/game-e2e). */
|
|
92
|
+
declare const MENU_FOCUSABLE_SELECTOR: string;
|
|
93
|
+
declare const DIALOG_SELECTOR = "[role=\"dialog\"]";
|
|
94
|
+
declare const MENU_DEFAULT_ATTR = "data-menu-default";
|
|
95
|
+
declare const NAV_INPUT_ATTR = "data-nav-input";
|
|
96
|
+
declare const NAV_INPUT: {
|
|
97
|
+
readonly key: "key";
|
|
98
|
+
readonly pad: "pad";
|
|
99
|
+
};
|
|
100
|
+
type NavInput = (typeof NAV_INPUT)[keyof typeof NAV_INPUT];
|
|
101
|
+
|
|
102
|
+
/** True when the element has a layout box and no hidden, inert or aria-hidden ancestor. */
|
|
103
|
+
declare function isShown(el: Element): boolean;
|
|
104
|
+
/** The visible, enabled controls inside root, in DOM order. */
|
|
105
|
+
declare function focusables(root: HTMLElement): HTMLElement[];
|
|
106
|
+
/** The [data-menu-default] control, else the first control, else null. */
|
|
107
|
+
declare function defaultInitialFocus(root: HTMLElement): HTMLElement | null;
|
|
108
|
+
|
|
109
|
+
/** Top-most visible dialog (aria-modal preferred, last in DOM wins), else the visible base root, else null. */
|
|
110
|
+
declare function menuRoot(baseRoot: () => HTMLElement | null): HTMLElement | null;
|
|
111
|
+
|
|
112
|
+
interface Box {
|
|
113
|
+
readonly x: number;
|
|
114
|
+
readonly y: number;
|
|
115
|
+
readonly w: number;
|
|
116
|
+
readonly h: number;
|
|
117
|
+
}
|
|
118
|
+
declare const NAV_DIRS: {
|
|
119
|
+
readonly up: "arrowup";
|
|
120
|
+
readonly down: "arrowdown";
|
|
121
|
+
readonly left: "arrowleft";
|
|
122
|
+
readonly right: "arrowright";
|
|
123
|
+
};
|
|
124
|
+
type NavDir = (typeof NAV_DIRS)[keyof typeof NAV_DIRS];
|
|
125
|
+
/** The direction an arrow key names (case-insensitive), or null for any other key. */
|
|
126
|
+
declare const navDir: (key: string) => NavDir | null;
|
|
127
|
+
/** Index of the nearest box from `from` in `dir`; wraps in list order when none lies that way; -1 for no boxes. */
|
|
128
|
+
declare function pickTarget(boxes: readonly Box[], from: number, dir: NavDir): number;
|
|
129
|
+
|
|
73
130
|
/**
|
|
74
131
|
* A vibration pattern: a single duration in ms, or an on/off pattern
|
|
75
132
|
* (`[wait, on, off, on, ...]`) exactly as the Web Vibration API expects.
|
|
@@ -151,4 +208,4 @@ type HapticIntent = 'tap' | 'select' | 'lock' | 'bump' | 'navigate' | 'success'
|
|
|
151
208
|
*/
|
|
152
209
|
declare const HAPTIC_PATTERNS: Record<HapticIntent, VibrationPattern>;
|
|
153
210
|
|
|
154
|
-
export { type ActivationTarget, DEFAULT_ENABLE_CONFIRMATION, DEFAULT_HAPTICS_STORAGE_KEY, type GamepadPollerOptions, HAPTIC_PATTERNS, type HapticIntent, type Haptics, type HapticsOptions, type HapticsStorage, IDLE_REPEAT, PAD_ACTIONS, type PadAction, type PadLike, type PadSnapshot, type RepeatState, STANDARD_BUTTON, USER_ACTIVATION_EVENTS, type VibrationCapable, type VibrationPattern, createGamepadPoller, createHaptics, stepPad };
|
|
211
|
+
export { type ActivationTarget, DEFAULT_ENABLE_CONFIRMATION, DEFAULT_HAPTICS_STORAGE_KEY, DIALOG_SELECTOR, type GamepadPollerOptions, HAPTIC_PATTERNS, type HapticIntent, type Haptics, type HapticsOptions, type HapticsStorage, IDLE_REPEAT, MENU_DEFAULT_ATTR, MENU_FOCUSABLE_SELECTOR, type MenuNav, type MenuNavOptions, NAV_DIRS, NAV_INPUT, NAV_INPUT_ATTR, type Box as NavBox, type NavDir, type NavInput, PAD_ACTIONS, type PadAction, type PadLike, type PadSnapshot, type RepeatState, STANDARD_BUTTON, USER_ACTIVATION_EVENTS, type VibrationCapable, type VibrationPattern, createGamepadPoller, createHaptics, createMenuNav, defaultInitialFocus, focusables, isShown, menuRoot, navDir, pickTarget, stepPad };
|
package/dist/index.js
CHANGED
|
@@ -122,6 +122,369 @@ function createGamepadPoller(opts) {
|
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// src/menu/constants.ts
|
|
126
|
+
var MENU_FOCUSABLE_SELECTOR = [
|
|
127
|
+
"button",
|
|
128
|
+
"a[href]",
|
|
129
|
+
"input",
|
|
130
|
+
"select",
|
|
131
|
+
"textarea",
|
|
132
|
+
'[role="button"]',
|
|
133
|
+
'[role="tab"]',
|
|
134
|
+
'[role="option"]',
|
|
135
|
+
'[role="slider"]',
|
|
136
|
+
'[role="switch"]',
|
|
137
|
+
'[role="checkbox"]',
|
|
138
|
+
'[role="radio"]',
|
|
139
|
+
'[tabindex="0"]'
|
|
140
|
+
].join(", ");
|
|
141
|
+
var NATIVE_FOCUSABLE_SELECTOR = "button, input, select, textarea, a[href]";
|
|
142
|
+
var DIALOG_SELECTOR = '[role="dialog"]';
|
|
143
|
+
var HIDDEN_ANCESTOR_SELECTOR = '[hidden], [inert], [aria-hidden="true"]';
|
|
144
|
+
var MENU_DEFAULT_ATTR = "data-menu-default";
|
|
145
|
+
var NAV_INPUT_ATTR = "data-nav-input";
|
|
146
|
+
var NAV_INPUT = { key: "key", pad: "pad" };
|
|
147
|
+
var OBSERVED_ATTRIBUTES = ["hidden", "class", "style", "aria-hidden", "aria-modal", "inert", "open"];
|
|
148
|
+
var ESCAPE_KEY = "Escape";
|
|
149
|
+
var ENTER_KEY = "Enter";
|
|
150
|
+
|
|
151
|
+
// src/menu/controlKind.ts
|
|
152
|
+
var NON_TEXT_INPUTS = ["button", "submit", "reset", "color", "file", "image"];
|
|
153
|
+
var TOGGLE_ROLES = ["checkbox", "switch", "radio"];
|
|
154
|
+
function inputKind(el) {
|
|
155
|
+
const type = el.type;
|
|
156
|
+
if (type === "range") {
|
|
157
|
+
return "slider" /* Slider */;
|
|
158
|
+
}
|
|
159
|
+
if (type === "checkbox" || type === "radio") {
|
|
160
|
+
return "toggle" /* Toggle */;
|
|
161
|
+
}
|
|
162
|
+
return NON_TEXT_INPUTS.includes(type) ? "button" /* Button */ : "textField" /* TextField */;
|
|
163
|
+
}
|
|
164
|
+
function controlKind(el) {
|
|
165
|
+
if (el instanceof HTMLInputElement) {
|
|
166
|
+
return inputKind(el);
|
|
167
|
+
}
|
|
168
|
+
if (el instanceof HTMLTextAreaElement) {
|
|
169
|
+
return "textField" /* TextField */;
|
|
170
|
+
}
|
|
171
|
+
if (el instanceof HTMLSelectElement) {
|
|
172
|
+
return "select" /* Select */;
|
|
173
|
+
}
|
|
174
|
+
const role = el.getAttribute("role") ?? "";
|
|
175
|
+
if (role === "slider") {
|
|
176
|
+
return "slider" /* Slider */;
|
|
177
|
+
}
|
|
178
|
+
return TOGGLE_ROLES.includes(role) ? "toggle" /* Toggle */ : "button" /* Button */;
|
|
179
|
+
}
|
|
180
|
+
var isTextField = (el) => controlKind(el) === "textField" /* TextField */;
|
|
181
|
+
function isStepper(el) {
|
|
182
|
+
const kind = controlKind(el);
|
|
183
|
+
return kind === "slider" /* Slider */ || kind === "select" /* Select */;
|
|
184
|
+
}
|
|
185
|
+
function needsSyntheticClick(el) {
|
|
186
|
+
const kind = controlKind(el);
|
|
187
|
+
const clickable = kind === "button" /* Button */ || kind === "toggle" /* Toggle */;
|
|
188
|
+
return clickable && !el.matches(NATIVE_FOCUSABLE_SELECTOR);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/menu/focusables.ts
|
|
192
|
+
function isShown(el) {
|
|
193
|
+
return el.getClientRects().length > 0 && el.closest(HIDDEN_ANCESTOR_SELECTOR) === null;
|
|
194
|
+
}
|
|
195
|
+
var isDisabled = (el) => el.disabled === true;
|
|
196
|
+
function focusables(root) {
|
|
197
|
+
return Array.from(root.querySelectorAll(MENU_FOCUSABLE_SELECTOR)).filter((el) => isShown(el) && !isDisabled(el));
|
|
198
|
+
}
|
|
199
|
+
function defaultInitialFocus(root) {
|
|
200
|
+
const list = focusables(root);
|
|
201
|
+
return list.find((el) => el.hasAttribute(MENU_DEFAULT_ATTR)) ?? list[0] ?? null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/menu/menuRoot.ts
|
|
205
|
+
function menuRoot(baseRoot) {
|
|
206
|
+
const dialogs = Array.from(document.querySelectorAll(DIALOG_SELECTOR)).filter(isShown);
|
|
207
|
+
const modal = dialogs.filter((d) => d.getAttribute("aria-modal") === "true");
|
|
208
|
+
const top = (modal.length > 0 ? modal : dialogs).pop();
|
|
209
|
+
if (top !== void 0) {
|
|
210
|
+
return top;
|
|
211
|
+
}
|
|
212
|
+
const base = baseRoot();
|
|
213
|
+
return base !== null && isShown(base) ? base : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/menu/pickTarget.ts
|
|
217
|
+
var NAV_DIRS = { up: "arrowup", down: "arrowdown", left: "arrowleft", right: "arrowright" };
|
|
218
|
+
var NAV_KEYS = Object.values(NAV_DIRS);
|
|
219
|
+
var CROSS_WEIGHT = 2;
|
|
220
|
+
var SLACK = 1;
|
|
221
|
+
var navDir = (key) => NAV_KEYS.includes(key.toLowerCase()) ? key.toLowerCase() : null;
|
|
222
|
+
var centre = (b) => ({ cx: b.x + b.w / 2, cy: b.y + b.h / 2 });
|
|
223
|
+
function cost(a, b, dir) {
|
|
224
|
+
const p = centre(a);
|
|
225
|
+
const q = centre(b);
|
|
226
|
+
const dx = q.cx - p.cx;
|
|
227
|
+
const dy = q.cy - p.cy;
|
|
228
|
+
const vertical = dir === NAV_DIRS.up || dir === NAV_DIRS.down;
|
|
229
|
+
const along = vertical ? dir === NAV_DIRS.down ? dy : -dy : dir === NAV_DIRS.right ? dx : -dx;
|
|
230
|
+
if (along <= SLACK) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return along + CROSS_WEIGHT * Math.abs(vertical ? dx : dy);
|
|
234
|
+
}
|
|
235
|
+
function pickTarget(boxes, from, dir) {
|
|
236
|
+
if (boxes.length === 0) {
|
|
237
|
+
return -1;
|
|
238
|
+
}
|
|
239
|
+
if (from < 0 || from >= boxes.length) {
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
const origin = boxes[from];
|
|
243
|
+
let best = -1;
|
|
244
|
+
let bestCost = Infinity;
|
|
245
|
+
boxes.forEach((b, i) => {
|
|
246
|
+
const c = i === from ? null : cost(origin, b, dir);
|
|
247
|
+
if (c !== null && c < bestCost) {
|
|
248
|
+
best = i;
|
|
249
|
+
bestCost = c;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
if (best >= 0) {
|
|
253
|
+
return best;
|
|
254
|
+
}
|
|
255
|
+
const step = dir === NAV_DIRS.down || dir === NAV_DIRS.right ? 1 : -1;
|
|
256
|
+
return (from + step + boxes.length) % boxes.length;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/menu/stepControl.ts
|
|
260
|
+
var DEFAULT_MAX = 100;
|
|
261
|
+
var num = (raw, fallback) => {
|
|
262
|
+
const n = parseFloat(raw ?? "");
|
|
263
|
+
return Number.isFinite(n) ? n : fallback;
|
|
264
|
+
};
|
|
265
|
+
function clampStep(value, delta, min, max, step) {
|
|
266
|
+
const decimals = (String(step).split(".")[1] ?? "").length;
|
|
267
|
+
return Math.min(max, Math.max(min, Number((value + delta * step).toFixed(decimals))));
|
|
268
|
+
}
|
|
269
|
+
function apply(el, current, next, write) {
|
|
270
|
+
if (next === current) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
write(next);
|
|
274
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
275
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
276
|
+
}
|
|
277
|
+
function stepControl(el, delta) {
|
|
278
|
+
if (el instanceof HTMLInputElement) {
|
|
279
|
+
const value = num(el.value, 0);
|
|
280
|
+
const next = clampStep(value, delta, num(el.min, 0), num(el.max, DEFAULT_MAX), num(el.step, 1));
|
|
281
|
+
apply(el, value, next, (v) => {
|
|
282
|
+
el.value = String(v);
|
|
283
|
+
});
|
|
284
|
+
} else if (el instanceof HTMLSelectElement) {
|
|
285
|
+
const next = Math.min(el.options.length - 1, Math.max(0, el.selectedIndex + delta));
|
|
286
|
+
apply(el, el.selectedIndex, next, (v) => {
|
|
287
|
+
el.selectedIndex = v;
|
|
288
|
+
});
|
|
289
|
+
} else {
|
|
290
|
+
const value = num(el.getAttribute("aria-valuenow"), 0);
|
|
291
|
+
const next = clampStep(value, delta, num(el.getAttribute("aria-valuemin"), 0), num(el.getAttribute("aria-valuemax"), DEFAULT_MAX), num(el.getAttribute("data-step"), 1));
|
|
292
|
+
apply(el, value, next, (v) => el.setAttribute("aria-valuenow", String(v)));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/menu/navActions.ts
|
|
297
|
+
var boxOf = (el) => {
|
|
298
|
+
const r = el.getBoundingClientRect();
|
|
299
|
+
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
|
300
|
+
};
|
|
301
|
+
function focusControl(el) {
|
|
302
|
+
if (!el.matches(NATIVE_FOCUSABLE_SELECTOR) && !el.hasAttribute("tabindex")) {
|
|
303
|
+
el.setAttribute("tabindex", "-1");
|
|
304
|
+
}
|
|
305
|
+
el.focus({ preventScroll: true });
|
|
306
|
+
if (typeof el.scrollIntoView === "function") {
|
|
307
|
+
el.scrollIntoView({ block: "nearest" });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function activeIn(root) {
|
|
311
|
+
const a = document.activeElement;
|
|
312
|
+
return a instanceof HTMLElement && a !== root && root.contains(a) ? a : null;
|
|
313
|
+
}
|
|
314
|
+
function focusInitial(root, initial) {
|
|
315
|
+
const target = initial(root);
|
|
316
|
+
if (target === null) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
focusControl(target);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
function moveFocus(root, active, dir, initial) {
|
|
323
|
+
const list = focusables(root);
|
|
324
|
+
const from = active === null ? -1 : list.indexOf(active);
|
|
325
|
+
if (from < 0) {
|
|
326
|
+
return focusInitial(root, initial);
|
|
327
|
+
}
|
|
328
|
+
focusControl(list[pickTarget(list.map(boxOf), from, dir)]);
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
function direct(root, active, dir, initial) {
|
|
332
|
+
const horizontal = dir === NAV_DIRS.left || dir === NAV_DIRS.right;
|
|
333
|
+
if (active !== null && horizontal && isStepper(active)) {
|
|
334
|
+
stepControl(active, dir === NAV_DIRS.right ? 1 : -1);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
return moveFocus(root, active, dir, initial);
|
|
338
|
+
}
|
|
339
|
+
function leaveField(root, field) {
|
|
340
|
+
const list = focusables(root);
|
|
341
|
+
const next = list[pickTarget(list.map(boxOf), list.indexOf(field), NAV_DIRS.down)];
|
|
342
|
+
if (next === field || next === void 0) {
|
|
343
|
+
field.blur();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
focusControl(next);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/menu/createMenuNav.ts
|
|
350
|
+
var PAD_DIRS = { up: NAV_DIRS.up, down: NAV_DIRS.down, left: NAV_DIRS.left, right: NAV_DIRS.right };
|
|
351
|
+
var isOpen = (el) => el.isConnected && isShown(el);
|
|
352
|
+
var markInput = (kind) => document.documentElement.setAttribute(NAV_INPUT_ATTR, kind);
|
|
353
|
+
var clearInput = () => document.documentElement.removeAttribute(NAV_INPUT_ATTR);
|
|
354
|
+
var sendEscape = () => [document.activeElement, window].find((t) => t !== null).dispatchEvent(new KeyboardEvent("keydown", { key: ESCAPE_KEY, bubbles: true, cancelable: true }));
|
|
355
|
+
function popClosed(stack) {
|
|
356
|
+
let restore = null;
|
|
357
|
+
while (stack.length > 0 && !isOpen(stack[stack.length - 1].dialog)) {
|
|
358
|
+
restore = stack.pop().restore;
|
|
359
|
+
}
|
|
360
|
+
return restore;
|
|
361
|
+
}
|
|
362
|
+
function canRestore(restore, root) {
|
|
363
|
+
return restore !== null && restore.isConnected && root.contains(restore) && isShown(restore);
|
|
364
|
+
}
|
|
365
|
+
function onKey(e, root, initial) {
|
|
366
|
+
const active = activeIn(root);
|
|
367
|
+
const dir = navDir(e.key);
|
|
368
|
+
if (e.key === ESCAPE_KEY) {
|
|
369
|
+
if (active === null || !isTextField(active)) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
e.stopImmediatePropagation();
|
|
374
|
+
leaveField(root, active);
|
|
375
|
+
} else if (e.key === ENTER_KEY) {
|
|
376
|
+
if (active === null || !needsSyntheticClick(active)) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
active.click();
|
|
381
|
+
} else if (dir !== null) {
|
|
382
|
+
markInput(NAV_INPUT.key);
|
|
383
|
+
if (active !== null && isTextField(active)) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (direct(root, active, dir, initial)) {
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function onPad(action, root, initial) {
|
|
392
|
+
const active = root === null ? null : activeIn(root);
|
|
393
|
+
const dir = PAD_DIRS[action];
|
|
394
|
+
if (action === PAD_ACTIONS.back) {
|
|
395
|
+
if (root !== null && active !== null && isTextField(active)) {
|
|
396
|
+
leaveField(root, active);
|
|
397
|
+
} else {
|
|
398
|
+
sendEscape();
|
|
399
|
+
}
|
|
400
|
+
} else if (root === null) {
|
|
401
|
+
return;
|
|
402
|
+
} else if (dir !== void 0) {
|
|
403
|
+
direct(root, active, dir, initial);
|
|
404
|
+
} else if (action === PAD_ACTIONS.confirm) {
|
|
405
|
+
if (active === null) {
|
|
406
|
+
focusInitial(root, initial);
|
|
407
|
+
} else if (isTextField(active)) {
|
|
408
|
+
focusControl(active);
|
|
409
|
+
} else {
|
|
410
|
+
active.click();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function createMenuNav(options) {
|
|
415
|
+
const suspended = options.suspended ?? (() => false);
|
|
416
|
+
const initial = options.initialFocus ?? defaultInitialFocus;
|
|
417
|
+
const stack = [];
|
|
418
|
+
let root = menuRoot(options.baseRoot);
|
|
419
|
+
let lastInRoot = null;
|
|
420
|
+
const settle = (next) => {
|
|
421
|
+
const restore = popClosed(stack);
|
|
422
|
+
if (next.matches(DIALOG_SELECTOR) && !stack.some((s) => s.dialog === next)) {
|
|
423
|
+
stack.push({ dialog: next, restore: lastInRoot });
|
|
424
|
+
}
|
|
425
|
+
if (activeIn(next) !== null) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (canRestore(restore, next)) {
|
|
429
|
+
focusControl(restore);
|
|
430
|
+
} else {
|
|
431
|
+
focusInitial(next, initial);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
const refresh = () => {
|
|
435
|
+
const next = menuRoot(options.baseRoot);
|
|
436
|
+
if (next === root) {
|
|
437
|
+
return next;
|
|
438
|
+
}
|
|
439
|
+
root = next;
|
|
440
|
+
if (next !== null) {
|
|
441
|
+
settle(next);
|
|
442
|
+
}
|
|
443
|
+
return next;
|
|
444
|
+
};
|
|
445
|
+
const keydown = (e) => {
|
|
446
|
+
if (e.altKey || e.ctrlKey || e.metaKey || suspended()) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const r = refresh();
|
|
450
|
+
if (r !== null) {
|
|
451
|
+
onKey(e, r, initial);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
const focusin = (e) => {
|
|
455
|
+
const t = e.target;
|
|
456
|
+
if (root !== null && root.contains(t) && t.closest(DIALOG_SELECTOR) === root.closest(DIALOG_SELECTOR)) {
|
|
457
|
+
lastInRoot = t;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
const observer = new MutationObserver(() => {
|
|
461
|
+
refresh();
|
|
462
|
+
});
|
|
463
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: [...OBSERVED_ATTRIBUTES] });
|
|
464
|
+
window.addEventListener("keydown", keydown, true);
|
|
465
|
+
window.addEventListener("pointerdown", clearInput, true);
|
|
466
|
+
document.addEventListener("focusin", focusin, true);
|
|
467
|
+
return {
|
|
468
|
+
pad: (action) => {
|
|
469
|
+
if (suspended()) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
markInput(NAV_INPUT.pad);
|
|
473
|
+
onPad(action, refresh(), initial);
|
|
474
|
+
},
|
|
475
|
+
refresh: () => {
|
|
476
|
+
refresh();
|
|
477
|
+
},
|
|
478
|
+
dispose: () => {
|
|
479
|
+
observer.disconnect();
|
|
480
|
+
window.removeEventListener("keydown", keydown, true);
|
|
481
|
+
window.removeEventListener("pointerdown", clearInput, true);
|
|
482
|
+
document.removeEventListener("focusin", focusin, true);
|
|
483
|
+
clearInput();
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
125
488
|
// src/constants.ts
|
|
126
489
|
var DEFAULT_HAPTICS_STORAGE_KEY = "game.haptics";
|
|
127
490
|
var DEFAULT_ENABLE_CONFIRMATION = 15;
|
|
@@ -234,13 +597,26 @@ function createHaptics(options = {}) {
|
|
|
234
597
|
|
|
235
598
|
exports.DEFAULT_ENABLE_CONFIRMATION = DEFAULT_ENABLE_CONFIRMATION;
|
|
236
599
|
exports.DEFAULT_HAPTICS_STORAGE_KEY = DEFAULT_HAPTICS_STORAGE_KEY;
|
|
600
|
+
exports.DIALOG_SELECTOR = DIALOG_SELECTOR;
|
|
237
601
|
exports.HAPTIC_PATTERNS = HAPTIC_PATTERNS;
|
|
238
602
|
exports.IDLE_REPEAT = IDLE_REPEAT;
|
|
603
|
+
exports.MENU_DEFAULT_ATTR = MENU_DEFAULT_ATTR;
|
|
604
|
+
exports.MENU_FOCUSABLE_SELECTOR = MENU_FOCUSABLE_SELECTOR;
|
|
605
|
+
exports.NAV_DIRS = NAV_DIRS;
|
|
606
|
+
exports.NAV_INPUT = NAV_INPUT;
|
|
607
|
+
exports.NAV_INPUT_ATTR = NAV_INPUT_ATTR;
|
|
239
608
|
exports.PAD_ACTIONS = PAD_ACTIONS;
|
|
240
609
|
exports.STANDARD_BUTTON = STANDARD_BUTTON;
|
|
241
610
|
exports.USER_ACTIVATION_EVENTS = USER_ACTIVATION_EVENTS;
|
|
242
611
|
exports.createGamepadPoller = createGamepadPoller;
|
|
243
612
|
exports.createHaptics = createHaptics;
|
|
613
|
+
exports.createMenuNav = createMenuNav;
|
|
614
|
+
exports.defaultInitialFocus = defaultInitialFocus;
|
|
615
|
+
exports.focusables = focusables;
|
|
616
|
+
exports.isShown = isShown;
|
|
617
|
+
exports.menuRoot = menuRoot;
|
|
618
|
+
exports.navDir = navDir;
|
|
619
|
+
exports.pickTarget = pickTarget;
|
|
244
620
|
exports.stepPad = stepPad;
|
|
245
621
|
//# sourceMappingURL=index.js.map
|
|
246
622
|
//# sourceMappingURL=index.js.map
|