B: Escape for anything that looks open, never back over it; hold B goes back (E31)

The markup-based popup fix missed X's account menu (a role="group" on a
full-screen mask). B now: closes dialogs/popovers with the browser APIs;
if anything looks open (aria-modal, popup markup, or a fixed layer that
covers the page and hides focusable content outside it) sends one
Escape (legacy keyCode/which set) and does not go back; otherwise goes
back as before. Holding B 700 ms always goes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mmis1000andClaude Opus 5.5 committed 2026-10-13 01:24:38 +08:00
1 parent 883b83475a
commit acd22c7d05
7 files changed
+200 -88

No files matched your search

+2 -1
View File
@@ -56,7 +56,8 @@ docs/
| D-pad | Move between elements (4-direction scored graph) |
| Left stick | Free aim at any angle (ray cast) |
| A | Click / focus a text field |
| B | Leave text field → leave container → close dialog → history back |
| B | Leave text field → leave container → close dialog / popover → Escape to whatever covers the page (menus, overlays) → otherwise history back |
| Hold B | Always history back (if a popup won't close) |
| X | Enter the focused container (a post card, or a scrollable panel — A also enters a panel) |
| Y | Cycle graph view: off → focused item → whole screen |
| LB / RB | Previous / next tab (extension only) |
+72 -29
View File
@@ -1,8 +1,8 @@
// ==UserScript==
// @name SofaNav
// @namespace https://sofanav.mmis1000.me
// @version 0.3.0.202610121706
// @description Navigate web pages with a controller: D-pad / left stick move focus, A click, B back, X enter, Y graph view. (build 230694036146)
// @version 0.3.0.202610121723
// @description Navigate web pages with a controller: D-pad / left stick move focus, A click, B back, X enter, Y graph view. (build 643003d32b0b)
// @homepageURL https://mmis1000.me/SofaNav/
// @downloadURL https://mmis1000.me/SofaNav/sofanav.user.js
// @updateURL https://mmis1000.me/SofaNav/sofanav.user.js
@@ -16,7 +16,7 @@
// @inject-into content
// ==/UserScript==
// Generated from extension/content.js by build-userscript.js. Edit that file, not this one.
// Build 230694036146 = first 12 hex digits of sha256(extension/content.js).
// Build 643003d32b0b = first 12 hex digits of sha256(extension/content.js).
// LB/RB (switch tabs) needs the extension; in a userscript they do nothing.
// @sandbox DOM / @inject-into content run SofaNav in an isolated world, where the page can't
// reach it. Only then does it control cross-origin frames (over GM storage, see content.js
@@ -34,12 +34,13 @@
// parent and carries out commands; the top frame does all navigation.
const IS_FRAME = window.top !== window;
// Replaced with the content hash by build-userscript.js; 'dev' when loaded unbuilt (extension, test pages).
const BUILD = '230694036146';
const BUILD = '643003d32b0b';
const USERSCRIPT = true; // the userscript build sets this
if (!IS_FRAME) console.info('[sofanav] build', BUILD);
// Standard-mapping button indexes.
const BTN = { A: 0, B: 1, X: 2, Y: 3, LB: 4, RB: 5, LT: 6, RT: 7, VIEW: 8, MENU: 9, UP: 12, DOWN: 13, LEFT: 14, RIGHT: 15 };
const HOLD_BACK = 700; // ms holding B that always goes back a page
const STICK_PRESS = 0.55; // stick deflection that counts as pushing
const STICK_RELEASE = 0.45; // ...and that it must drop below to count as released (keep close to STICK_PRESS)
const STICK_SETTLE = 40; // ms to let a stick push finish before reading its angle
@@ -88,6 +89,7 @@
let lastMove = null; // {from, to, angle} so pushing the opposite way returns
let scrolledAway = false; // user scrolled manually since focus last moved
let prevButtons = [];
let bDownAt = null; // when B went down (for hold-to-go-back)
// phase: idle -> settle (stick only) -> held. See handleDirection.
const held = { phase: 'idle', stick: false, angle: null, since: 0, next: 0, lastMoveAt: -Infinity, releasedAt: null, calmSince: null };
let looping = false;
@@ -123,10 +125,10 @@
return modals.reverse().find((m) => m.getBoundingClientRect().width > 0) || null;
}
// An open popup that isn't a modal dialog: a menu or listbox floating over the page (X's
// account menu, a dropdown), an open popover, or what an expanded menu button opened. B
// closes it before going back. Menus that are part of the page layout don't float, so they
// don't count. Returns { el, trigger } or null.
// An open popup marked up as one (not a modal dialog): a menu or listbox floating over the
// page, an open popover, or what an expanded menu button opened. Menus that are part of the
// page layout don't float, so they don't count. Markup is only one hint that something is
// open (many sites mark up nothing, see coveringOverlay). Returns { el, trigger } or null.
function activePopup() {
const shown = (el) => {
const r = el.getBoundingClientRect();
@@ -151,18 +153,53 @@
return found.pop() || null; // later in the document = on top, usually
}
async function closePopup(popup) {
const { el, trigger } = popup;
// Escape, sent from inside the popup so its own key handler sees it.
const inside = [nodeOf(current), document.activeElement].find((n) => n instanceof Element && el.contains(n)) || el;
for (const type of ['keydown', 'keyup']) {
inside.dispatchEvent(new KeyboardEvent(type, { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true, cancelable: true, composed: true }));
// A layer covering the page, whatever its markup (X's menus sit on a transparent full-screen
// mask): the same big fixed element is on top across the screen, and page content outside it
// is hidden beneath it. An app whose whole page lives in one fixed element has nothing outside
// it, so it doesn't count. This only tells B not to leave the page; it can't say how to close it.
function coveringOverlay() {
let layer = null;
for (const [fx, fy] of [[0.5, 0.5], [0.15, 0.15], [0.85, 0.15], [0.15, 0.85], [0.85, 0.85]]) {
let f = null;
for (let n = document.elementFromPoint(innerWidth * fx, innerHeight * fy); n && n !== document.documentElement; n = n.parentElement) {
if (getComputedStyle(n).position !== 'fixed') continue;
const r = n.getBoundingClientRect();
if (r.width >= innerWidth * 0.9 && r.height >= innerHeight * 0.9) f = n; // outermost
}
if (!f || (layer && f !== layer)) return null;
layer = f;
}
await new Promise((r) => setTimeout(r, 200));
if (activePopup()?.el !== el) return;
// Still open (scripted Escape is ignored by native popovers and some sites).
if (el.matches(':popover-open')) { try { el.hidePopover(); } catch {} return; }
if (trigger?.isConnected) activate(trigger); // menu buttons toggle
let covered = 0;
for (const el of document.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')) {
if (layer.contains(el)) continue;
const r = el.getBoundingClientRect();
const x = r.left + r.width / 2, y = r.top + r.height / 2;
if (r.width < 2 || r.height < 2 || x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) continue;
const hit = document.elementFromPoint(x, y);
if (hit && layer.contains(hit) && ++covered >= 3) return layer;
}
return null;
}
// A scripted Escape (keydown + keyup). Pages' own handlers see it; browser defaults ignore it
// (untrusted). Older handlers check keyCode/which, which the constructor leaves at 0.
function sendEscape(target) {
for (const type of ['keydown', 'keyup']) {
const e = new KeyboardEvent(type, { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true, composed: true, view: document.defaultView });
for (const k of ['keyCode', 'which']) Object.defineProperty(e, k, { get: () => 27 });
target.dispatchEvent(e);
}
}
// Something is open over the page (aria-modal dialog, floating menu, covering layer): one
// Escape attempt per B press, from where the user is (inside it if possible), never history.back.
async function dismissOpen(box, trigger) {
const here = [nodeOf(current), document.activeElement].filter((n) => n instanceof Element && n.isConnected);
sendEscape(here.find((n) => box.contains(n)) || here[0] || box);
await new Promise((r) => setTimeout(r, 250));
if (!box.isConnected || box.getBoundingClientRect().width < 2) return;
if (trigger?.isConnected && trigger.getAttribute('aria-expanded') === 'true') { activate(trigger); return; } // menu buttons toggle
say('Hold B to go back a page');
}
function usable(el) {
@@ -1381,7 +1418,8 @@
if (activeScope() && await leaveScope()) return;
const modal = activeModal();
const popup = activePopup();
if (popup && (!modal || modal.contains(popup.el))) { await closePopup(popup); return; }
// Browser primitives first: these close for real (and respect a cancelled close request).
if (popup?.el.matches(':popover-open') && (!modal || modal.contains(popup.el))) { try { popup.el.hidePopover(); } catch {} return; }
if (modal?.tagName === 'DIALOG') {
// A script-dispatched Escape is untrusted, so browsers ignore it for <dialog>.
// requestClose() is the real close request (fires cancel, honours preventDefault).
@@ -1389,16 +1427,15 @@
else if (modal.dispatchEvent(new Event('cancel', { cancelable: true }))) modal.close();
return;
}
if (modal) {
// ARIA dialogs: their own code listens for Escape.
const target = ae || document.body;
for (const type of ['keydown', 'keyup']) {
target.dispatchEvent(new KeyboardEvent(type, { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true, cancelable: true }));
}
return;
}
// Custom UI: anything that looks open gets an Escape instead of leaving the page.
if (popup && (!modal || modal.contains(popup.el))) { await dismissOpen(popup.el, popup.trigger); return; }
if (modal) { await dismissOpen(modal); return; }
const overlay = coveringOverlay();
if (overlay) { await dismissOpen(overlay); return; }
wentBack = true;
history.back();
}
let wentBack = false; // this B press already went back a page (so holding it doesn't go back twice)
function pageScroll(sign) {
scrolledAway = true;
@@ -1935,7 +1972,13 @@
updateNeedle(handleDirection(now, buttons, axes));
if (pressed(BTN.A)) { if (current && usable(current)) activate(current); else move(DIR_ANGLE.down, true); }
if (pressed(BTN.B)) back();
if (pressed(BTN.B)) { wentBack = false; bDownAt = now; back(); }
// Hold B: always go back a page (when the press only closed something, or looked stuck).
if (buttons[BTN.B] && bDownAt != null && now - bDownAt >= HOLD_BACK) {
bDownAt = null;
if (!wentBack) { wentBack = true; say('Back'); history.back(); }
}
if (!buttons[BTN.B]) bDownAt = null;
if (pressed(BTN.Y)) cycleViz();
if (pressed(BTN.X)) enterScope();
if (pressed(BTN.MENU)) debugReport(); // ☰: debug report (Alt+Shift+D can be taken by the page)
+24 -13
View File
@@ -416,19 +416,30 @@ automated (closed source, store-only); check it by hand with
`/test/attack/attacker.html` and `/test/?ext`.
**E31. B couldn't close X's account menu; it went back a page instead.** B
closed native `<dialog>`s and `aria-modal` dialogs only. X's account menu (and
most dropdowns) is a floating `role="menu"` in a full-screen layer, neither of
those, so B fell through to `history.back()` and the menu stayed open. *Fix:*
`activePopup` finds an open popup that isn't a modal: a `menu`, `listbox` or
non-modal `dialog` that floats (absolutely positioned, or inside something
fixed; menus that are part of the layout don't count), an open `popover`, or
what an expanded `aria-haspopup` button controls. B closes it first: Escape is
sent from inside the popup (X handles it on the menu, not the document); if it
is still open after 200 ms, `hidePopover()` for popovers (they ignore a scripted
Escape) or the menu button is pressed again. *Where:* `activePopup`,
`closePopup`, `back`. *Checks:* automated (`test:regress`, an X-style menu and a
native popover on the test page's "Popup menus" section); fails on 36b24f8 ("B
went back a page").
closed native `<dialog>`s and `aria-modal` dialogs only, then called
`history.back()`, and the menu stayed open. A first fix (883b834) recognised
popups by markup (`role="menu"`, `aria-haspopup`...) and still failed on X: the
debug report showed the menu is a plain `<div role="group" tabindex="0">`, and
the only reachable stop (a transparent full-screen mask covers the rest).
Markup guessing is whack-a-mole. *Fix* (design agreed with the user after an
outside review, ChatGPT): there is no reliable "Escape was unhandled" signal
(`preventDefault` means cancelled, not closed; DOM changes don't prove
causation), so B never infers success from a reaction. Instead: (1) browser
primitives close for real: `dialog.requestClose()`, `hidePopover()`; (2) if
anything *looks* open (aria-modal, popup markup, or `coveringOverlay`: the same
big fixed element is on top at five points across the screen and hides at
least 3 focusable page elements outside it, so a whole-page fixed app shell
doesn't count), B sends one Escape (with legacy `keyCode`/`which` set, from the
focused element inside it) and **never** goes back; if it's still there, the
menu button is pressed again if known, else a toast says "Hold B to go back a
page"; (3) otherwise single-press B goes back as before; (4) holding B for
700 ms always goes back (once per press), the escape hatch for anything that
won't close. *Where:* `activePopup`, `coveringOverlay`, `sendEscape`,
`dismissOpen`, `back`, B handling in `tick`. *Checks:* automated
(`test:regress`: an X-style `role="group"` menu on a mask, a native popover, an
overlay that ignores Escape (B stays, hold B goes back)); fails on 883b834 ("B
went back a page"). Not yet confirmed on x.com itself: whether X reacts to a
scripted Escape was still being checked by hand.
**E21. A frame kept its client forever.** `answered` was never reset, so after the
iframe navigated to a page without the script, it kept `hasClient = true` and its
+68 -25
View File
@@ -16,6 +16,7 @@
// Standard-mapping button indexes.
const BTN = { A: 0, B: 1, X: 2, Y: 3, LB: 4, RB: 5, LT: 6, RT: 7, VIEW: 8, MENU: 9, UP: 12, DOWN: 13, LEFT: 14, RIGHT: 15 };
const HOLD_BACK = 700; // ms holding B that always goes back a page
const STICK_PRESS = 0.55; // stick deflection that counts as pushing
const STICK_RELEASE = 0.45; // ...and that it must drop below to count as released (keep close to STICK_PRESS)
const STICK_SETTLE = 40; // ms to let a stick push finish before reading its angle
@@ -64,6 +65,7 @@
let lastMove = null; // {from, to, angle} so pushing the opposite way returns
let scrolledAway = false; // user scrolled manually since focus last moved
let prevButtons = [];
let bDownAt = null; // when B went down (for hold-to-go-back)
// phase: idle -> settle (stick only) -> held. See handleDirection.
const held = { phase: 'idle', stick: false, angle: null, since: 0, next: 0, lastMoveAt: -Infinity, releasedAt: null, calmSince: null };
let looping = false;
@@ -99,10 +101,10 @@
return modals.reverse().find((m) => m.getBoundingClientRect().width > 0) || null;
}
// An open popup that isn't a modal dialog: a menu or listbox floating over the page (X's
// account menu, a dropdown), an open popover, or what an expanded menu button opened. B
// closes it before going back. Menus that are part of the page layout don't float, so they
// don't count. Returns { el, trigger } or null.
// An open popup marked up as one (not a modal dialog): a menu or listbox floating over the
// page, an open popover, or what an expanded menu button opened. Menus that are part of the
// page layout don't float, so they don't count. Markup is only one hint that something is
// open (many sites mark up nothing, see coveringOverlay). Returns { el, trigger } or null.
function activePopup() {
const shown = (el) => {
const r = el.getBoundingClientRect();
@@ -127,18 +129,53 @@
return found.pop() || null; // later in the document = on top, usually
}
async function closePopup(popup) {
const { el, trigger } = popup;
// Escape, sent from inside the popup so its own key handler sees it.
const inside = [nodeOf(current), document.activeElement].find((n) => n instanceof Element && el.contains(n)) || el;
for (const type of ['keydown', 'keyup']) {
inside.dispatchEvent(new KeyboardEvent(type, { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true, cancelable: true, composed: true }));
// A layer covering the page, whatever its markup (X's menus sit on a transparent full-screen
// mask): the same big fixed element is on top across the screen, and page content outside it
// is hidden beneath it. An app whose whole page lives in one fixed element has nothing outside
// it, so it doesn't count. This only tells B not to leave the page; it can't say how to close it.
function coveringOverlay() {
let layer = null;
for (const [fx, fy] of [[0.5, 0.5], [0.15, 0.15], [0.85, 0.15], [0.15, 0.85], [0.85, 0.85]]) {
let f = null;
for (let n = document.elementFromPoint(innerWidth * fx, innerHeight * fy); n && n !== document.documentElement; n = n.parentElement) {
if (getComputedStyle(n).position !== 'fixed') continue;
const r = n.getBoundingClientRect();
if (r.width >= innerWidth * 0.9 && r.height >= innerHeight * 0.9) f = n; // outermost
}
if (!f || (layer && f !== layer)) return null;
layer = f;
}
await new Promise((r) => setTimeout(r, 200));
if (activePopup()?.el !== el) return;
// Still open (scripted Escape is ignored by native popovers and some sites).
if (el.matches(':popover-open')) { try { el.hidePopover(); } catch {} return; }
if (trigger?.isConnected) activate(trigger); // menu buttons toggle
let covered = 0;
for (const el of document.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')) {
if (layer.contains(el)) continue;
const r = el.getBoundingClientRect();
const x = r.left + r.width / 2, y = r.top + r.height / 2;
if (r.width < 2 || r.height < 2 || x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) continue;
const hit = document.elementFromPoint(x, y);
if (hit && layer.contains(hit) && ++covered >= 3) return layer;
}
return null;
}
// A scripted Escape (keydown + keyup). Pages' own handlers see it; browser defaults ignore it
// (untrusted). Older handlers check keyCode/which, which the constructor leaves at 0.
function sendEscape(target) {
for (const type of ['keydown', 'keyup']) {
const e = new KeyboardEvent(type, { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true, composed: true, view: document.defaultView });
for (const k of ['keyCode', 'which']) Object.defineProperty(e, k, { get: () => 27 });
target.dispatchEvent(e);
}
}
// Something is open over the page (aria-modal dialog, floating menu, covering layer): one
// Escape attempt per B press, from where the user is (inside it if possible), never history.back.
async function dismissOpen(box, trigger) {
const here = [nodeOf(current), document.activeElement].filter((n) => n instanceof Element && n.isConnected);
sendEscape(here.find((n) => box.contains(n)) || here[0] || box);
await new Promise((r) => setTimeout(r, 250));
if (!box.isConnected || box.getBoundingClientRect().width < 2) return;
if (trigger?.isConnected && trigger.getAttribute('aria-expanded') === 'true') { activate(trigger); return; } // menu buttons toggle
say('Hold B to go back a page');
}
function usable(el) {
@@ -1357,7 +1394,8 @@
if (activeScope() && await leaveScope()) return;
const modal = activeModal();
const popup = activePopup();
if (popup && (!modal || modal.contains(popup.el))) { await closePopup(popup); return; }
// Browser primitives first: these close for real (and respect a cancelled close request).
if (popup?.el.matches(':popover-open') && (!modal || modal.contains(popup.el))) { try { popup.el.hidePopover(); } catch {} return; }
if (modal?.tagName === 'DIALOG') {
// A script-dispatched Escape is untrusted, so browsers ignore it for <dialog>.
// requestClose() is the real close request (fires cancel, honours preventDefault).
@@ -1365,16 +1403,15 @@
else if (modal.dispatchEvent(new Event('cancel', { cancelable: true }))) modal.close();
return;
}
if (modal) {
// ARIA dialogs: their own code listens for Escape.
const target = ae || document.body;
for (const type of ['keydown', 'keyup']) {
target.dispatchEvent(new KeyboardEvent(type, { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true, cancelable: true }));
}
return;
}
// Custom UI: anything that looks open gets an Escape instead of leaving the page.
if (popup && (!modal || modal.contains(popup.el))) { await dismissOpen(popup.el, popup.trigger); return; }
if (modal) { await dismissOpen(modal); return; }
const overlay = coveringOverlay();
if (overlay) { await dismissOpen(overlay); return; }
wentBack = true;
history.back();
}
let wentBack = false; // this B press already went back a page (so holding it doesn't go back twice)
function pageScroll(sign) {
scrolledAway = true;
@@ -1911,7 +1948,13 @@
updateNeedle(handleDirection(now, buttons, axes));
if (pressed(BTN.A)) { if (current && usable(current)) activate(current); else move(DIR_ANGLE.down, true); }
if (pressed(BTN.B)) back();
if (pressed(BTN.B)) { wentBack = false; bDownAt = now; back(); }
// Hold B: always go back a page (when the press only closed something, or looked stuck).
if (buttons[BTN.B] && bDownAt != null && now - bDownAt >= HOLD_BACK) {
bDownAt = null;
if (!wentBack) { wentBack = true; say('Back'); history.back(); }
}
if (!buttons[BTN.B]) bDownAt = null;
if (pressed(BTN.Y)) cycleViz();
if (pressed(BTN.X)) enterScope();
if (pressed(BTN.MENU)) debugReport(); // ☰: debug report (Alt+Shift+D can be taken by the page)
+19 -10
View File
@@ -65,26 +65,35 @@ const checks = {
return { ok: !open, detail: `dialog.open=${open}` };
},
// E31: B must close a floating menu (X's account menu) or a popover, not go back a page.
async 'E31 B closes an open popup menu instead of going back'(page) {
// E31: B must close a popup (X's account menu: no menu markup, on a full-screen mask) or a
// popover, not go back a page; over something that ignores Escape it stays; holding B goes back.
async 'E31 B closes popups instead of going back; holding B goes back'(page) {
const { press, wait } = helpers(page);
const open = (btn, sel) => page.evaluate(async ([b, s]) => {
document.getElementById(b).scrollIntoView({ block: 'center' });
document.getElementById(b).click();
await new Promise((r) => setTimeout(r, 300));
__sofanav.focus(document.querySelector(s));
}, [btn, sel]);
await press('ArrowUp');
await page.evaluate(() => { location.hash = 'before-menu'; });
await page.evaluate(() => { document.getElementById('menuBtn').scrollIntoView({ block: 'center' }); document.getElementById('menuBtn').click(); });
await wait(300);
await page.evaluate(() => __sofanav.focus(document.querySelector('#menu [role=menuitem]')));
await open('menuBtn', '#menu');
await wait(300);
await press('Backspace', 700);
const menu = await page.evaluate(() => ({ open: !document.getElementById('menuLayer').hidden, hash: location.hash }));
if (menu.hash !== '#before-menu') return { ok: false, detail: `B went back a page (X-style menu open=${menu.open})` };
await page.evaluate(() => { document.getElementById('popBtn').click(); });
await wait(300);
await page.evaluate(() => __sofanav.focus(document.querySelector('#pop button')));
await open('popBtn', '#pop button');
await wait(300);
await press('Backspace', 700);
const pop = await page.evaluate(() => ({ open: !!document.getElementById('pop')?.matches(':popover-open'), hash: location.hash }));
const ok = !menu.open && !pop.open && pop.hash === '#before-menu';
return { ok, detail: `X-style menu open=${menu.open}, popover open=${pop.open}, page ${pop.hash === '#before-menu' ? 'stayed' : 'went back'}` };
await open('stuckBtn', '#menu');
await wait(300);
await press('Backspace', 700);
const stuck = await page.evaluate(() => ({ open: !document.getElementById('menuLayer').hidden, hash: location.hash }));
await page.keyboard.down('Backspace'); await wait(1000); await page.keyboard.up('Backspace'); await wait(500);
const held = await page.evaluate(() => location.hash);
const ok = !menu.open && !pop.open && pop.hash === '#before-menu' && stuck.hash === '#before-menu' && held === '';
return { ok, detail: `X-style menu open=${menu.open}, popover open=${pop.open}, stuck overlay: B ${stuck.hash === '#before-menu' ? 'stayed' : 'went back'}, hold B ${held === '' ? 'went back' : 'stayed'}` };
},
// E21: a frame that navigates to a page without the script must become one plain element.
+2 -1
View File
@@ -214,7 +214,8 @@
<tr><td>D-pad</td><td>Move to the next link or button in that direction</td></tr>
<tr><td>Left stick</td><td>Aim at any angle</td></tr>
<tr><td>A</td><td>Click; on a text field, start typing</td></tr>
<tr><td>B</td><td>Back: leave a text field, leave a card or panel, close a dialog, then go back a page</td></tr>
<tr><td>B</td><td>Back: leave a text field, leave a card or panel, close a dialog or menu, then go back a page</td></tr>
<tr><td>Hold B</td><td>Go back a page, even with a menu open</td></tr>
<tr><td>X</td><td>Enter a card or panel</td></tr>
<tr><td>Y</td><td>Graph view: off → arrows (or the stick pie) → whole page</td></tr>
<tr><td>Right stick</td><td>Scroll; on a focused panel, scrolls the panel</td></tr>
+13 -9
View File
@@ -41,7 +41,7 @@
<body>
<header>
<h1>Gamepad Nav Test</h1>
<div>Controller: D-pad/left stick move · A click · B back · Y graph · LT/RT page · right stick scroll · View on/off</div>
<div>Controller: D-pad/left stick move · A click · B back (hold: always back a page) · Y graph · LT/RT page · right stick scroll · View on/off</div>
<div>Keyboard fallback: arrows · Enter = A · Backspace = B · X = enter container · Y = graph · PgUp/PgDn = LT/RT · V = View · M = Menu (debug report) · Q/E/Z/C = stick at 45° ↖↗↙↘ · J/L rotate aim, K = push stick at aim · Y twice = stick pie</div>
</header>
@@ -275,15 +275,17 @@
<div class="row"><button id="ok">OK</button><button id="cancel">Cancel</button></div>
</dialog>
<h2>Popup menus (E31) — B closes an open menu instead of going back</h2>
<h2>Popup menus (E31) — B closes an open menu instead of going back; holding B always goes back</h2>
<div class="row">
<button type="button" id="menuBtn" aria-haspopup="menu" aria-expanded="false">Account ⋯ (X-style menu)</button>
<button type="button" id="menuBtn">Account ⋯ (X-style menu)</button>
<button type="button" id="stuckBtn">Overlay that ignores Escape</button>
<button type="button" id="popBtn" popovertarget="pop">Popover menu</button>
</div>
<!-- Like X's account menu: a full-screen layer with a click-to-close mask; Escape is handled on the menu itself. -->
<!-- Like X's account menu: no menu/dialog markup (role="group"), on a full-screen layer with a
click-to-close mask; Escape is handled on the menu itself. "Ignores Escape" opens it stuck. -->
<div id="menuLayer" hidden style="position:fixed;inset:0;z-index:50">
<div id="menuMask" style="position:absolute;inset:0"></div>
<div role="menu" id="menu" style="position:absolute;left:40px;bottom:80px;background:#2a2a2a;border:1px solid #555;border-radius:8px;padding:8px">
<div role="group" tabindex="0" id="menu" style="position:absolute;left:40px;bottom:80px;background:#2a2a2a;border:1px solid #555;border-radius:8px;padding:8px">
<div><button role="menuitem">Add an existing account</button></div>
<div><button role="menuitem">Log out</button></div>
</div>
@@ -307,11 +309,13 @@
if (el?.role === 'tab') document.querySelectorAll('[role=tab]').forEach((t) => t.setAttribute('aria-selected', t === el));
});
document.getElementById('openDialog').onclick = () => document.getElementById('dlg').showModal();
const menuBtn = document.getElementById('menuBtn'), menuLayer = document.getElementById('menuLayer');
const setMenu = (open) => { menuLayer.hidden = !open; menuBtn.setAttribute('aria-expanded', String(open)); };
menuBtn.onclick = () => setMenu(menuLayer.hidden);
const menuLayer = document.getElementById('menuLayer');
let menuStuck = false;
const setMenu = (open, stuck = false) => { menuLayer.hidden = !open; menuStuck = stuck; };
document.getElementById('menuBtn').onclick = () => setMenu(true);
document.getElementById('stuckBtn').onclick = () => setMenu(true, true);
document.getElementById('menuMask').onclick = () => setMenu(false);
document.getElementById('menu').addEventListener('keydown', (e) => { if (e.key === 'Escape') setMenu(false); });
document.getElementById('menu').addEventListener('keydown', (e) => { if (e.key === 'Escape' && !menuStuck) setMenu(false); });
document.getElementById('ok').onclick = document.getElementById('cancel').onclick = () => document.getElementById('dlg').close();
// Keyboard fallback: a virtual standard-mapping pad appended to the real ones.