| 1 | // chrome.js behaviour, run under Node against a minimal DOM: just enough |
| 2 | // elements, attribute selectors and event dispatch for the script's own |
| 3 | // selectors. estilo has no browser harness; the browser proof is in the |
| 4 | // adopting apps (Sheets' tools/drive/menu-actions.mjs, Docs' menu tests). |
| 5 | import assert from "node:assert/strict"; |
| 6 | import fs from "node:fs"; |
| 7 | import test from "node:test"; |
| 8 | import vm from "node:vm"; |
| 9 | |
| 10 | const src = fs.readFileSync(new URL("./chrome.js", import.meta.url), "utf8"); |
| 11 | |
| 12 | // A compound selector: tag, [attr] and [attr="value"], comma-separated. |
| 13 | function matches(el, selector) { |
| 14 | return selector.split(",").some((part) => { |
| 15 | const m = part.trim().match(/^([a-z]*)((?:\[[^\]]+\])*)$/); |
| 16 | if (!m) throw new Error("unsupported selector " + part); |
| 17 | if (m[1] && el.tag !== m[1]) return false; |
| 18 | for (const [, name, value] of m[2].matchAll(/\[([^=\]]+)(?:="([^"]*)")?\]/g)) { |
| 19 | if (!el.attrs.has(name)) return false; |
| 20 | if (value !== undefined && el.attrs.get(name) !== value) return false; |
| 21 | } |
| 22 | return true; |
| 23 | }); |
| 24 | } |
| 25 | |
| 26 | class El { |
| 27 | constructor(tag, attrs = {}, children = []) { |
| 28 | this.tag = tag; |
| 29 | this.attrs = new Map(Object.entries(attrs)); |
| 30 | this.children = children; |
| 31 | this.parent = null; |
| 32 | for (const c of children) c.parent = this; |
| 33 | } |
| 34 | hasAttribute(n) { return this.attrs.has(n); } |
| 35 | getAttribute(n) { return this.attrs.has(n) ? this.attrs.get(n) : null; } |
| 36 | get open() { return this.attrs.has("open"); } |
| 37 | set open(v) { if (v) this.attrs.set("open", ""); else this.attrs.delete("open"); } |
| 38 | get tabIndex() { return Number(this.attrs.get("tabindex") ?? 0); } |
| 39 | set tabIndex(v) { this.attrs.set("tabindex", String(v)); } |
| 40 | *walk() { for (const c of this.children) { yield c; yield* c.walk(); } } |
| 41 | querySelectorAll(s) { return [...this.walk()].filter((e) => matches(e, s)); } |
| 42 | querySelector(s) { return this.querySelectorAll(s)[0] ?? null; } |
| 43 | closest(s) { for (let e = this; e; e = e.parent) if (e.tag && matches(e, s)) return e; return null; } |
| 44 | contains(o) { for (let e = o; e; e = e.parent) if (e === this) return true; return false; } |
| 45 | focus() { doc.activeElement = this; } |
| 46 | } |
| 47 | |
| 48 | let doc; |
| 49 | function load(root) { |
| 50 | const listeners = []; |
| 51 | doc = { |
| 52 | activeElement: null, |
| 53 | querySelector: (s) => root.querySelector(s), |
| 54 | getElementById: () => null, |
| 55 | addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture: !!capture }), |
| 56 | }; |
| 57 | vm.runInNewContext(src, { document: doc, HTMLButtonElement: function () {} }); |
| 58 | return (type, target, init = {}) => { |
| 59 | const e = { type, target, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, ...init }; |
| 60 | for (const l of listeners.filter((l) => l.type === type && l.capture)) l.fn(e); |
| 61 | for (const l of listeners.filter((l) => l.type === type && !l.capture)) l.fn(e); |
| 62 | return e; |
| 63 | }; |
| 64 | } |
| 65 | |
| 66 | function menubar(tabindex) { |
| 67 | const titles = ["File", "Edit", "Tools"].map((name, i) => new El("summary", { "estilo-menubar-title": "", ...(tabindex ? { tabindex: i === 0 ? "0" : "-1" } : {}) })); |
| 68 | const menus = titles.map((t) => new El("details", { "estilo-menubar-menu": "", name: "estilo-menus" }, [t, new El("div", { "estilo-menu": "" }, [new El("button", { "estilo-menu-item": "" })])])); |
| 69 | return { root: new El("", {}, [new El("nav", { "estilo-menubar": "" }, menus)]), titles, menus }; |
| 70 | } |
| 71 | |
| 72 | // Astra, estilo #11: Escape closed the menu and focused its title, but |
| 73 | // the menubar's roving tab stop stayed on File, so Tab then Shift+Tab |
| 74 | // landed on File instead of the menu you had been in. |
| 75 | test("Escape moves a menubar's roving tab stop to the title it focuses", () => { |
| 76 | const { root, titles, menus } = menubar(true); |
| 77 | const dispatch = load(root); |
| 78 | menus[2].open = true; |
| 79 | const item = menus[2].querySelector("button"); |
| 80 | const e = dispatch("keydown", item, { key: "Escape" }); |
| 81 | assert.equal(menus[2].open, false); |
| 82 | assert.equal(doc.activeElement, titles[2]); |
| 83 | assert.deepEqual(titles.map((t) => t.getAttribute("tabindex")), ["-1", "-1", "0"]); |
| 84 | assert.equal(e.defaultPrevented, true); |
| 85 | }); |
| 86 | |
| 87 | test("a menubar without a roving tab stop is left without one", () => { |
| 88 | const { root, titles, menus } = menubar(false); |
| 89 | const dispatch = load(root); |
| 90 | menus[1].open = true; |
| 91 | dispatch("keydown", menus[1].querySelector("button"), { key: "Escape" }); |
| 92 | assert.equal(doc.activeElement, titles[1]); |
| 93 | assert.deepEqual(titles.map((t) => t.getAttribute("tabindex")), [null, null, null]); |
| 94 | }); |
| 95 | |
| 96 | test("Escape is heard before a menu that stops propagation", () => { |
| 97 | const { root, menus } = menubar(true); |
| 98 | const dispatch = load(root); |
| 99 | menus[0].open = true; |
| 100 | // The capture listener runs first, whatever the target's own handlers do. |
| 101 | dispatch("keydown", menus[0].querySelector("button"), { key: "Escape" }); |
| 102 | assert.equal(menus[0].open, false); |
| 103 | }); |
| 104 | |