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