Public: anyone can read this; the crew can change it.

One overlay host: every dialog, menu, palette and drawer opens, traps focus a… #11933

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11931-one-overlay-host-every-dialog-menu-palet → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/11931-one-overlay-host-every-dialog-menu-palet was merged into main as 41271f4.

Review

Sign in to review this change.
9 files changed +336 −100
src/web/client.ts +8 −87
@@ -16,52 +16,11 @@ import { actNowJs } from "./act_now.ts";
1616 import { pickJs } from "./pick.ts";
1717 import { imageViewJs } from "./render/image.ts";
1818 import { STAGE_BADGE_JS } from "./stage-row.ts";
19 import { overlayHostJs } from "./overlay_host.ts";
1920 import { paletteHelpersJs } from "./palette.ts";
2021 import { MEDIA_PLAY_JS } from "./render/player.ts";
2122 import { NOT_TOLD_WORDS } from "../actions/tell_words.ts";
2223
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
6524 /**
6625 * The device-code page's only enhancement: listen on the page's own stream
6726 * (`data-stream` on the "check again" form) and re-read the page when the
@@ -518,7 +477,7 @@ export const appTailJs = /* js */ `
518477 export const codeJs = /* js */ `
519478 (function () {
520479 var doc = document;
521 ${modalJs}
480 ${overlayHostJs}
522481 var cfg = doc.getElementById('code');
523482 if (!cfg || typeof fetch === 'undefined') return;
524483 var nav = doc.querySelector('.explorer');
@@ -1295,7 +1254,7 @@ export const moduleLoaderJs = /* js */ `
12951254 const shellCore = /* js */ `
12961255 (function () {
12971256 var doc = document;
1298 ${modalJs}
1257 ${overlayHostJs}
12991258 ${imageViewJs}
13001259
13011260 // Controls that only mean something with a script running stay hidden
@@ -1305,35 +1264,7 @@ const shellCore = /* js */ `
13051264 // ---- a file the browser cannot play: its note, with Download ----
13061265 ${MEDIA_PLAY_JS}
13071266
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 ----
13371268 doc.addEventListener('keydown', function (e) {
13381269 if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
13391270 var panel = e.target && e.target.closest ? e.target.closest('.menu-panel') : null;
@@ -2193,22 +2124,12 @@ const shellCore = /* js */ `
21932124 dlg.style.setProperty('--vv-h', Math.max(0, vv.height) + 'px');
21942125 }
21952126 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(); };
22082130 dlg.onclick = function (e) {
22092131 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(); }
22122133 };
22132134 input.oninput = function () { ask(input.value.trim()); render(false); };
22142135 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 */ `
213213 */
214214 export const INFO_JS = `
215215 function panel(player, info, saved) {
216 function shut(d) { forgeOverlay.close(d); }
outdated

Dead fallback (point 1): the host is always present here. Use forgeOverlay.close(d) and forgeOverlay.open(d,{opener:a}) directly.

216217 document.addEventListener('click', function (ev) {
217218 var t = ev.target; if (!t || !t.closest) return;
218219 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; }
220221 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);
223224 });
224225 function shown(sec, editing) {
225226 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); }
814814 /* A dialog centres itself with its own auto margins, which the reset zeroes
815815 — so anything that wants to be centred has to say so again. */
816816 .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; }
819819 .palette [data-palette-close] { display: none; }
820820 .palette .palette-input {
821821 width: 100%; border: 0; border-bottom: 1px solid var(--border); border-radius: 0;
test/keyhelp.test.ts +4 −3
@@ -1,6 +1,7 @@
11 import { expect, test } from 'bun:test';
22 import { readFileSync } from 'node:fs';
33 import { shellJs } from '../src/web/client.ts';
4 import { overlayHostJs } from '../src/web/overlay_host.ts';
45
56 const src = readFileSync(new URL('../src/web/client.ts', import.meta.url), 'utf8');
67 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', () =>
3435
3536 test('the sheet opens as the shared modal, which closes on Esc and returns focus', () => {
3637 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()');
4041 });
4142
4243 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", () => {
9393 expect(components.indexOf(base)).toBeGreaterThan(-1);
9494 expect(components.indexOf(base)).toBeLessThan(sheet);
9595 }
96 expect(components).toContain("html:has(.palette[open]) { overflow: hidden; }");
96 expect(components).toContain("html[data-overlay] { overflow: hidden; }");
9797 });
test/palette.test.ts +1 −1
@@ -175,7 +175,7 @@ describe("the page runs the same functions", () => {
175175 expect(shellJs).toContain("function openModal(");
176176 // Escape is consumed on keydown and the dialog's own cancel is ours.
177177 expect(shellJs).toMatch(
178 /if \(e\.key !== 'Escape'\) return;\s*e\.preventDefault\(\);\s*e\.stopPropagation\(\);/,
178 /if \(e\.key === 'Escape'\) \{[^}]*e\.preventDefault\(\); e\.stopPropagation\(\);/,
179179 );
180180 expect(shellJs).toContain(
181181 "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
112112 const r = runShell({ marked: true, width: 390 });
113113 expect(r.log.focused).toBeGreaterThan(0);
114114 // 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.

118118 });
119119
120120 test("/search?q=… on a phone is the results page: no auto-open marker, its form and its rows", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs