oficina / estilo Public

Keep a menubar's roving tab stop on the title Escape returns to

chrome.js closed the open menu on Escape and focused its title, but a
menubar's roving tab stop stayed where it was, so Tab then Shift+Tab
went back to File, not to the menu you were in (Astra, #11; it affects
Docs, whose menu.mjs then finds nothing open to move the stop for). Focus
returns through returnTo, which moves tabindex=0 to that title and -1 to
the rest when the menubar has a roving stop, and leaves one without it.
The dialog-close focus return uses it too.

chrome.test.mjs runs chrome.js under Node against a minimal DOM, from Go
(TestScriptBehaviour) like savestatus. The roving test was red before
the fix (tabindex stayed on File).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 63d2210ed5e05c216fdb3a295ef8c25c27f145d7 parent b7988ff
3 files changed, +128 −2
  • chrome/chrome.js +15 −2
  • chrome/chrome.test.mjs +103 −0
  • chrome/chrome_test.go +10 −0

CI passed — run details

CI log
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/estilo.git/base	0.003s
ok  	amadan.net/oficina/estilo.git/brand	0.002s
ok  	amadan.net/oficina/estilo.git/callui	0.003s
ok  	amadan.net/oficina/estilo.git/chrome	0.157s
?   	amadan.net/oficina/estilo.git/cmd/oficina-notices	[no test files]
ok  	amadan.net/oficina/estilo.git/credits	0.005s
ok  	amadan.net/oficina/estilo.git/credits/catalog	0.005s
ok  	amadan.net/oficina/estilo.git/homeconnect	0.005s
ok  	amadan.net/oficina/estilo.git/internal/lint	0.004s
?   	amadan.net/oficina/estilo.git/internal/tokencheck	[no test files]
ok  	amadan.net/oficina/estilo.git/menu	0.007s
ok  	amadan.net/oficina/estilo.git/savestatus	0.070s
ok  	amadan.net/oficina/estilo.git/theme	0.003s
diff --git a/chrome/chrome.js b/chrome/chrome.js
index 15a9333..1f583c6 100644
--- a/chrome/chrome.js
+++ b/chrome/chrome.js
@@ -29,6 +29,19 @@
const group = 'details[name="estilo-menus"][open]'
const openPanel = () => document.querySelector(group)
+ // Focus returns to a menu's title; in a menubar with a roving tab stop
+ // (one title tabbable, the rest tabindex=-1) the stop moves with it, so
+ // Tab then Shift+Tab comes back to the menu you were in, not to File
+ // (Astra, estilo #11). A menubar without one is left without one.
+ const returnTo = (title) => {
+ title.focus()
+ const bar = title.closest('[estilo-menubar]')
+ if (!bar) return
+ const titles = bar.querySelectorAll('[estilo-menubar-title]')
+ if (!Array.prototype.some.call(titles, (t) => t.hasAttribute('tabindex'))) return
+ Array.prototype.forEach.call(titles, (t) => { t.tabIndex = t === title ? 0 : -1 })
+ }
+
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || e.defaultPrevented) return
const input = e.target.closest && e.target.closest('[estilo-bar-title-input]')
@@ -42,7 +55,7 @@
if (!open) return
open.open = false
const summary = open.querySelector('summary')
- if (summary) summary.focus()
+ if (summary) returnTo(summary)
e.preventDefault()
}, true)
@@ -60,7 +73,7 @@
if (holder) {
holder.open = false
const summary = holder.querySelector('summary')
- if (dialog && summary) dialog.addEventListener('close', () => summary.focus(), { once: true })
+ if (dialog && summary) dialog.addEventListener('close', () => returnTo(summary), { once: true })
}
if (invokers || !dialog || typeof dialog.showModal !== 'function' || dialog.open) return
dialog.showModal()
diff --git a/chrome/chrome.test.mjs b/chrome/chrome.test.mjs
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);
+});
diff --git a/chrome/chrome_test.go b/chrome/chrome_test.go
index 0847309..97a312a 100644
--- a/chrome/chrome_test.go
+++ b/chrome/chrome_test.go
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "os/exec"
"regexp"
"strings"
"testing"
@@ -438,3 +439,12 @@ func TestScriptIsClassicAndSelfContained(t *testing.T) {
t.Error("chrome.js's Escape handler is not in the capture phase")
}
}
+
+// chrome.js's behaviour runs under Node against a minimal DOM
+// (chrome.test.mjs), the way savestatus's does.
+func TestScriptBehaviour(t *testing.T) {
+ out, err := exec.Command("node", "--test", "chrome.test.mjs").CombinedOutput()
+ if err != nil {
+ t.Fatalf("node --test chrome.test.mjs: %v\n%s", err, out)
+ }
+}