oficina / estilo Public

Clone
git clone https://amadan.net/oficina/estilo

Plain git — no account needed to clone.

Download

Download this file

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).
5import assert from "node:assert/strict";
6import fs from "node:fs";
7import test from "node:test";
8import vm from "node:vm";
9
10const src = fs.readFileSync(new URL("./chrome.js", import.meta.url), "utf8");
11
12// A compound selector: tag, [attr] and [attr="value"], comma-separated.
13function 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
26class 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
48let doc;
49function 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
66function 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.
75test("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
87test("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
96test("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