Public: anyone can read this; the crew can change it.
One overlay host: every dialog, menu, palette and drawer opens, traps focus a… #11933
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11931-one-overlay-host-every-dialog-menu-palet → main 4 commits fork 2 threads · 2 unresolved
9 files
changed +336
−100
src/web/client.ts +8 −87
| @@ -16,52 +16,11 @@ import { actNowJs } from "./act_now.ts"; | ||
| 16 | 16 | import { pickJs } from "./pick.ts"; |
| 17 | 17 | import { imageViewJs } from "./render/image.ts"; |
| 18 | 18 | import { STAGE_BADGE_JS } from "./stage-row.ts"; |
| 19 | import { overlayHostJs } from "./overlay_host.ts"; | |
| 19 | 20 | import { paletteHelpersJs } from "./palette.ts"; |
| 20 | 21 | import { MEDIA_PLAY_JS } from "./render/player.ts"; |
| 21 | 22 | import { NOT_TOLD_WORDS } from "../actions/tell_words.ts"; |
| 22 | 23 | |
| 23 | /** | |
| 24 | * A native `<dialog>` opened as a modal that owns its Escape. Shared by the | |
| 25 | * scripts that open one (the palette, the help, the finder), because the | |
| 26 | * leak is the same for each: a `showModal()` dialog closes on Escape through | |
| 27 | * the browser's default action, and the *keydown itself* is left unhandled — | |
| 28 | * which a fullscreen browser window on macOS reads as "leave fullscreen". | |
| 29 | * So Escape is consumed on keydown and the dialog is closed by hand; the | |
| 30 | * dialog's own `cancel` is caught the same way for the browsers that raise | |
| 31 | * it regardless. Nothing is listened for while the dialog is closed, and on | |
| 32 | * close the focus goes back to where it was. | |
| 33 | * | |
| 34 | * Returns `close`, which is safe to call more than once. | |
| 35 | */ | |
| 36 | const modalJs = /* js */ ` | |
| 37 | function openModal(dlg, onClose) { | |
| 38 | var was = document.activeElement; | |
| 39 | var done = false; | |
| 40 | function onKey(e) { | |
| 41 | if (e.key !== 'Escape') return; | |
| 42 | e.preventDefault(); | |
| 43 | e.stopPropagation(); | |
| 44 | close(); | |
| 45 | } | |
| 46 | function onCancel(e) { e.preventDefault(); close(); } | |
| 47 | function close() { | |
| 48 | if (done) return; | |
| 49 | done = true; | |
| 50 | dlg.removeEventListener('keydown', onKey); | |
| 51 | dlg.removeEventListener('cancel', onCancel); | |
| 52 | dlg.removeEventListener('close', close); | |
| 53 | if (dlg.open) dlg.close(); | |
| 54 | if (onClose) onClose(); | |
| 55 | if (was && was.focus && document.contains(was)) { try { was.focus(); } catch (_) {} } | |
| 56 | } | |
| 57 | dlg.addEventListener('keydown', onKey); | |
| 58 | dlg.addEventListener('cancel', onCancel); | |
| 59 | dlg.addEventListener('close', close); | |
| 60 | dlg.showModal(); | |
| 61 | return close; | |
| 62 | } | |
| 63 | `; | |
| 64 | ||
| 65 | 24 | /** |
| 66 | 25 | * The device-code page's only enhancement: listen on the page's own stream |
| 67 | 26 | * (`data-stream` on the "check again" form) and re-read the page when the |
| @@ -518,7 +477,7 @@ export const appTailJs = /* js */ ` | ||
| 518 | 477 | export const codeJs = /* js */ ` |
| 519 | 478 | (function () { |
| 520 | 479 | var doc = document; |
| 521 | ${modalJs} | |
| 480 | ${overlayHostJs} | |
| 522 | 481 | var cfg = doc.getElementById('code'); |
| 523 | 482 | if (!cfg || typeof fetch === 'undefined') return; |
| 524 | 483 | var nav = doc.querySelector('.explorer'); |
| @@ -1295,7 +1254,7 @@ export const moduleLoaderJs = /* js */ ` | ||
| 1295 | 1254 | const shellCore = /* js */ ` |
| 1296 | 1255 | (function () { |
| 1297 | 1256 | var doc = document; |
| 1298 | ${modalJs} | |
| 1257 | ${overlayHostJs} | |
| 1299 | 1258 | ${imageViewJs} |
| 1300 | 1259 | |
| 1301 | 1260 | // Controls that only mean something with a script running stay hidden |
| @@ -1305,35 +1264,7 @@ const shellCore = /* js */ ` | ||
| 1305 | 1264 | // ---- a file the browser cannot play: its note, with Download ---- |
| 1306 | 1265 | ${MEDIA_PLAY_JS} |
| 1307 | 1266 | |
| 1308 | // ---- menus (details) ---- | |
| 1309 | function closeMenus(except) { | |
| 1310 | doc.querySelectorAll('details.menu[open]').forEach(function (m) { | |
| 1311 | if (m !== except) m.removeAttribute('open'); | |
| 1312 | }); | |
| 1313 | } | |
| 1314 | doc.addEventListener('click', function (e) { | |
| 1315 | var t = e.target; | |
| 1316 | closeMenus(t && t.closest ? t.closest('details.menu') : null); | |
| 1317 | }); | |
| 1318 | doc.addEventListener('keydown', function (e) { | |
| 1319 | if (e.key !== 'Escape') return; | |
| 1320 | var open = doc.querySelector('details.menu[open]'); | |
| 1321 | if (open) { | |
| 1322 | open.removeAttribute('open'); | |
| 1323 | var s = open.querySelector('summary'); | |
| 1324 | if (s) s.focus(); | |
| 1325 | // Consumed: an Escape the page lets through leaves fullscreen on macOS. | |
| 1326 | e.preventDefault(); | |
| 1327 | e.stopPropagation(); | |
| 1328 | } | |
| 1329 | }); | |
| 1330 | doc.addEventListener('toggle', function (e) { | |
| 1331 | var m = e.target; | |
| 1332 | if (!m || !m.matches || !m.matches('details.menu[open]')) return; | |
| 1333 | closeMenus(m); | |
| 1334 | var first = m.querySelector('.menu-panel .menu-item, .menu-panel input'); | |
| 1335 | if (first && !first.closest('.theme-form')) first.focus(); | |
| 1336 | }, true); | |
| 1267 | // ---- menus (details): opened, dismissed and focused by the overlay host ---- | |
| 1337 | 1268 | doc.addEventListener('keydown', function (e) { |
| 1338 | 1269 | if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; |
| 1339 | 1270 | var panel = e.target && e.target.closest ? e.target.closest('.menu-panel') : null; |
| @@ -2193,22 +2124,12 @@ const shellCore = /* js */ ` | ||
| 2193 | 2124 | dlg.style.setProperty('--vv-h', Math.max(0, vv.height) + 'px'); |
| 2194 | 2125 | } |
| 2195 | 2126 | if (vv) { fit(); vv.addEventListener('resize', fit); vv.addEventListener('scroll', fit); } |
| 2196 | // A press anywhere but the input would move the focus off it. Where it | |
| 2197 | // began is remembered: a drag that starts inside and ends on the | |
| 2198 | // backdrop is a selection, not a dismissal. | |
| 2199 | var pressedOutside = false; | |
| 2200 | function outside(e) { | |
| 2201 | var r = dlg.getBoundingClientRect(); | |
| 2202 | return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom; | |
| 2203 | } | |
| 2204 | dlg.onpointerdown = function (e) { | |
| 2205 | pressedOutside = e.target === dlg && outside(e); | |
| 2206 | if (e.target !== input) e.preventDefault(); | |
| 2207 | }; | |
| 2127 | // A press anywhere but the input would move the focus off it; a press | |
| 2128 | // outside the box is the overlay host's to dismiss. | |
| 2129 | dlg.onpointerdown = function (e) { if (e.target !== input) e.preventDefault(); }; | |
| 2208 | 2130 | dlg.onclick = function (e) { |
| 2209 | 2131 | var x = e.target && e.target.closest ? e.target.closest('[data-palette-close]') : null; |
| 2210 | if (x || (e.target === dlg && pressedOutside && outside(e))) { e.preventDefault(); close(); } | |
| 2211 | pressedOutside = false; | |
| 2132 | if (x) { e.preventDefault(); close(); } | |
| 2212 | 2133 | }; |
| 2213 | 2134 | input.oninput = function () { ask(input.value.trim()); render(false); }; |
| 2214 | 2135 | list.onpointermove = function (e) { |
src/web/overlay_host.ts added +164
| @@ -0,0 +1,164 @@ | ||
| 1 | /** | |
| 2 | * The one overlay host (#11931): every dialog, menu, palette and drawer opens | |
| 3 | * through `forgeOverlay.open`, so each opens, traps focus and dismisses the | |
| 4 | * same way, and none can be wrong in its own way (0.7.549 shipped a closed | |
| 5 | * palette that covered every phone page). | |
| 6 | * | |
| 7 | * The behaviour is re-implemented, with no dependency, from the ideas in | |
| 8 | * vercel-labs/ui (Apache-2.0, `packages/core/src`: dismissal-layer, | |
| 9 | * overlay-host, roving-focus). Nothing of its code is copied. | |
| 10 | * | |
| 11 | * - A stack of open layers. Escape, and a press that begins and ends outside | |
| 12 | * the layer, close the top one only. | |
| 13 | * - Focus moves in on open, is held inside while a modal is open (Tab wraps), | |
| 14 | * and returns to the opener on close. | |
| 15 | * - A modal layer is a native `showModal()` dialog, so the page behind is | |
| 16 | * inert; `html[data-overlay]` stops it scrolling too. | |
| 17 | * - A layer that is not open has no box: a closed `<dialog>` and a closed | |
| 18 | * `<details>` are `display: none` (the covered-page rule, `src/design/overlay.ts`). | |
| 19 | * - Development only (localhost): an overlay opened without the host is | |
| 20 | * named in the console. | |
| 21 | * | |
| 22 | * Shipped as static source inside the shell script; page scripts reach it as | |
| 23 | * `window.forgeOverlay`. Idempotent: a second copy in one page finds the first. | |
| 24 | */ | |
| 25 | export const overlayHostJs = /* js */ ` | |
| 26 | var overlayHost = window.forgeOverlay || (window.forgeOverlay = (function () { | |
| 27 | var doc = document; | |
| 28 | var stack = []; | |
| 29 | var pressedOutside = false; | |
| 30 | var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])'; | |
| 31 | var dev = /^(localhost|127\\.0\\.0\\.1|\\[::1\\])$/.test(location.hostname); | |
| 32 | ||
| 33 | function label(el) { | |
| 34 | return el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).join('.') : ''); | |
| 35 | } | |
| 36 | function outside(el, e) { | |
| 37 | var r = el.getBoundingClientRect(); | |
| 38 | return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom; | |
| 39 | } | |
| 40 | function top() { return stack[stack.length - 1]; } | |
| 41 | function locked() { | |
| 42 | var modal = stack.some(function (l) { return l.modal; }); | |
| 43 | if (modal) doc.documentElement.setAttribute('data-overlay', ''); else doc.documentElement.removeAttribute('data-overlay'); | |
| 44 | } | |
| 45 | function find(el) { for (var i = 0; i < stack.length; i++) if (stack[i].el === el) return stack[i]; return null; } | |
| 46 | ||
| 47 | function onKey(e) { | |
| 48 | var l = top(); | |
| 49 | if (!l) return; | |
| 50 | if (e.key === 'Escape') { | |
| 51 | // Consumed: an Escape the page lets through leaves fullscreen on macOS. | |
| 52 | e.preventDefault(); e.stopPropagation(); | |
| 53 | l.close(); | |
| 54 | return; | |
| 55 | } | |
| 56 | if (e.key === 'Tab' && l.modal) { | |
| 57 | var items = Array.prototype.filter.call(l.el.querySelectorAll(FOCUSABLE), function (n) { return !n.hidden && !n.closest('[hidden]'); }); | |
| 58 | if (!items.length) { e.preventDefault(); return; } | |
| 59 | var first = items[0], last = items[items.length - 1], at = doc.activeElement; | |
| 60 | if (!l.el.contains(at)) { e.preventDefault(); first.focus(); } | |
| 61 | else if (e.shiftKey && at === first) { e.preventDefault(); last.focus(); } | |
| 62 | else if (!e.shiftKey && at === last) { e.preventDefault(); first.focus(); } | |
| 63 | } | |
| 64 | } | |
| 65 | function onDown(e) { | |
| 66 | var l = top(); | |
| 67 | if (!l) return; | |
| 68 | pressedOutside = l.modal ? e.target === l.el && outside(l.el, e) : !l.el.contains(e.target); | |
| 69 | } | |
| 70 | function onClick(e) { | |
| 71 | var l = top(); | |
| 72 | if (!l) return; | |
| 73 | var out = l.modal ? e.target === l.el && outside(l.el, e) : !l.el.contains(e.target); | |
| 74 | // A press that began inside and ended outside is a selection, not a dismissal. | |
| 75 | if (out && pressedOutside) { if (l.modal) e.preventDefault(); l.close(); } | |
| 76 | pressedOutside = false; | |
| 77 | } | |
| 78 | var live = false; | |
| 79 | function listen(on) { | |
| 80 | if (on === live) return; | |
| 81 | live = on; | |
| 82 | var f = on ? 'addEventListener' : 'removeEventListener'; | |
| 83 | doc[f]('keydown', onKey, true); | |
| 84 | doc[f]('pointerdown', onDown, true); | |
| 85 | doc[f]('click', onClick, true); | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Open a layer. el: a <dialog> (modal) or a <details> (a menu). opts: | |
| 90 | * name (for the dev warning), opener (where the focus returns; default | |
| 91 | * the focused element), onClose. Returns close, safe to call twice. | |
| 92 | */ | |
| 93 | function open(el, opts) { | |
| 94 | opts = opts || {}; | |
| 95 | var held = find(el); | |
| 96 | if (held) return held.close; | |
| 97 | var modal = typeof el.showModal === 'function'; | |
| 98 | var was = opts.opener || doc.activeElement; | |
| 99 | var done = false; | |
| 100 | var l = { el: el, modal: modal, close: close }; | |
| 101 | function onCancel(e) { e.preventDefault(); close(); } | |
| 102 | function close() { | |
| 103 | if (done) return; | |
| 104 | done = true; | |
| 105 | var i = stack.indexOf(l); | |
| 106 | if (i !== -1) stack.splice(i, 1); | |
| 107 | el.removeEventListener('cancel', onCancel); | |
| 108 | el.removeEventListener('close', close); | |
| 109 | if (modal) { if (el.open) el.close(); } else el.removeAttribute('open'); | |
| 110 | locked(); | |
| 111 | if (!stack.length) listen(false); | |
| 112 | if (opts.onClose) opts.onClose(); | |
| 113 | if (was && was.focus && doc.contains(was)) { try { was.focus(); } catch (_) {} } | |
| 114 | } | |
| 115 | // One menu at a time: opening a non-modal layer closes the others. | |
| 116 | if (!modal) stack.slice().forEach(function (o) { if (!o.modal) o.close(); }); | |
| 117 | stack.push(l); | |
| 118 | listen(true); | |
| 119 | if (modal) { | |
| 120 | el.addEventListener('cancel', onCancel); | |
| 121 | el.addEventListener('close', close); | |
| 122 | el.showModal(); | |
| 123 | } else el.setAttribute('open', ''); | |
| 124 | locked(); | |
| 125 | return close; | |
| 126 | } | |
| 127 | ||
| 128 | // Development only: a dialog or menu that opens without the host is named. | |
| 129 | if (dev && typeof MutationObserver !== 'undefined') { | |
| 130 | new MutationObserver(function (list) { | |
| 131 | list.forEach(function (m) { | |
| 132 | var el = m.target; | |
| 133 | if (!el.hasAttribute || !el.hasAttribute('open') || find(el)) return; | |
| 134 | if (el.tagName !== 'DIALOG' && !(el.matches && el.matches('details.menu'))) return; | |
| 135 | // A menu opened natively is adopted here, before the browser's async toggle event. | |
| 136 | if (el.matches('details.menu')) { adopt(el); return; } | |
| 137 | setTimeout(function () { | |
| 138 | if (el.hasAttribute('open') && !find(el)) console.warn('[overlay] ' + label(el) + ' opened without the overlay host: open it with forgeOverlay.open(el)'); | |
| 139 | }, 0); | |
| 140 | }); | |
| 141 | }).observe(doc.documentElement, { attributes: true, attributeFilter: ['open'], subtree: true }); | |
| 142 | } | |
| 143 | ||
| 144 | // Menus are <details>: the browser opens them, the host adopts them, so | |
| 145 | // one menu at a time, Escape and an outside press close the top layer. | |
| 146 | function adopt(m) { | |
| 147 | if (find(m)) return; | |
| 148 | open(m, { opener: m.querySelector('summary') }); | |
| 149 | var first = m.querySelector('.menu-panel .menu-item, .menu-panel input'); | |
| 150 | if (first && !first.closest('.theme-form')) first.focus(); | |
| 151 | } | |
| 152 | doc.addEventListener('toggle', function (e) { | |
| 153 | var m = e.target; | |
| 154 | if (!m || !m.matches || !m.matches('details.menu')) return; | |
| 155 | if (!m.open) { var l = find(m); if (l) l.close(); return; } | |
| 156 | adopt(m); | |
| 157 | }, true); | |
| 158 | // A menu that is already open when the page loads is a layer too. | |
| 159 | Array.prototype.forEach.call(doc.querySelectorAll('details.menu[open]'), function (m) { open(m, { opener: m.querySelector('summary') }); }); | |
| 160 | ||
| 161 | return { open: open, close: function (el) { var l = find(el); if (l) l.close(); else if (el.open) el.close(); }, isOpen: function (el) { return !!find(el); }, depth: function () { return stack.length; } }; | |
| 162 | })()); | |
| 163 | function openModal(dlg, onClose, opener) { return overlayHost.open(dlg, { onClose: onClose, opener: opener }); } | |
| 164 | `; |
src/web/pages/build_info.ts +4 −3
| @@ -213,13 +213,14 @@ export const INFO_CSS = /* css */ ` | ||
| 213 | 213 | */ |
| 214 | 214 | export const INFO_JS = ` |
| 215 | 215 | function panel(player, info, saved) { |
| 216 | function shut(d) { forgeOverlay.close(d); } | |
| 216 | 217 | document.addEventListener('click', function (ev) { |
| 217 | 218 | var t = ev.target; if (!t || !t.closest) return; |
| 218 | 219 | var a = t.closest('[data-ask]'); |
| 219 | if (a) { var d = document.getElementById(a.getAttribute('data-ask')); if (d && d.showModal && !d.open) d.showModal(); return; } | |
| 220 | if (a) { var d = document.getElementById(a.getAttribute('data-ask')); if (d && d.showModal && !d.open) forgeOverlay.open(d, { opener: a }); return; } | |
| 220 | 221 | var dlg = t.closest('dialog.it-dlg'); if (!dlg) return; |
| 221 | if (t.closest('[data-cancel]')) { dlg.close(); return; } | |
| 222 | if (t.closest('[data-done]')) setTimeout(function () { if (dlg.open) dlg.close(); }, 900); | |
| 222 | if (t.closest('[data-cancel]')) { shut(dlg); return; } | |
| 223 | if (t.closest('[data-done]')) setTimeout(function () { shut(dlg); }, 900); | |
| 223 | 224 | }); |
| 224 | 225 | function shown(sec, editing) { |
| 225 | 226 | var empty = sec.getAttribute('data-empty') === '1'; |
src/web/style/components.ts +2 −2
| @@ -814,8 +814,8 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 814 | 814 | /* A dialog centres itself with its own auto margins, which the reset zeroes |
| 815 | 815 | — so anything that wants to be centred has to say so again. */ |
| 816 | 816 | .palette { width: min(620px, calc(100vw - 24px)); margin: 12dvh auto auto; } |
| 817 | /* No scrolling the page behind an open palette. */ | |
| 818 | html:has(.palette[open]) { overflow: hidden; } | |
| 817 | /* No scrolling the page behind an open modal layer: the overlay host sets the attribute. */ | |
| 818 | html[data-overlay] { overflow: hidden; } | |
| 819 | 819 | .palette [data-palette-close] { display: none; } |
| 820 | 820 | .palette .palette-input { |
| 821 | 821 | width: 100%; border: 0; border-bottom: 1px solid var(--border); border-radius: 0; |
test/keyhelp.test.ts +4 −3
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test'; |
| 2 | 2 | import { readFileSync } from 'node:fs'; |
| 3 | 3 | import { shellJs } from '../src/web/client.ts'; |
| 4 | import { overlayHostJs } from '../src/web/overlay_host.ts'; | |
| 4 | 5 | |
| 5 | 6 | const src = readFileSync(new URL('../src/web/client.ts', import.meta.url), 'utf8'); |
| 6 | 7 | const layout = readFileSync(new URL('../src/web/layout.ts', import.meta.url), 'utf8'); |
| @@ -34,9 +35,9 @@ test('j/k, enter, esc and ? are rows of the one table the sheet renders', () => | ||
| 34 | 35 | |
| 35 | 36 | test('the sheet opens as the shared modal, which closes on Esc and returns focus', () => { |
| 36 | 37 | expect(help).toContain('openModal(dlg);'); |
| 37 | expect(src).toContain('var was = document.activeElement;'); | |
| 38 | expect(src).toContain("if (e.key !== 'Escape') return;"); | |
| 39 | expect(src).toContain('was.focus()'); | |
| 38 | expect(overlayHostJs).toContain('var was = opts.opener || doc.activeElement;'); | |
| 39 | expect(overlayHostJs).toContain("if (e.key === 'Escape')"); | |
| 40 | expect(overlayHostJs).toContain('was.focus()'); | |
| 40 | 41 | }); |
| 41 | 42 | |
| 42 | 43 | test('the dialog is out of the flow and in the page chrome, built without innerHTML', () => { |
test/overlay-host.test.ts added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { readFileSync } from "node:fs"; | |
| 3 | import { Window } from "happy-dom"; | |
| 4 | import { overlayHostJs } from "../src/web/overlay_host.ts"; | |
| 5 | import { sourceFiles } from "../src/design/overlay.ts"; | |
| 6 | ||
| 7 | // The real host, run in a happy-dom page at a phone and a desktop size. | |
| 8 | function page(width: number, height: number, host = "example.com") { | |
| 9 | const w: any = new Window({ url: `http://${host}/`, width, height }); | |
| 10 | const d = w.document; | |
| 11 | d.body.innerHTML = `<button id="a">A</button> | |
| 12 | <dialog id="dlg"><input id="first"><button id="last">Last</button></dialog> | |
| 13 | <details class="menu" id="m1"><summary id="s1">One</summary><div class="menu-panel"><a class="menu-item" href="#x">x</a></div></details> | |
| 14 | <details class="menu" id="m2"><summary id="s2">Two</summary><div class="menu-panel"><a class="menu-item" href="#y">y</a></div></details>`; | |
| 15 | const dlg: any = d.getElementById("dlg"); | |
| 16 | dlg.showModal = () => dlg.setAttribute("open", ""); | |
| 17 | dlg.close = () => { dlg.removeAttribute("open"); dlg.dispatchEvent(new w.Event("close")); }; | |
| 18 | dlg.getBoundingClientRect = () => ({ left: 20, right: width - 20, top: 100, bottom: 400 }); | |
| 19 | const warns: string[] = []; | |
| 20 | const con = { warn: (m: string) => warns.push(m) }; | |
| 21 | new Function("document", "window", "location", "console", "MutationObserver", `var window_ = window; ${overlayHostJs.replace(/^\s*var overlayHost = /, "var overlayHost = ")}; window.__h = overlayHost;`)( | |
| 22 | d, w, w.location, con, w.MutationObserver); | |
| 23 | const key = (key: string, extra: object = {}) => { | |
| 24 | const e: any = new w.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...extra }); | |
| 25 | (d.activeElement ?? d.body).dispatchEvent(e); | |
| 26 | return e; | |
| 27 | }; | |
| 28 | const tap = (el: any, x: number, y: number) => { | |
| 29 | for (const t of ["pointerdown", "click"]) el.dispatchEvent(new w.MouseEvent(t, { bubbles: true, cancelable: true, clientX: x, clientY: y })); | |
| 30 | }; | |
| 31 | return { w, d, dlg, key, tap, warns, host: w.__h, opener: d.getElementById("a") }; | |
| 32 | } | |
| 33 | ||
| 34 | for (const [width, height] of [[390, 844], [1280, 800]] as const) { | |
| 35 | test(`${width}x${height}: a closed dialog is not open and the page is not locked`, () => { | |
| 36 | const p = page(width, height); | |
| 37 | expect(p.dlg.hasAttribute("open")).toBe(false); | |
| 38 | expect(p.d.documentElement.hasAttribute("data-overlay")).toBe(false); | |
| 39 | expect(p.host.depth()).toBe(0); | |
| 40 | }); | |
| 41 | ||
| 42 | test(`${width}x${height}: Escape closes the dialog and the focus returns to the opener`, () => { | |
| 43 | const p = page(width, height); | |
| 44 | p.opener.focus(); | |
| 45 | p.host.open(p.dlg); | |
| 46 | expect(p.d.documentElement.hasAttribute("data-overlay")).toBe(true); | |
| 47 | const e = p.key("Escape"); | |
| 48 | expect(e.defaultPrevented).toBe(true); | |
| 49 | expect(p.dlg.hasAttribute("open")).toBe(false); | |
| 50 | expect(p.d.activeElement).toBe(p.opener); | |
| 51 | expect(p.d.documentElement.hasAttribute("data-overlay")).toBe(false); | |
| 52 | }); | |
| 53 | ||
| 54 | test(`${width}x${height}: a tap outside closes it; a tap inside does not; a drag out of it does not`, () => { | |
| 55 | const p = page(width, height); | |
| 56 | p.opener.focus(); | |
| 57 | p.host.open(p.dlg); | |
| 58 | p.tap(p.dlg, width / 2, 200); | |
| 59 | expect(p.dlg.hasAttribute("open")).toBe(true); | |
| 60 | p.d.getElementById("first").dispatchEvent(new p.w.MouseEvent("pointerdown", { bubbles: true, clientX: 50, clientY: 150 })); | |
| 61 | p.dlg.dispatchEvent(new p.w.MouseEvent("click", { bubbles: true, cancelable: true, clientX: 2, clientY: 700 })); | |
| 62 | expect(p.dlg.hasAttribute("open")).toBe(true); | |
| 63 | p.tap(p.dlg, 2, 700); | |
| 64 | expect(p.dlg.hasAttribute("open")).toBe(false); | |
| 65 | expect(p.d.activeElement).toBe(p.opener); | |
| 66 | }); | |
| 67 | ||
| 68 | test(`${width}x${height}: Tab never leaves an open dialog`, () => { | |
| 69 | const p = page(width, height); | |
| 70 | p.host.open(p.dlg); | |
| 71 | p.d.getElementById("last").focus(); | |
| 72 | expect(p.key("Tab").defaultPrevented).toBe(true); | |
| 73 | expect(p.d.activeElement).toBe(p.d.getElementById("first")); | |
| 74 | expect(p.key("Tab", { shiftKey: true }).defaultPrevented).toBe(true); | |
| 75 | expect(p.d.activeElement).toBe(p.d.getElementById("last")); | |
| 76 | p.opener.focus(); // focus that escaped is pulled back in | |
| 77 | expect(p.key("Tab").defaultPrevented).toBe(true); | |
| 78 | expect(p.dlg.contains(p.d.activeElement)).toBe(true); | |
| 79 | }); | |
| 80 | } | |
| 81 | ||
| 82 | test("Escape closes only the top layer of the stack", () => { | |
| 83 | const p = page(390, 844); | |
| 84 | const m = p.d.getElementById("m1"); | |
| 85 | p.host.open(m); | |
| 86 | p.host.open(p.dlg); | |
| 87 | p.key("Escape"); | |
| 88 | expect(p.dlg.hasAttribute("open")).toBe(false); | |
| 89 | expect(m.hasAttribute("open")).toBe(true); | |
| 90 | p.key("Escape"); | |
| 91 | expect(m.hasAttribute("open")).toBe(false); | |
| 92 | expect(p.host.depth()).toBe(0); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a menu: an outside tap closes it, Escape returns the focus to its summary, one is open at a time", () => { | |
| 96 | const p = page(390, 844); | |
| 97 | const m1 = p.d.getElementById("m1"), m2 = p.d.getElementById("m2"); | |
| 98 | p.host.open(m1, { opener: p.d.getElementById("s1") }); | |
| 99 | p.host.open(m2, { opener: p.d.getElementById("s2") }); | |
| 100 | expect(m1.hasAttribute("open")).toBe(false); | |
| 101 | expect(m2.hasAttribute("open")).toBe(true); | |
| 102 | p.tap(p.opener, 5, 5); | |
| 103 | expect(m2.hasAttribute("open")).toBe(false); | |
| 104 | p.d.getElementById("s2").focus(); | |
| 105 | p.opener.focus(); | |
| 106 | p.host.open(m2, { opener: p.d.getElementById("s2") }); | |
| 107 | p.key("Escape"); | |
| 108 | expect(p.d.activeElement).toBe(p.d.getElementById("s2")); | |
| 109 | }); | |
| 110 | ||
| 111 | test("development only: a dialog opened without the host is named", async () => { | |
| 112 | const dev = page(390, 844, "localhost"); | |
| 113 | dev.dlg.id = "mine"; dev.dlg.className = "dialog"; | |
| 114 | dev.dlg.setAttribute("open", ""); | |
| 115 | await new Promise((r) => setTimeout(r, 20)); // real-timer: the host warns from a 0 ms timer inside happy-dom; no clock is injectable there | |
| 116 | expect(dev.warns.some((m) => m.includes("dialog#mine.dialog") && m.includes("without the overlay host"))).toBe(true); | |
| 117 | const prod = page(390, 844); | |
| 118 | prod.dlg.setAttribute("open", ""); | |
| 119 | await new Promise((r) => setTimeout(r, 20)); // real-timer: the host warns from a 0 ms timer inside happy-dom; no clock is injectable there | |
| 120 | expect(prod.warns).toEqual([]); | |
| 121 | }); | |
| 122 | ||
| 123 | test("development only: a menu opened natively is adopted, not warned about", async () => { | |
| 124 | const dev = page(390, 844, "localhost"); | |
| 125 | const m: any = dev.d.getElementById("m1"); | |
| 126 | m.setAttribute("open", ""); | |
| 127 | await new Promise((r) => setTimeout(r, 20)); // real-timer: the observer's callback runs on happy-dom's own queue | |
| 128 | expect(dev.warns).toEqual([]); | |
| 129 | expect(dev.host.isOpen(m)).toBe(true); | |
| 130 | }); | |
| 131 | ||
| 132 | test("no overlay in src/web carries its own dismissal code", () => { | |
| 133 | const own = /\.showModal\(\)|dialog\.close\(\)|dlg\.close\(\)|details\.menu\[open\][^\n]*removeAttribute|addEventListener\(\s*'cancel'/; | |
| 134 | const offenders: string[] = []; | |
| 135 | for (const f of sourceFiles(process.cwd())) { | |
| 136 | if (f.endsWith("overlay_host.ts")) continue; | |
| 137 | readFileSync(f, "utf8").split("\n").forEach((line, i) => { | |
| 138 | if (own.test(line) && !/^\s*(\/\/|\*)/.test(line)) offenders.push(`${f}:${i + 1}: ${line.trim()}`); | |
| 139 | }); | |
| 140 | } | |
| 141 | expect(offenders).toEqual([]); | |
| 142 | }); | |
| 143 | ||
| 144 | test("a closed overlay has no box: the stylesheet never lays out a closed dialog, and the page lock is the host's", () => { | |
| 145 | const css = readFileSync("src/web/style/components.ts", "utf8"); | |
| 146 | expect(css).toContain(".dialog[open] { display: flex;"); | |
| 147 | expect(css).not.toMatch(/\n\.dialog \{[^}]*display:\s*(flex|block|grid)/); | |
| 148 | expect(css).toContain("html[data-overlay] { overflow: hidden; }"); | |
| 149 | }); |
test/palette-dismiss.test.ts +1 −1
| @@ -93,5 +93,5 @@ test("the phone sheet's rules come after every base rule they override", () => { | ||
| 93 | 93 | expect(components.indexOf(base)).toBeGreaterThan(-1); |
| 94 | 94 | expect(components.indexOf(base)).toBeLessThan(sheet); |
| 95 | 95 | } |
| 96 | expect(components).toContain("html:has(.palette[open]) { overflow: hidden; }"); | |
| 96 | expect(components).toContain("html[data-overlay] { overflow: hidden; }"); | |
| 97 | 97 | }); |
test/palette.test.ts +1 −1
| @@ -175,7 +175,7 @@ describe("the page runs the same functions", () => { | ||
| 175 | 175 | expect(shellJs).toContain("function openModal("); |
| 176 | 176 | // Escape is consumed on keydown and the dialog's own cancel is ours. |
| 177 | 177 | expect(shellJs).toMatch( |
| 178 | /if \(e\.key !== 'Escape'\) return;\s*e\.preventDefault\(\);\s*e\.stopPropagation\(\);/, | |
| 178 | /if \(e\.key === 'Escape'\) \{[^}]*e\.preventDefault\(\); e\.stopPropagation\(\);/, | |
| 179 | 179 | ); |
| 180 | 180 | expect(shellJs).toContain( |
| 181 | 181 | "function onCancel(e) { e.preventDefault(); close(); }", |
test/search-is-palette.test.ts +3 −3
| @@ -112,9 +112,9 @@ test("focus lands in the palette input; Escape closes it and focus returns to wh | ||
| 112 | 112 | const r = runShell({ marked: true, width: 390 }); |
| 113 | 113 | expect(r.log.focused).toBeGreaterThan(0); |
| 114 | 114 | // openModal remembers document.activeElement (the page's autofocused search |
| 115 | // input) and refocuses it on close, Escape included (client.ts modalJs). | |
| 116 | expect(shellJs).toMatch(/var was = document\.activeElement;[\s\S]*?was\.focus\(\)/); | |
| 117 | expect(shellJs).toMatch(/if \(e\.key !== 'Escape'\) return;[\s\S]*?close\(\);/); | |
| 115 | // input) and refocuses it on close, Escape included (src/web/overlay_host.ts). | |
| 116 | expect(shellJs).toMatch(/var was = opts\.opener \|\| doc\.activeElement;[\s\S]*?was\.focus\(\)/); | |
| 117 | expect(shellJs).toMatch(/if \(e\.key === 'Escape'\) \{[\s\S]*?l\.close\(\);/); | |
outdated
Stale pin: this old Escape text is no longer the implementation; pin the host's Escape branch instead, and fix the modalJs comment above. |
||
| 118 | 118 | }); |
| 119 | 119 | |
| 120 | 120 | test("/search?q=… on a phone is the results page: no auto-open marker, its form and its rows", async () => { |
new above
offline
Dead fallback (point 1): the host is always present here. Use forgeOverlay.close(d) and forgeOverlay.open(d,{opener:a}) directly.