// chrome.js behaviour, run under Node against a minimal DOM: just enough // elements, attribute selectors and event dispatch for the script's own // selectors. estilo has no browser harness; the browser proof is in the // adopting apps (Sheets' tools/drive/menu-actions.mjs, Docs' menu tests). import assert from "node:assert/strict"; import fs from "node:fs"; import test from "node:test"; import vm from "node:vm"; const src = fs.readFileSync(new URL("./chrome.js", import.meta.url), "utf8"); // A compound selector: tag, [attr] and [attr="value"], comma-separated. function matches(el, selector) { return selector.split(",").some((part) => { const m = part.trim().match(/^([a-z]*)((?:\[[^\]]+\])*)$/); if (!m) throw new Error("unsupported selector " + part); if (m[1] && el.tag !== m[1]) return false; for (const [, name, value] of m[2].matchAll(/\[([^=\]]+)(?:="([^"]*)")?\]/g)) { if (!el.attrs.has(name)) return false; if (value !== undefined && el.attrs.get(name) !== value) return false; } return true; }); } class El { constructor(tag, attrs = {}, children = []) { this.tag = tag; this.attrs = new Map(Object.entries(attrs)); this.children = children; this.parent = null; for (const c of children) c.parent = this; } hasAttribute(n) { return this.attrs.has(n); } getAttribute(n) { return this.attrs.has(n) ? this.attrs.get(n) : null; } get open() { return this.attrs.has("open"); } set open(v) { if (v) this.attrs.set("open", ""); else this.attrs.delete("open"); } get tabIndex() { return Number(this.attrs.get("tabindex") ?? 0); } set tabIndex(v) { this.attrs.set("tabindex", String(v)); } *walk() { for (const c of this.children) { yield c; yield* c.walk(); } } querySelectorAll(s) { return [...this.walk()].filter((e) => matches(e, s)); } querySelector(s) { return this.querySelectorAll(s)[0] ?? null; } closest(s) { for (let e = this; e; e = e.parent) if (e.tag && matches(e, s)) return e; return null; } contains(o) { for (let e = o; e; e = e.parent) if (e === this) return true; return false; } focus() { doc.activeElement = this; } } let doc; function load(root) { const listeners = []; doc = { activeElement: null, querySelector: (s) => root.querySelector(s), getElementById: () => null, addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture: !!capture }), }; vm.runInNewContext(src, { document: doc, HTMLButtonElement: function () {} }); return (type, target, init = {}) => { const e = { type, target, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, ...init }; for (const l of listeners.filter((l) => l.type === type && l.capture)) l.fn(e); for (const l of listeners.filter((l) => l.type === type && !l.capture)) l.fn(e); return e; }; } function menubar(tabindex) { const titles = ["File", "Edit", "Tools"].map((name, i) => new El("summary", { "estilo-menubar-title": "", ...(tabindex ? { tabindex: i === 0 ? "0" : "-1" } : {}) })); 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": "" })])])); return { root: new El("", {}, [new El("nav", { "estilo-menubar": "" }, menus)]), titles, menus }; } // Astra, estilo #11: Escape closed the menu and focused its title, but // the menubar's roving tab stop stayed on File, so Tab then Shift+Tab // landed on File instead of the menu you had been in. test("Escape moves a menubar's roving tab stop to the title it focuses", () => { const { root, titles, menus } = menubar(true); const dispatch = load(root); menus[2].open = true; const item = menus[2].querySelector("button"); const e = dispatch("keydown", item, { key: "Escape" }); assert.equal(menus[2].open, false); assert.equal(doc.activeElement, titles[2]); assert.deepEqual(titles.map((t) => t.getAttribute("tabindex")), ["-1", "-1", "0"]); assert.equal(e.defaultPrevented, true); }); test("a menubar without a roving tab stop is left without one", () => { const { root, titles, menus } = menubar(false); const dispatch = load(root); menus[1].open = true; dispatch("keydown", menus[1].querySelector("button"), { key: "Escape" }); assert.equal(doc.activeElement, titles[1]); assert.deepEqual(titles.map((t) => t.getAttribute("tabindex")), [null, null, null]); }); test("Escape is heard before a menu that stops propagation", () => { const { root, menus } = menubar(true); const dispatch = load(root); menus[0].open = true; // The capture listener runs first, whatever the target's own handlers do. dispatch("keydown", menus[0].querySelector("button"), { key: "Escape" }); assert.equal(menus[0].open, false); });