| new file mode 100644 |
| index 0000000..bf8f924 |
| --- /dev/null |
| +++ b/chrome/chrome.test.mjs |
| @@ -0,0 +1,103 @@ |
| +// 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); |
| +}); |