mirror of
https://github.com/mmis1000/SofaNav.git
synced 2026-10-17 17:01:11 +02:00
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:
1 parent
883b83475a
commit
acd22c7d05
7 files changed
+200
-88
No files matched your search
@@ -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) |
|
||||
|
||||
Vendored
+72
-29
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user