B closes floating menus and popovers before going back (E31)

X's account menu is a floating role="menu", not a dialog, so B went
back a page and left it open. B now finds an open popup (floating
menu/listbox/non-modal dialog, open popover, or what an expanded
aria-haspopup button controls) and closes it: Escape from inside the
popup, then hidePopover() or pressing the menu button again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mmis1000andClaude Opus 5.5 committed 2026-10-13 01:07:52 +08:00
1 parent 36b24f846d
commit 883b83475a
5 files changed
+152 -4

No files matched your search

+48 -4
View File
@@ -1,8 +1,8 @@
// ==UserScript==
// @name SofaNav
// @namespace https://sofanav.mmis1000.me
// @version 0.3.0.202610121644
// @description Navigate web pages with a controller: D-pad / left stick move focus, A click, B back, X enter, Y graph view. (build f1792a5bc555)
// @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)
// @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 f1792a5bc555 = first 12 hex digits of sha256(extension/content.js).
// Build 230694036146 = 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,7 +34,7 @@
// 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 = 'f1792a5bc555';
const BUILD = '230694036146';
const USERSCRIPT = true; // the userscript build sets this
if (!IS_FRAME) console.info('[sofanav] build', BUILD);
@@ -123,6 +123,48 @@
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.
function activePopup() {
const shown = (el) => {
const r = el.getBoundingClientRect();
return r.width > 1 && r.height > 1 && getComputedStyle(el).visibility !== 'hidden';
};
const floats = (el) => {
if (getComputedStyle(el).position === 'absolute') return true;
for (let n = el; n && n !== document.body; n = n.parentElement) if (getComputedStyle(n).position === 'fixed') return true;
return false;
};
const found = [];
for (const el of document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"]:not([aria-modal="true"]), [popover]')) {
if (el.tagName === 'DIALOG' || !shown(el)) continue;
if (el.matches('[popover]') ? el.matches(':popover-open') : floats(el)) found.push({ el, trigger: null });
}
for (const t of document.querySelectorAll('[aria-expanded="true"][aria-haspopup]:not([aria-haspopup="false"])')) {
const el = document.getElementById(t.getAttribute('aria-controls') || '');
const hit = found.find((p) => el && (p.el === el || el.contains(p.el)));
if (hit) hit.trigger = t;
else if (el && shown(el)) found.push({ el, trigger: t });
}
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 }));
}
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
}
function usable(el) {
if (el instanceof RemoteEl) return remoteVisible(el);
if (!el?.isConnected || el.disabled) return false;
@@ -1338,6 +1380,8 @@
if (ae && ae.matches?.(TEXT_LIKE)) { ae.blur(); return; }
if (activeScope() && await leaveScope()) return;
const modal = activeModal();
const popup = activePopup();
if (popup && (!modal || modal.contains(popup.el))) { await closePopup(popup); 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).
+15
View File
@@ -415,6 +415,21 @@ removed → safe fallback (no cross-origin frames). Tampermonkey is not
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").
**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
old proxies stayed alive candidates (commands went to a document that no longer
+44
View File
@@ -99,6 +99,48 @@
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.
function activePopup() {
const shown = (el) => {
const r = el.getBoundingClientRect();
return r.width > 1 && r.height > 1 && getComputedStyle(el).visibility !== 'hidden';
};
const floats = (el) => {
if (getComputedStyle(el).position === 'absolute') return true;
for (let n = el; n && n !== document.body; n = n.parentElement) if (getComputedStyle(n).position === 'fixed') return true;
return false;
};
const found = [];
for (const el of document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"]:not([aria-modal="true"]), [popover]')) {
if (el.tagName === 'DIALOG' || !shown(el)) continue;
if (el.matches('[popover]') ? el.matches(':popover-open') : floats(el)) found.push({ el, trigger: null });
}
for (const t of document.querySelectorAll('[aria-expanded="true"][aria-haspopup]:not([aria-haspopup="false"])')) {
const el = document.getElementById(t.getAttribute('aria-controls') || '');
const hit = found.find((p) => el && (p.el === el || el.contains(p.el)));
if (hit) hit.trigger = t;
else if (el && shown(el)) found.push({ el, trigger: t });
}
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 }));
}
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
}
function usable(el) {
if (el instanceof RemoteEl) return remoteVisible(el);
if (!el?.isConnected || el.disabled) return false;
@@ -1314,6 +1356,8 @@
if (ae && ae.matches?.(TEXT_LIKE)) { ae.blur(); return; }
if (activeScope() && await leaveScope()) return;
const modal = activeModal();
const popup = activePopup();
if (popup && (!modal || modal.contains(popup.el))) { await closePopup(popup); 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).
+22
View File
@@ -65,6 +65,28 @@ 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) {
const { press, wait } = helpers(page);
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 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 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'}` };
},
// E21: a frame that navigates to a page without the script must become one plain element.
async 'E21 frame that loses its client becomes a plain element'(page) {
const { press, wait } = helpers(page);
+23
View File
@@ -275,6 +275,24 @@
<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>
<div class="row">
<button type="button" id="menuBtn" aria-haspopup="menu" aria-expanded="false">Account ⋯ (X-style menu)</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. -->
<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><button role="menuitem">Add an existing account</button></div>
<div><button role="menuitem">Log out</button></div>
</div>
</div>
<!-- Native popover: ignores a scripted Escape, so SofaNav must use hidePopover(). -->
<div popover id="pop" role="menu" style="background:#2a2a2a;color:#ddd;border:1px solid #555;border-radius:8px;padding:8px">
<button>Popover item 1</button> <button>Popover item 2</button>
</div>
<div class="tall">long empty space — moving down should scroll here</div>
<div class="row" style="padding-bottom:48px"><button>Bottom button</button><button>Another</button></div>
@@ -289,6 +307,11 @@
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);
document.getElementById('menuMask').onclick = () => setMenu(false);
document.getElementById('menu').addEventListener('keydown', (e) => { if (e.key === 'Escape') 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.