mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
Add gamepad/controller support (#1001)
* feat(input): add gamepad/controller support
Adds full gamepad/controller navigation and playback control, driven
by requests from players who use fee[dB]ack on a TV/console setup and
from wheelchair users for whom a controller is far more convenient
than a keyboard + mouse. Confirmed working end-to-end on a Steam Deck
across several rounds of on-device testing.
- static/v3/gamepad.js: polls navigator.getGamepads() and dispatches
synthetic keydown events (Arrow/Enter/Space/Escape) on the focused
element (falling back to document), reusing the app's existing
keyboard pipeline (static/js/shortcuts.js's scope-aware dispatcher,
player shortcuts, text-field/modal guards) instead of a parallel
action-mapping table. Only acts on gamepads reporting the W3C
"standard" mapping — which is what Steam Input presents for the
Deck's built-in controls, both in Gaming Mode and in Desktop Mode
via a non-Steam shortcut — so button order is guaranteed correct
and a non-standard/raw device safely no-ops instead of misfiring.
Handles Steam Input's virtual-pad duplicates (a real controller
plus 1-2 mirrored XInput slots) without spamming connect toasts or
losing input when the live pad isn't at index 0. Xbox-style face
button mapping: bottom face = Space (play/pause, and activates the
focused control), right face = Escape (back), top face reveals the
player screen's tool rail (focuses it into visibility via the
existing CSS :focus-within rule). D-pad/stick repeat while held,
mirroring OS keyboard auto-repeat.
- static/v3/gamepad-nav.js: fills the one real gap in that reuse
strategy — no screen but the song library grid had any arrow-key
navigation, and Chromium doesn't run native Enter/Space button
activation for untrusted synthetic events even when dispatched at
the focused element. Gated entirely on `!e.isTrusted`, so it only
ever reacts to gamepad-originated events and never touches real
keyboard/mouse users: emulates Tab-order (the sidebar + active
screen's real, already-focusable buttons/links) for Arrow keys,
explicitly .click()s the focused element for Enter/Space, and gives
Escape a consistent "go back" behavior — an existing in-screen back
button if one's visible (reusing each screen's own drill-down logic
for free), else the main menu. Every branch defers via
`e.defaultPrevented` to any screen that already handles the key
itself (the song grid, the player, settings), so nothing here
overrides existing behavior.
- static/v3/songs.js: adds real 2D d-pad/arrow-key navigation to the
song library's virtualized grid (only a slice of the library is
ever in the DOM), including fetching/scrolling off-screen rows into
view and correcting for the sticky filter toolbar's occlusion.
- static/v3/index.html: wires up the two new scripts.
* chore: regenerate stale tailwind.min.css
Rebuilt in a fresh clone (not the local working copy). Several plugin
directories (audio_engine, plugin_manager, community_charts, etc.) are
gitignored locally but present on disk from checking out plugin repos
for local dev/testing — Tailwind's content scan picks them up
regardless, so a rebuild against the contaminated local working copy
bakes in extra utility classes that don't belong in the real,
git-tracked build. A clean checkout reproduces CI's expected output
exactly.
* fix(gamepad): check all matching back buttons, not just the first
document.querySelector on the combined [data-ap-back], [data-albums-back],
#v3-pl-back selector only ever inspects the first match in DOM order —
since screens stay in the DOM (hidden, not removed) when you navigate
away, a hidden back button from an unrelated screen could sort before
the one that's actually visible, incorrectly falling through to
showScreen('v3-home') instead of clicking it. Uses querySelectorAll +
find(visible) instead.
* fix(gamepad): address CodeRabbit findings on connect/disconnect and grid nav
- gamepad.js: anyLiveConnectedPad -> anyLiveStandardPad, filtering by
mapping === 'standard' like firstLiveStandardPad already does, and
applied at the top of the gamepadconnected handler too. A still-
connected non-standard raw mirror could otherwise mask the real
pad's disconnect (toast never fires, polling never stops).
- songs.js _gpMove: an unset cursor now always seeds at index 0
before the first press, instead of applying that press's delta
immediately (ArrowDown/Right previously skipped straight past row
0; Left/Up only looked right by accident of clamping). Matches the
existing convention in shortcuts.js's legacy _handleLibArrowNav.
- songs.js _gpBlockedTarget: form-control/button blocking now
requires the element to be visible (offsetParent !== null), not
just present. Screens stay in the DOM hidden (not removed) when you
navigate away, so a real button focused on some other now-hidden
screen could leave document.activeElement pointing at it and block
all grid navigation indefinitely. (An el.closest('#v3-songs') scope
was tried first and reverted — it fixed that case but broke
blocking for the topbar search input, which lives outside
#v3-songs's DOM subtree even while v3-songs is active; visibility
is the distinction that actually matters, not DOM nesting.)
Skipped two CodeRabbit suggestions, verified against current code:
gating songs.js's grid keydown listener to synthetic-only events
would regress the real keyboard accessibility this PR intentionally
added (v3-songs' grid had none before); renaming the _gp* helpers to
drop their underscore prefix would break from this codebase's own
established module-private naming convention.
Verified in-browser: first arrow press lands on index 0, stale hidden
focus no longer blocks grid nav, the topbar search input still
correctly blocks it, and normal nav resumes after blur.
* test(gamepad): unit-cover the controller + nav state machines
- gamepad.test.js (10): standard-mapping filter, Steam Input duplicate-slot
dedup, disconnect masking, button edge-detection, d-pad/stick repeat timing,
analog deadzone — driven via a fake navigator + manual rAF queue.
- gamepad_nav.test.js (10): !isTrusted/defaultPrevented gating, arrow focus
traversal + clamping, hidden-element skipping, Enter/Space click activation
(not into text fields/body), Escape visible-back-button vs home fallback.
songs.js grid nav is left to on-device coverage (async + windowed-DOM heavy).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
---------
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Byron Gamatos
parent
fcdb4867d6
commit
23c509322b
@@ -0,0 +1,100 @@
|
|||||||
|
// Generic gamepad menu navigation: Tab-order emulation.
|
||||||
|
//
|
||||||
|
// Every v3 screen except v3-songs (which has its own 2D grid nav) is built from
|
||||||
|
// real, natively-focusable <button>/<a> elements, so real Tab/Shift+Tab and real
|
||||||
|
// Enter/Space already work perfectly. The gap is that nothing ever calls
|
||||||
|
// .focus() on anything, and gamepad.js only ever synthesizes Arrow keydowns —
|
||||||
|
// it never sends Tab (browsers don't focus-traverse on a synthetic Tab anyway).
|
||||||
|
// This fills that gap by moving focus through the same set of elements Tab
|
||||||
|
// already visits, one step per Arrow press, treating Down/Right as "next" and
|
||||||
|
// Up/Left as "previous".
|
||||||
|
//
|
||||||
|
// Gated on !e.isTrusted so this NEVER touches real keyboard/mouse users — it
|
||||||
|
// only ever reacts to gamepad.js's synthetic events. Also bails whenever a more
|
||||||
|
// specific handler already claimed the key (songs.js's grid nav, shortcuts.js's
|
||||||
|
// legacy library arrow-nav, or the shortcuts registry's player-scope seek
|
||||||
|
// shortcuts all call preventDefault() before this listener runs, since script
|
||||||
|
// tag order puts them earlier in the document than this file).
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), ' +
|
||||||
|
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
var ARROWS = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
|
||||||
|
var TEXT_INPUT_TYPES = ['text', 'search', 'email', 'url', 'tel', 'password', 'number'];
|
||||||
|
|
||||||
|
function visible(el) {
|
||||||
|
return el.offsetParent !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusScopeRoot() {
|
||||||
|
var modal = document.querySelector('[role="dialog"][aria-modal="true"], .feedBack-modal');
|
||||||
|
if (modal && visible(modal)) return [modal];
|
||||||
|
var nav = document.getElementById('v3-nav');
|
||||||
|
var screen = document.querySelector('.screen.active');
|
||||||
|
return [nav, screen].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusables() {
|
||||||
|
var roots = focusScopeRoot();
|
||||||
|
var els = [];
|
||||||
|
roots.forEach(function (root) {
|
||||||
|
Array.prototype.push.apply(els, root.querySelectorAll(FOCUSABLE));
|
||||||
|
});
|
||||||
|
return els.filter(visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextInput(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
if (el.tagName === 'TEXTAREA' || el.isContentEditable) return true;
|
||||||
|
return el.tagName === 'INPUT' && TEXT_INPUT_TYPES.includes((el.type || 'text').toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.isTrusted || e.defaultPrevented) return;
|
||||||
|
|
||||||
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
||||||
|
// Chromium doesn't run the native "Enter/Space activates the focused
|
||||||
|
// link/button" default action for untrusted synthetic keydowns, even
|
||||||
|
// when dispatched straight at the focused element (confirmed by
|
||||||
|
// testing) — so without this, a focused sidebar link or dashboard
|
||||||
|
// button just sits there forever. click() works for untrusted events.
|
||||||
|
var active = document.activeElement;
|
||||||
|
if (active && active !== document.body && !isTextInput(active)) active.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
// Only 'player' and 'settings' have a registered Escape shortcut
|
||||||
|
// (shortcuts.js); every other screen (v3-songs, v3-plugins,
|
||||||
|
// v3-playlists, ...) leaves B with nothing to do — confirmed on-device,
|
||||||
|
// players get stuck unable to leave the library or any other screen.
|
||||||
|
// The app never pushes history entries on navigation (shell.js
|
||||||
|
// deliberately doesn't reflect screen changes into location.hash), so
|
||||||
|
// history.back() isn't a real "undo the last screen" — a fixed target
|
||||||
|
// is. Prefer an existing in-screen back button if one is visible
|
||||||
|
// (reuses each screen's own drill-down logic for free: v3-songs'
|
||||||
|
// artist/album pages, v3-playlists' list<->detail view), else fall
|
||||||
|
// back to the main menu, matching the direct showScreen() call the
|
||||||
|
// settings Escape shortcut already uses.
|
||||||
|
// querySelector alone would only ever look at the first match in
|
||||||
|
// DOM order across all three selectors — screens stay in the DOM
|
||||||
|
// (hidden, not removed) when you navigate away, so a hidden back
|
||||||
|
// button from a screen you're not on can sort before the visible
|
||||||
|
// one that actually applies. Check every match for visibility.
|
||||||
|
var backBtns = document.querySelectorAll('[data-ap-back], [data-albums-back], #v3-pl-back');
|
||||||
|
var backBtn = Array.prototype.find.call(backBtns, visible);
|
||||||
|
if (backBtn) backBtn.click();
|
||||||
|
else if (window.showScreen) window.showScreen('v3-home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dir = ARROWS[e.key];
|
||||||
|
if (!dir) return;
|
||||||
|
var els = focusables();
|
||||||
|
if (!els.length) return;
|
||||||
|
var idx = els.indexOf(document.activeElement);
|
||||||
|
var next = idx === -1 ? 0 : Math.max(0, Math.min(els.length - 1, idx + dir));
|
||||||
|
els[next].focus();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Gamepad/controller support.
|
||||||
|
//
|
||||||
|
// Rather than a parallel gamepad->action mapping table, this polls
|
||||||
|
// navigator.getGamepads() and dispatches synthetic keydown events onto
|
||||||
|
// document with the same key/code pairs a physical keyboard would send.
|
||||||
|
// static/js/shortcuts.js's existing dispatcher (scope checks, text-field/
|
||||||
|
// modal guards, library grid nav, player shortcuts) handles the rest.
|
||||||
|
//
|
||||||
|
// Steam Deck: Steam Input re-emits the Deck's controls as a standard
|
||||||
|
// XInput-style virtual pad (both in Gaming Mode and in Desktop Mode when
|
||||||
|
// launched via a non-Steam shortcut with a controller template), so this
|
||||||
|
// reports mapping: 'standard' and the button layout below lines up with
|
||||||
|
// the Deck's physical ABXY. If a pad reports a non-standard mapping
|
||||||
|
// (e.g. raw HID with no Steam Input in between), this no-ops rather than
|
||||||
|
// guessing button order.
|
||||||
|
//
|
||||||
|
// Plain non-module script; degrades to a no-op without the Gamepad API.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.getGamepads) return;
|
||||||
|
|
||||||
|
var BUTTON_KEYS = {
|
||||||
|
// Bottom face button (Xbox A / PS Cross "X") — play/pause on the player
|
||||||
|
// screen; also activates the currently-selected library card, since
|
||||||
|
// Space is already treated as an activation key there alongside Enter.
|
||||||
|
0: { key: ' ', code: 'Space' },
|
||||||
|
1: { key: 'Escape', code: 'Escape' }, // Xbox B / PS Circle
|
||||||
|
// 2 (Xbox X / PS Square) intentionally unmapped — undecided.
|
||||||
|
};
|
||||||
|
var RAIL_REVEAL_BUTTON = 3; // Y — reveals the player screen's left tool rail
|
||||||
|
|
||||||
|
// The player rail (#v3-player-rail) has no keyboard shortcut to reuse — it's
|
||||||
|
// shown via CSS on #v3-railzone:hover or :focus-within (see v3.css). So
|
||||||
|
// instead of a synthetic keydown, this directly focuses the rail's first
|
||||||
|
// icon, which the existing :focus-within rule already reveals it for —
|
||||||
|
// the same mechanism a Tab-key user gets for free.
|
||||||
|
function revealPlayerRail() {
|
||||||
|
var active = document.querySelector('.screen.active');
|
||||||
|
if (!active || active.id !== 'player') return;
|
||||||
|
var icon = document.querySelector('#v3-player-rail .v3-rail-icon');
|
||||||
|
if (icon) icon.focus();
|
||||||
|
}
|
||||||
|
var DPAD_BUTTONS = {
|
||||||
|
12: { key: 'ArrowUp', code: 'ArrowUp' },
|
||||||
|
13: { key: 'ArrowDown', code: 'ArrowDown' },
|
||||||
|
14: { key: 'ArrowLeft', code: 'ArrowLeft' },
|
||||||
|
15: { key: 'ArrowRight', code: 'ArrowRight' },
|
||||||
|
};
|
||||||
|
var STICK_DEADZONE = 0.5;
|
||||||
|
var REPEAT_DELAY_MS = 400;
|
||||||
|
var REPEAT_INTERVAL_MS = 120;
|
||||||
|
|
||||||
|
var polling = false;
|
||||||
|
var buttonWasDown = {}; // index -> bool, for edge-detection (no repeat)
|
||||||
|
var dirWasDown = {}; // 'up'/'down'/'left'/'right' -> bool
|
||||||
|
var dirRepeatAt = {}; // 'up'/'down'/'left'/'right' -> timestamp of next repeat
|
||||||
|
var connectedIndices = {}; // gamepad.index -> true, tracks which slots we've announced
|
||||||
|
|
||||||
|
function fireKey(spec) {
|
||||||
|
// Dispatch on the focused element (falling back to document when nothing
|
||||||
|
// is focused), not document itself. document.activeElement is always an
|
||||||
|
// ancestor-inclusive descendant of document, so this still bubbles up
|
||||||
|
// through every existing document-level listener exactly as before — but
|
||||||
|
// now a focused <button>/<a> also gets its native Enter/Space activation
|
||||||
|
// (which never fires for a document-targeted event, since that native
|
||||||
|
// behavior is wired to the genuinely-focused element receiving the key),
|
||||||
|
// and any element-scoped keydown handler sees it too.
|
||||||
|
(document.activeElement || document).dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
|
key: spec.key, code: spec.code, bubbles: true, cancelable: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollButtons(gp) {
|
||||||
|
for (var i = 0; i < gp.buttons.length; i++) {
|
||||||
|
var down = gp.buttons[i].pressed;
|
||||||
|
if (down && !buttonWasDown[i]) {
|
||||||
|
if (i === RAIL_REVEAL_BUTTON) revealPlayerRail();
|
||||||
|
else if (BUTTON_KEYS[i]) fireKey(BUTTON_KEYS[i]);
|
||||||
|
}
|
||||||
|
buttonWasDown[i] = down;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stickDirections(gp) {
|
||||||
|
var x = gp.axes[0] || 0;
|
||||||
|
var y = gp.axes[1] || 0;
|
||||||
|
return {
|
||||||
|
left: x < -STICK_DEADZONE,
|
||||||
|
right: x > STICK_DEADZONE,
|
||||||
|
up: y < -STICK_DEADZONE,
|
||||||
|
down: y > STICK_DEADZONE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollDirection(name, spec, down, now) {
|
||||||
|
var wasDown = !!dirWasDown[name];
|
||||||
|
if (down && !wasDown) {
|
||||||
|
fireKey(spec);
|
||||||
|
dirRepeatAt[name] = now + REPEAT_DELAY_MS;
|
||||||
|
} else if (down && wasDown && now >= (dirRepeatAt[name] || Infinity)) {
|
||||||
|
fireKey(spec);
|
||||||
|
dirRepeatAt[name] = now + REPEAT_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
dirWasDown[name] = down;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollDpad(gp, now) {
|
||||||
|
var stick = stickDirections(gp);
|
||||||
|
Object.keys(DPAD_BUTTONS).forEach(function (idx) {
|
||||||
|
var spec = DPAD_BUTTONS[idx];
|
||||||
|
var name = spec.key.replace('Arrow', '').toLowerCase();
|
||||||
|
var down = (gp.buttons[idx] && gp.buttons[idx].pressed) || stick[name];
|
||||||
|
pollDirection(name, spec, down, now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A disconnected gamepad's slot stays in the array (gp.connected flips to
|
||||||
|
// false) rather than being removed — a plain truthiness check on the array
|
||||||
|
// entry treats a stale, frozen-state disconnected pad as "still there"
|
||||||
|
// forever, which both swallows the disconnect notice and (if the real
|
||||||
|
// reconnected pad lands at a different index) reads dead input forever.
|
||||||
|
function firstLiveStandardPad() {
|
||||||
|
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||||
|
for (var i = 0; i < pads.length; i++) {
|
||||||
|
var p = pads[i];
|
||||||
|
if (p && p.connected && p.mapping === 'standard') return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same standard-mapping filter as firstLiveStandardPad — otherwise a
|
||||||
|
// still-connected non-standard raw mirror (or the real pad simply
|
||||||
|
// reporting a different mapping) can mask the actual pad's disconnect:
|
||||||
|
// the toast never fires and polling never stops, even though the pad
|
||||||
|
// this module can act on is gone.
|
||||||
|
function anyLiveStandardPad() {
|
||||||
|
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||||
|
for (var i = 0; i < pads.length; i++) {
|
||||||
|
var p = pads[i];
|
||||||
|
if (p && p.connected && p.mapping === 'standard') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
var gp = firstLiveStandardPad();
|
||||||
|
if (gp) {
|
||||||
|
pollButtons(gp);
|
||||||
|
pollDpad(gp, performance.now());
|
||||||
|
}
|
||||||
|
if (polling) requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(title, icon) {
|
||||||
|
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||||
|
window.fbNotify.show({ title: title, icon: icon, accent: '#0ea5e9', durationMs: 3000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('gamepadconnected', function (e) {
|
||||||
|
var idx = e.gamepad && e.gamepad.index;
|
||||||
|
// Non-standard slots (raw HID mirrors, or anything this module can't
|
||||||
|
// safely act on) are never tracked/toasted/polled for — only ever
|
||||||
|
// treat a standard-mapped pad as "a controller connected". Keeping a
|
||||||
|
// non-standard slot out of connectedIndices also keeps it out of
|
||||||
|
// anyLiveStandardPad's count, so it can't mask a real disconnect.
|
||||||
|
if (!e.gamepad || e.gamepad.mapping !== 'standard') return;
|
||||||
|
if (connectedIndices[idx]) return; // already-announced slot re-firing (focus regain, etc.)
|
||||||
|
// On the Deck, Steam Input mirrors a real pad with 1-2 virtual XInput
|
||||||
|
// slots of its own (same physical button presses, extra indices) — only
|
||||||
|
// toast for the first slot seen so plugging in one controller doesn't
|
||||||
|
// spam three "connected" notices.
|
||||||
|
var isFirstSlot = Object.keys(connectedIndices).length === 0;
|
||||||
|
connectedIndices[idx] = true;
|
||||||
|
|
||||||
|
if (isFirstSlot) notify('Controller connected', '🎮');
|
||||||
|
buttonWasDown = {};
|
||||||
|
dirWasDown = {};
|
||||||
|
dirRepeatAt = {};
|
||||||
|
if (!polling) {
|
||||||
|
polling = true;
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('gamepaddisconnected', function (e) {
|
||||||
|
var idx = e.gamepad && e.gamepad.index;
|
||||||
|
delete connectedIndices[idx];
|
||||||
|
if (!anyLiveStandardPad()) {
|
||||||
|
polling = false;
|
||||||
|
notify('Controller disconnected', '🔌');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1292,6 +1292,7 @@
|
|||||||
<script defer src="/static/v3/theme-core.js"></script>
|
<script defer src="/static/v3/theme-core.js"></script>
|
||||||
<script defer src="/static/v3/progression-core.js"></script>
|
<script defer src="/static/v3/progression-core.js"></script>
|
||||||
<script defer src="/static/v3/notifications.js"></script>
|
<script defer src="/static/v3/notifications.js"></script>
|
||||||
|
<script defer src="/static/v3/gamepad.js"></script>
|
||||||
<script defer src="/static/v3/profile.js"></script>
|
<script defer src="/static/v3/profile.js"></script>
|
||||||
<script defer src="/static/v3/progress.js"></script>
|
<script defer src="/static/v3/progress.js"></script>
|
||||||
<script defer src="/static/v3/shop.js"></script>
|
<script defer src="/static/v3/shop.js"></script>
|
||||||
@@ -1322,6 +1323,7 @@
|
|||||||
the cover picker (window.__fbOpenImagePicker). -->
|
the cover picker (window.__fbOpenImagePicker). -->
|
||||||
<script defer src="/static/v3/image-picker.js"></script>
|
<script defer src="/static/v3/image-picker.js"></script>
|
||||||
<script defer src="/static/v3/songs.js"></script>
|
<script defer src="/static/v3/songs.js"></script>
|
||||||
|
<script defer src="/static/v3/gamepad-nav.js"></script>
|
||||||
<script defer src="/static/v3/lessons.js"></script>
|
<script defer src="/static/v3/lessons.js"></script>
|
||||||
<script defer src="/static/v3/dashboard.js"></script>
|
<script defer src="/static/v3/dashboard.js"></script>
|
||||||
<script defer src="/static/v3/settings.js"></script>
|
<script defer src="/static/v3/settings.js"></script>
|
||||||
|
|||||||
@@ -4259,6 +4259,127 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Grid cursor navigation (arrow keys / gamepad d-pad) ─────────────────
|
||||||
|
// shortcuts.js's generic library arrow-nav (_handleLibArrowNav) can't reach
|
||||||
|
// this screen: it assumes every navigable item is already a DOM node, but
|
||||||
|
// this grid is windowed — most of the library isn't in the DOM at any given
|
||||||
|
// scroll position. Cursor state here is an absolute index into state.songs,
|
||||||
|
// and moving it may need to fetch a page and/or scroll the window before the
|
||||||
|
// target card exists to highlight or activate.
|
||||||
|
let _gpIdx = null;
|
||||||
|
|
||||||
|
function _gpCardEl(idx) {
|
||||||
|
return document.querySelector('#v3-songs-grid [data-idx="' + idx + '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _gpApplyHighlight() {
|
||||||
|
const prev = document.querySelector('#v3-songs-grid [data-gp-cursor]');
|
||||||
|
if (prev) {
|
||||||
|
prev.removeAttribute('data-gp-cursor');
|
||||||
|
prev.querySelector('[data-v3-play]')?.classList.remove('ring-2', 'ring-fb-primary');
|
||||||
|
}
|
||||||
|
if (_gpIdx == null) return;
|
||||||
|
const el = _gpCardEl(_gpIdx);
|
||||||
|
if (!el) return;
|
||||||
|
el.setAttribute('data-gp-cursor', '1');
|
||||||
|
el.querySelector('[data-v3-play]')?.classList.add('ring-2', 'ring-fb-primary');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _gpEnsureVisible(idx) {
|
||||||
|
const main = document.getElementById('v3-main'), sizer = _sizerEl();
|
||||||
|
if (!main || !sizer) return;
|
||||||
|
const { cols, rowH } = measureGeom();
|
||||||
|
const row = Math.floor(idx / Math.max(1, cols));
|
||||||
|
const sizerTop = _sizerTopInScroller(main, sizer);
|
||||||
|
const rowTop = sizerTop + row * rowH;
|
||||||
|
const rowBottom = rowTop + rowH;
|
||||||
|
const viewTop = main.scrollTop, viewBottom = viewTop + main.clientHeight;
|
||||||
|
if (rowTop < viewTop) main.scrollTop = rowTop;
|
||||||
|
else if (rowBottom > viewBottom) main.scrollTop = rowBottom - main.clientHeight;
|
||||||
|
await renderWindow();
|
||||||
|
|
||||||
|
// #v3-songs-toolbar is sticky, but only pins to the top once scrolled
|
||||||
|
// PAST its natural in-flow position — before that point it isn't
|
||||||
|
// covering anything, after it covers a fixed band. That makes its
|
||||||
|
// occlusion scroll-dependent in a way no single precomputed offset
|
||||||
|
// captures, so measure the real rendered overlap and correct for it,
|
||||||
|
// rather than assuming the toolbar's height is always "lost" space.
|
||||||
|
const toolbar = document.getElementById('v3-songs-toolbar');
|
||||||
|
const cardEl = _gpCardEl(idx);
|
||||||
|
if (toolbar && cardEl) {
|
||||||
|
const overlap = toolbar.getBoundingClientRect().bottom - cardEl.getBoundingClientRect().top;
|
||||||
|
if (overlap > 0) {
|
||||||
|
// Push the row DOWN the screen to clear the toolbar — that means
|
||||||
|
// scrolling the content back UP, i.e. decreasing scrollTop (screen
|
||||||
|
// position = content position - scrollTop).
|
||||||
|
main.scrollTop -= overlap;
|
||||||
|
await renderWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _gpMove(delta) {
|
||||||
|
if (!state.total) return;
|
||||||
|
if (_gpIdx == null) {
|
||||||
|
// Nothing selected yet — land on the first card and stop, same as
|
||||||
|
// shortcuts.js's legacy _handleLibArrowNav does for an empty
|
||||||
|
// selection: the first press establishes a cursor, it doesn't also
|
||||||
|
// move it (Right/Down previously skipped straight past row 0).
|
||||||
|
_gpIdx = 0;
|
||||||
|
} else {
|
||||||
|
const next = Math.max(0, Math.min(state.total - 1, _gpIdx + delta));
|
||||||
|
if (next === _gpIdx) return;
|
||||||
|
_gpIdx = next;
|
||||||
|
}
|
||||||
|
await ensureWindow(Math.max(0, _gpIdx - 1), Math.min(state.total, _gpIdx + 2));
|
||||||
|
await _gpEnsureVisible(_gpIdx);
|
||||||
|
_gpApplyHighlight();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _gpActivate() {
|
||||||
|
if (_gpIdx == null) return;
|
||||||
|
_gpCardEl(_gpIdx)?.querySelector('[data-v3-play]')?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bails on focus inside anything with its own keyboard semantics — form
|
||||||
|
// controls, buttons, and dialog/drawer overlays — same intent as
|
||||||
|
// shortcuts.js's _isInsideInteractiveControl, reimplemented locally since
|
||||||
|
// this is a plain script (not an ES module) and can't import it.
|
||||||
|
//
|
||||||
|
// The form-control/button check requires the element to be VISIBLE, not
|
||||||
|
// merely present: screens stay in the DOM (hidden, not removed) when you
|
||||||
|
// navigate away, so a real <button> focused on some OTHER now-hidden
|
||||||
|
// screen (dashboard, etc.) can leave document.activeElement pointing at
|
||||||
|
// it — an el.closest('#v3-songs') scope check would let that stale focus
|
||||||
|
// through fine, but would ALSO wrongly stop blocking genuinely-focused
|
||||||
|
// shared chrome like the topbar search input (#v3-search lives outside
|
||||||
|
// #v3-songs's DOM subtree even while v3-songs is the active screen).
|
||||||
|
// Visibility is the actual distinction that matters here, not DOM
|
||||||
|
// nesting. The dialog/drawer-overlay and contentEditable checks stay as
|
||||||
|
// they were — overlay-level concerns regardless of which screen sits
|
||||||
|
// underneath.
|
||||||
|
function _gpBlockedTarget(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(el.tagName) && el.offsetParent !== null) return true;
|
||||||
|
if (el.isContentEditable) return true;
|
||||||
|
if (el.closest && el.closest('[role="dialog"], .feedBack-modal, #lib-filter-drawer')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (!songsActive() || state.view !== 'grid') return;
|
||||||
|
if (_gpBlockedTarget(document.activeElement)) return;
|
||||||
|
const isActivate = e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar';
|
||||||
|
if (!isActivate && !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (isActivate) { _gpActivate(); return; }
|
||||||
|
const { cols } = measureGeom();
|
||||||
|
if (e.key === 'ArrowRight') _gpMove(1);
|
||||||
|
else if (e.key === 'ArrowLeft') _gpMove(-1);
|
||||||
|
else if (e.key === 'ArrowDown') _gpMove(cols);
|
||||||
|
else if (e.key === 'ArrowUp') _gpMove(-cols);
|
||||||
|
});
|
||||||
|
|
||||||
if (sm && typeof sm.on === 'function') {
|
if (sm && typeof sm.on === 'function') {
|
||||||
sm.on('screen:changed', (e) => {
|
sm.on('screen:changed', (e) => {
|
||||||
const id = e && e.detail && e.detail.id;
|
const id = e && e.detail && e.detail.id;
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// Behavioral tests for static/v3/gamepad.js — the controller polling state
|
||||||
|
// machine. gamepad.js is a plain IIFE with no exports, so it's loaded into a vm
|
||||||
|
// with a fake navigator/window/document and driven frame-by-frame through a
|
||||||
|
// manual requestAnimationFrame queue. This exercises the parts that were only
|
||||||
|
// ever checked on a real Steam Deck: standard-mapping filtering, Steam Input's
|
||||||
|
// duplicate-slot dedup, disconnect masking, button edge-detection, d-pad/stick
|
||||||
|
// key-repeat timing, and the analog-stick deadzone.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'gamepad.js'), 'utf8');
|
||||||
|
|
||||||
|
function pad(index, opts = {}) {
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
connected: opts.connected !== false,
|
||||||
|
mapping: opts.mapping || 'standard',
|
||||||
|
buttons: (opts.buttons || []).map(p => ({ pressed: !!p })),
|
||||||
|
axes: opts.axes || [0, 0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a fresh gamepad.js instance with a controllable environment.
|
||||||
|
function load() {
|
||||||
|
let pads = [];
|
||||||
|
const listeners = {};
|
||||||
|
const rafQueue = [];
|
||||||
|
const fired = []; // synthetic key codes dispatched at the focused element
|
||||||
|
const toasts = []; // {title,...} from fbNotify.show
|
||||||
|
let clock = 0;
|
||||||
|
|
||||||
|
const activeElement = { dispatchEvent(evt) { fired.push(evt.code); return true; } };
|
||||||
|
const sandbox = {
|
||||||
|
console: { log() {}, error() {} },
|
||||||
|
performance: { now: () => clock },
|
||||||
|
requestAnimationFrame: (fn) => { rafQueue.push(fn); return rafQueue.length; },
|
||||||
|
navigator: { getGamepads: () => pads },
|
||||||
|
KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } },
|
||||||
|
document: {
|
||||||
|
activeElement,
|
||||||
|
// revealPlayerRail() looks these up; returning null makes button 3 a no-op.
|
||||||
|
querySelector: () => null,
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
addEventListener: (t, fn) => { (listeners[t] || (listeners[t] = [])).push(fn); },
|
||||||
|
fbNotify: { show: (o) => toasts.push(o) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vm.runInNewContext(SRC, sandbox);
|
||||||
|
|
||||||
|
const emit = (type, gamepad) => (listeners[type] || []).forEach(fn => fn({ gamepad }));
|
||||||
|
return {
|
||||||
|
setPads: (arr) => { pads = arr; },
|
||||||
|
connect: (gp) => emit('gamepadconnected', gp),
|
||||||
|
disconnect: (gp) => emit('gamepaddisconnected', gp),
|
||||||
|
tick: () => { const fn = rafQueue.shift(); if (fn) fn(); },
|
||||||
|
polling: () => rafQueue.length > 0, // a live tick re-queues itself only while polling
|
||||||
|
setClock: (t) => { clock = t; },
|
||||||
|
fired, toasts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a non-standard pad is ignored entirely (no toast, no polling)', () => {
|
||||||
|
const g = load();
|
||||||
|
const p = pad(0, { mapping: 'xbox-nonstandard' });
|
||||||
|
g.setPads([p]);
|
||||||
|
g.connect(p);
|
||||||
|
assert.equal(g.toasts.length, 0);
|
||||||
|
assert.equal(g.polling(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a standard pad connecting toasts once and starts polling', () => {
|
||||||
|
const g = load();
|
||||||
|
const p = pad(0);
|
||||||
|
g.setPads([p]);
|
||||||
|
g.connect(p);
|
||||||
|
assert.equal(g.toasts.length, 1);
|
||||||
|
assert.equal(g.toasts[0].title, 'Controller connected');
|
||||||
|
assert.equal(g.polling(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Steam Input's duplicate virtual slots only toast once", () => {
|
||||||
|
const g = load();
|
||||||
|
const a = pad(0), b = pad(1);
|
||||||
|
g.setPads([a, b]);
|
||||||
|
g.connect(a);
|
||||||
|
g.connect(b); // same physical controller, second XInput mirror slot
|
||||||
|
assert.equal(g.toasts.length, 1, 'one physical controller = one toast');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('face buttons edge-detect: fire once per press, not once per frame', () => {
|
||||||
|
const g = load();
|
||||||
|
const p = pad(0, { buttons: [true] }); // button 0 held down
|
||||||
|
g.setPads([p]);
|
||||||
|
g.connect(p);
|
||||||
|
g.tick();
|
||||||
|
g.tick(); // still held on the next frame
|
||||||
|
assert.deepEqual(g.fired, ['Space'], 'held button must not auto-repeat');
|
||||||
|
|
||||||
|
p.buttons[0].pressed = false; g.tick(); // release
|
||||||
|
p.buttons[0].pressed = true; g.tick(); // press again
|
||||||
|
assert.deepEqual(g.fired, ['Space', 'Space'], 'a fresh press fires again');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('button 1 maps to Escape; button 3 (rail reveal) fires no key', () => {
|
||||||
|
const g = load();
|
||||||
|
const p = pad(0, { buttons: [false, true, false, true] });
|
||||||
|
g.setPads([p]);
|
||||||
|
g.connect(p);
|
||||||
|
g.tick();
|
||||||
|
assert.deepEqual(g.fired, ['Escape'], 'B=Escape, Y=rail-reveal (no synthetic key)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('d-pad / stick repeat: initial fire, delay, then interval repeats', () => {
|
||||||
|
const g = load();
|
||||||
|
const p = pad(0, { buttons: [] }); // no buttons; drive via the d-pad indices
|
||||||
|
p.buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
|
||||||
|
p.buttons[13].pressed = true; // ArrowDown
|
||||||
|
g.setPads([p]);
|
||||||
|
g.connect(p);
|
||||||
|
|
||||||
|
g.setClock(0); g.tick(); // initial press
|
||||||
|
g.setClock(399); g.tick(); // before the 400ms repeat delay
|
||||||
|
g.setClock(400); g.tick(); // repeat delay elapsed
|
||||||
|
assert.deepEqual(g.fired, ['ArrowDown', 'ArrowDown'], 'one initial + one repeat at 400ms, nothing at 399ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analog stick honors the deadzone', () => {
|
||||||
|
const g = load();
|
||||||
|
const p = pad(0);
|
||||||
|
p.buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
|
||||||
|
g.setPads([p]);
|
||||||
|
g.connect(p);
|
||||||
|
|
||||||
|
p.axes = [0, 0.4]; g.setClock(0); g.tick(); // below 0.5 deadzone → nothing
|
||||||
|
assert.deepEqual(g.fired, [], 'sub-deadzone deflection is ignored');
|
||||||
|
p.axes = [0.6, 0]; g.setClock(1); g.tick(); // right, past deadzone
|
||||||
|
assert.deepEqual(g.fired, ['ArrowRight']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnecting one of two live slots does not stop polling or toast', () => {
|
||||||
|
const g = load();
|
||||||
|
const a = pad(0), b = pad(1);
|
||||||
|
g.setPads([a, b]);
|
||||||
|
g.connect(a); g.connect(b);
|
||||||
|
g.toasts.length = 0;
|
||||||
|
|
||||||
|
b.connected = false; // Steam mirror slot drops
|
||||||
|
g.setPads([a, b]);
|
||||||
|
g.disconnect(b);
|
||||||
|
assert.equal(g.toasts.length, 0, 'a still-live standard pad masks the mirror disconnect');
|
||||||
|
assert.equal(g.polling(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnecting the last live pad stops polling and toasts', () => {
|
||||||
|
const g = load();
|
||||||
|
const a = pad(0);
|
||||||
|
g.setPads([a]);
|
||||||
|
g.connect(a);
|
||||||
|
a.connected = false;
|
||||||
|
g.setPads([a]);
|
||||||
|
g.disconnect(a);
|
||||||
|
assert.equal(g.toasts.some(t => t.title === 'Controller disconnected'), true);
|
||||||
|
// Drain the final queued tick; polling must not re-queue itself.
|
||||||
|
g.tick();
|
||||||
|
assert.equal(g.polling(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('polling acts only on the live standard pad, skipping stale/non-standard slots', () => {
|
||||||
|
const g = load();
|
||||||
|
const dead = pad(0, { connected: false, buttons: [true] }); // frozen, disconnected
|
||||||
|
const raw = pad(1, { mapping: 'raw-hid', buttons: [true] }); // non-standard
|
||||||
|
const live = pad(2, { buttons: [true] }); // standard, button 0 down
|
||||||
|
g.setPads([dead, raw, live]);
|
||||||
|
g.connect(live);
|
||||||
|
g.tick();
|
||||||
|
assert.deepEqual(g.fired, ['Space'], 'input read from the live standard pad only');
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Behavioral tests for static/v3/gamepad-nav.js — the generic Tab-order
|
||||||
|
// emulation layer. Loaded into a vm with a minimal fake DOM; the module's
|
||||||
|
// single keydown listener is captured and fed synthetic events. Covers the
|
||||||
|
// three things it does: arrow-key focus traversal (with clamping), Enter/Space
|
||||||
|
// activation via .click() (Chromium won't natively activate untrusted keys),
|
||||||
|
// and the Escape "go back" fallback — plus the !isTrusted / defaultPrevented
|
||||||
|
// gating that keeps it off real keyboard users.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'gamepad-nav.js'), 'utf8');
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const state = { focused: null, clicked: [], screens: [] };
|
||||||
|
const body = { tagName: 'BODY' };
|
||||||
|
const cfg = { modal: null, nav: null, screen: null, backButtons: [], activeEl: body };
|
||||||
|
let handler = null;
|
||||||
|
|
||||||
|
function elem(opts = {}) {
|
||||||
|
return {
|
||||||
|
tagName: opts.tagName || 'BUTTON',
|
||||||
|
type: opts.type,
|
||||||
|
isContentEditable: !!opts.isContentEditable,
|
||||||
|
offsetParent: opts.visible === false ? null : {},
|
||||||
|
_focusables: opts.focusables || [],
|
||||||
|
querySelectorAll() { return this._focusables; },
|
||||||
|
focus() { state.focused = this; },
|
||||||
|
click() { state.clicked.push(this); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = {
|
||||||
|
body,
|
||||||
|
get activeElement() { return cfg.activeEl; },
|
||||||
|
addEventListener(type, fn) { if (type === 'keydown') handler = fn; },
|
||||||
|
querySelector(sel) {
|
||||||
|
if (sel.includes('dialog') || sel.includes('modal')) return cfg.modal;
|
||||||
|
if (sel.includes('screen.active')) return cfg.screen;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getElementById(id) { return id === 'v3-nav' ? cfg.nav : null; },
|
||||||
|
querySelectorAll() { return cfg.backButtons; }, // only the Escape back-button lookup uses this
|
||||||
|
};
|
||||||
|
const sandbox = { document, window: { showScreen: (id) => state.screens.push(id) } };
|
||||||
|
vm.runInNewContext(SRC, sandbox);
|
||||||
|
|
||||||
|
const fire = (over) => handler(Object.assign({ isTrusted: false, defaultPrevented: false, key: '' }, over));
|
||||||
|
return { cfg, state, body, elem, fire };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a screen holding `n` visible focusables; expose them for cfg.activeEl.
|
||||||
|
function screenWith(g, n) {
|
||||||
|
const items = Array.from({ length: n }, () => g.elem());
|
||||||
|
g.cfg.screen = g.elem({ focusables: items });
|
||||||
|
g.cfg.nav = g.elem({ focusables: [] });
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('real keyboard input (isTrusted) is never touched', () => {
|
||||||
|
const g = load();
|
||||||
|
const items = screenWith(g, 3);
|
||||||
|
g.cfg.activeEl = items[0];
|
||||||
|
g.fire({ isTrusted: true, key: 'ArrowDown' });
|
||||||
|
assert.equal(g.state.focused, null, 'trusted events must pass through untouched');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a key already handled by another listener (defaultPrevented) is skipped', () => {
|
||||||
|
const g = load();
|
||||||
|
const items = screenWith(g, 3);
|
||||||
|
g.cfg.activeEl = items[0];
|
||||||
|
g.fire({ defaultPrevented: true, key: 'ArrowDown' });
|
||||||
|
assert.equal(g.state.focused, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ArrowDown/Right moves to the next focusable; ArrowUp/Left to the previous', () => {
|
||||||
|
const g = load();
|
||||||
|
const items = screenWith(g, 3);
|
||||||
|
g.cfg.activeEl = items[1];
|
||||||
|
g.fire({ key: 'ArrowDown' });
|
||||||
|
assert.equal(g.state.focused, items[2], 'Down = next');
|
||||||
|
|
||||||
|
g.cfg.activeEl = items[1];
|
||||||
|
g.fire({ key: 'ArrowLeft' });
|
||||||
|
assert.equal(g.state.focused, items[0], 'Left = previous');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traversal clamps at both ends', () => {
|
||||||
|
const g = load();
|
||||||
|
const items = screenWith(g, 3);
|
||||||
|
g.cfg.activeEl = items[2];
|
||||||
|
g.fire({ key: 'ArrowDown' });
|
||||||
|
assert.equal(g.state.focused, items[2], 'no wrap past the last item');
|
||||||
|
|
||||||
|
g.cfg.activeEl = items[0];
|
||||||
|
g.fire({ key: 'ArrowUp' });
|
||||||
|
assert.equal(g.state.focused, items[0], 'no wrap before the first item');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with nothing relevant focused, the first arrow lands on the first item', () => {
|
||||||
|
const g = load();
|
||||||
|
const items = screenWith(g, 3);
|
||||||
|
g.cfg.activeEl = g.body; // not in the focusable list
|
||||||
|
g.fire({ key: 'ArrowRight' });
|
||||||
|
assert.equal(g.state.focused, items[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden focusables are skipped (offsetParent visibility)', () => {
|
||||||
|
const g = load();
|
||||||
|
const visibleA = g.elem();
|
||||||
|
const hidden = g.elem({ visible: false });
|
||||||
|
const visibleB = g.elem();
|
||||||
|
g.cfg.screen = g.elem({ focusables: [visibleA, hidden, visibleB] });
|
||||||
|
g.cfg.nav = g.elem({ focusables: [] });
|
||||||
|
g.cfg.activeEl = visibleA;
|
||||||
|
g.fire({ key: 'ArrowDown' });
|
||||||
|
assert.equal(g.state.focused, visibleB, 'the hidden element is not a traversal stop');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Enter/Space activates the focused control via click()', () => {
|
||||||
|
const g = load();
|
||||||
|
const btn = g.elem({ tagName: 'BUTTON' });
|
||||||
|
g.cfg.activeEl = btn;
|
||||||
|
g.fire({ key: 'Enter' });
|
||||||
|
g.fire({ key: ' ' });
|
||||||
|
assert.deepEqual(g.state.clicked, [btn, btn], 'both Enter and Space activate');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activation never clicks a focused text field or the body', () => {
|
||||||
|
const g = load();
|
||||||
|
g.cfg.activeEl = g.elem({ tagName: 'INPUT', type: 'text' });
|
||||||
|
g.fire({ key: 'Enter' });
|
||||||
|
g.cfg.activeEl = g.body;
|
||||||
|
g.fire({ key: ' ' });
|
||||||
|
assert.deepEqual(g.state.clicked, [], 'no synthetic click into a text input or the bare body');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape clicks the visible in-screen back button when one exists', () => {
|
||||||
|
const g = load();
|
||||||
|
const hiddenBack = g.elem({ visible: false }); // a back button from another, now-hidden screen
|
||||||
|
const visibleBack = g.elem();
|
||||||
|
g.cfg.backButtons = [hiddenBack, visibleBack];
|
||||||
|
g.fire({ key: 'Escape' });
|
||||||
|
assert.deepEqual(g.state.clicked, [visibleBack], 'the visible back button wins, not DOM order');
|
||||||
|
assert.deepEqual(g.state.screens, [], 'no home fallback while a back button handled it');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape with no visible back button falls back to the home screen', () => {
|
||||||
|
const g = load();
|
||||||
|
g.cfg.backButtons = [g.elem({ visible: false })];
|
||||||
|
g.fire({ key: 'Escape' });
|
||||||
|
assert.deepEqual(g.state.screens, ['v3-home']);
|
||||||
|
assert.deepEqual(g.state.clicked, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user