1// Tests of the keyboard accessibility of static/js/ermite.js and files.js on
2// the small DOM of dom.mjs: the "go to" palette as a combobox, Escape in the
3// file finder, the focus kept through a live swap, and the preference that
4// turns the single-key shortcuts off. Run with
5// node --test 'internal/adapters/web/jstest/*.test.mjs'
6import assert from 'node:assert/strict';
7import { test } from 'node:test';
8import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs';
9
10// The few things ermite.js uses that dom.mjs does not have.
11El.prototype.replaceWith = function (n) {
12 const p = this.parent;
13 const i = p.nodes.indexOf(this);
14 const [added] = p.adopt([n]);
15 p.nodes.splice(i, 1, added);
16 this.parent = null;
17};
18El.prototype.contains = function (e) { for (let n = e; n; n = n.parent) if (n === this) return true; return false; };
19Object.defineProperty(El.prototype, 'isConnected', { get() { let n = this; while (n.parent) n = n.parent; return n === dom.body; } });
20El.prototype.toggleAttribute = function (k, on) { if (on) this.setAttribute(k, ''); else this.removeAttribute(k); return on; };
21El.prototype.dispatchEvent = function (ev) { (this.events ||= []).push(ev.type); };
22// The focusable controls of a fragment, as the selector of ermiteLive says.
23const nativeAll = El.prototype.querySelectorAll;
24El.prototype.querySelectorAll = function (sel) {
25 if (sel === '[data-rows] > [data-cursor]') return this.querySelectorAll('li[data-cursor]'); // no cursor in these tests
26 if (sel === '[data-rows] > *') return [];
27 if (!sel.startsWith('a[href], button')) return nativeAll.call(this, sel);
28 const out = [];
29 const walk = (e) => e.children.forEach((c) => {
30 if ((c.tagName === 'A' && c.hasAttribute('href')) || ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY'].includes(c.tagName) || c.hasAttribute('tabindex')) out.push(c);
31 walk(c);
32 });
33 walk(this);
34 return out;
35};
36
37let active = null;
38// As in a browser, an element inside a hidden one does not take the focus.
39El.prototype.focus = function (opts) {
40 dom.focused.push({ el: this, opts });
41 if (!this.closest('[hidden]')) active = this;
42};
43const doc = installDom({ parseHTML: (text) => parsed[text] });
44const parsed = {};
45Object.defineProperty(doc, 'activeElement', { get: () => active ?? dom.body });
46doc.documentElement = { classList: { add() {} } };
47const query = { all: doc.querySelectorAll, one: doc.querySelector };
48doc.querySelectorAll = () => []; // the page-wide queries of the top of ermite.js
49doc.querySelector = () => null;
50
51const store = new Map();
52globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)) };
53const listeners = {};
54globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
55globalThis.scrollTo = () => {};
56globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });
57
58await import('../static/js/files.js');
59await import('../static/js/ermite.js');
60doc.querySelectorAll = query.all;
61doc.querySelector = query.one;
62const F = window.ermiteFinder;
63const K = window.ermiteKeys;
64
65// keydown runs an element handler, then the window ones unless stopped, as a
66// browser does.
67function keydown(target, key, elementHandler, extra = {}) {
68 const e = {
69 key, target, isComposing: false, prevented: false, stopped: false, blurred: false,
70 preventDefault() { this.prevented = true; },
71 stopPropagation() { this.stopped = true; },
72 ...extra,
73 };
74 target.closest ||= () => null;
75 elementHandler?.(e);
76 if (!e.stopped) for (const f of listeners.keydown) f(e);
77 return e;
78}
79
80// ---- the palette --------------------------------------------------------
81
82function palette() {
83 const li = (k, label) => el('li', { attrs: { 'data-k': k } }, el('a', { attrs: { href: '/' + label } }));
84 const items = [li('go home', 'home'), li('section files', 'files'), li('branch main', 'main')];
85 items[0].setAttribute('data-cursor', '');
86 const input = el('input', { id: 'cmd-q', attrs: { role: 'combobox', 'aria-controls': 'cmd-list' } });
87 const list = el('ul', { id: 'cmd-list' }, ...items);
88 dom.body.replaceChildren(input, list);
89 return { input, list, items };
90}
91
92test('the palette: options, selected option and aria-activedescendant follow the cursor', () => {
93 const p = palette();
94 window.ermiteCmd.sync();
95 assert.deepEqual(p.items.map((i) => i.getAttribute('role')), ['option', 'option', 'option']);
96 assert.equal(new Set(p.items.map((i) => i.id)).size, 3, 'unique ids');
97 assert.equal(p.input.getAttribute('aria-expanded'), 'true');
98 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[0].id);
99 assert.deepEqual(p.items.map((i) => i.getAttribute('aria-selected')), ['true', 'false', 'false']);
100
101 window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
102 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[1].id);
103 assert.deepEqual(p.items.map((i) => i.getAttribute('aria-selected')), ['false', 'true', 'false']);
104 assert.ok(p.items[1].hasAttribute('data-cursor') && !p.items[0].hasAttribute('data-cursor'));
105
106 window.ermite.cmdKey({ key: 'ArrowUp', preventDefault() {} });
107 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[0].id);
108});
109
110test('the palette: a filter moves the active option, no match empties it and collapses the popup', () => {
111 const p = palette();
112 p.input.value = 'branch';
113 window.ermite.cmdFilter(p.input);
114 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[2].id);
115 assert.equal(p.items[2].getAttribute('aria-selected'), 'true');
116 assert.equal(p.items[0].getAttribute('aria-selected'), 'false');
117 p.input.value = 'zzz';
118 window.ermite.cmdFilter(p.input);
119 assert.equal(p.input.getAttribute('aria-expanded'), 'false');
120 assert.equal(p.input.getAttribute('aria-activedescendant'), null);
121});
122
123test('the palette: the file hits that arrive later get ids, roles and the active option', async () => {
124 const p = palette();
125 dom.body.setAttribute('data-paths', '/paths.json');
126 dom.body.setAttribute('data-blob', '/blob/main');
127 p.input.value = 'main';
128 window.ermite.cmdFilter(p.input); // the branch "main" is the active option
129 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[2].id);
130 F.palette(p.input);
131 flushFrames();
132 await settle();
133 const hits = p.list.children.filter((li) => li.className === 'finder-hit');
134 assert.ok(hits.length > 0, 'a file hit came');
135 for (const h of hits) {
136 assert.equal(h.getAttribute('role'), 'option');
137 assert.ok(h.id);
138 }
139 assert.equal(p.list.children.length, new Set(p.list.children.map((i) => i.id)).size, 'ids stay unique');
140 // down to the hit: the field points at it
141 window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
142 assert.equal(p.input.getAttribute('aria-activedescendant'), hits[0].id);
143 assert.equal(hits[0].getAttribute('aria-selected'), 'true');
144 dom.body.removeAttribute('data-paths');
145});
146
147// ---- the file finder ----------------------------------------------------
148
149function finder() {
150 const q = el('input', { id: 'finder-q', attrs: { role: 'combobox' } });
151 const rows = ['a', 'b'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
152 rows[0].setAttribute('data-cursor', '');
153 const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
154 const list = el('ul', { id: 'finder-list' });
155 dom.body.replaceChildren(q, el('div', { id: 'tree-view' }, treeRows), el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), list));
156 dom.body.setAttribute('data-paths', '/paths.json');
157 dom.body.setAttribute('data-blob', '/blob/main');
158 return { q, list };
159}
160
161test('Escape in the finder clears the query, keeps the focus in the field and does not reach the global handler', async () => {
162 const f = finder();
163 f.q.value = 'main';
164 F.input(f.q);
165 flushFrames();
166 await settle();
167 active = f.q;
168 let blurred = false;
169 f.q.blur = () => { blurred = true; };
170 f.q.closest = () => f.q; // the global handler sees a field being typed in
171 const e = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
172 assert.equal(f.q.value, '');
173 assert.equal(e.stopped, true);
174 assert.equal(e.prevented, true);
175 assert.equal(blurred, false, 'the field keeps the focus');
176 assert.equal(active, f.q);
177 // Escape again, with nothing typed: the field is left (global handler)
178 const e2 = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
179 assert.equal(e2.stopped, false);
180 assert.equal(blurred, true);
181});
182
183test('the finder follows the cursor moved from outside (j / k)', async () => {
184 const f = finder();
185 f.q.value = 'm';
186 F.input(f.q);
187 flushFrames();
188 await settle();
189 const [first, second] = f.list.children;
190 assert.equal(f.q.getAttribute('aria-activedescendant'), first.id);
191 // ermite.move: the cursor goes to the next option
192 first.removeAttribute('data-cursor');
193 second.setAttribute('data-cursor', '');
194 F.sync();
195 assert.equal(second.getAttribute('aria-selected'), 'true');
196 assert.equal(first.getAttribute('aria-selected'), 'false');
197 assert.equal(f.q.getAttribute('aria-activedescendant'), second.id);
198});
199
200// ---- the focus through a live swap --------------------------------------
201
202function rows() {
203 const mk = (id, href) => el('li', { id }, el('a', { attrs: { href }, id: id + '-a' }), el('button', { id: id + '-b' }));
204 const main = el('main', { id: 'main', attrs: { tabindex: '-1' } });
205 const list = el('ul', { id: 'repo-rows' }, mk('repo-a', '/a'), mk('repo-b', '/b'), mk('repo-c', '/c'));
206 dom.body.replaceChildren(main, list);
207 return { main, list, mk };
208}
209const swapWith = (html, ...nodes) => { parsed[html] = nodes; window.ermiteLive.swap(html); };
210
211test('a swap that replaces the focused row puts the focus on the same control of the new one', () => {
212 const r = rows();
213 const old = r.list.children[1];
214 active = old.children[0];
215 dom.focused = [];
216 const fresh = r.mk('repo-b', '/b');
217 swapWith('b1', fresh);
218 assert.equal(active, fresh.children[0], 'the link with the same href');
219 assert.equal(dom.focused.at(-1).opts.preventScroll, true);
220 // the second control
221 active = fresh.children[1];
222 const fresh2 = r.mk('repo-b', '/b');
223 swapWith('b2', fresh2);
224 assert.equal(active, fresh2.children[1], 'the button with the same id');
225});
226
227test('a swap with the focus elsewhere does not touch it', () => {
228 const r = rows();
229 active = r.list.children[0].children[0];
230 dom.focused = [];
231 swapWith('b3', r.mk('repo-b', '/b'));
232 assert.equal(dom.focused.length, 0);
233 assert.equal(active, r.list.children[0].children[0]);
234});
235
236test('a removed row hands the focus to the next row, else the previous, else the content', () => {
237 const r = rows();
238 active = r.list.children[1].children[0];
239 const gone = (id) => { const g = el('li', { id, attrs: { 'data-gone': '' } }); return g; };
240 swapWith('g1', gone('repo-b'));
241 assert.equal(active, r.list.children[1].children[0], 'repo-c, which took its place');
242 assert.equal(r.list.children[1].id, 'repo-c');
243 active = r.list.children[1].children[0];
244 swapWith('g2', gone('repo-c'));
245 assert.equal(active, r.list.children[0].children[0], 'repo-a, the previous row');
246 swapWith('g3', gone('repo-a'));
247 // focus was on repo-a's link? it was handed there: now it is removed too
248 assert.equal(active, r.main);
249});
250
251test('a removed row skips the rows a filter hides', () => {
252 const r = rows();
253 const [a, b, c] = r.list.children;
254 b.hidden = true; // filtered out
255 active = a.children[0];
256 swapWith('g4', el('li', { id: 'repo-a', attrs: { 'data-gone': '' } }));
257 assert.equal(active, c.children[0], 'repo-c, the next shown row, not the hidden repo-b');
258 c.hidden = true;
259 swapWith('g5', el('li', { id: 'repo-c', attrs: { 'data-gone': '' } }));
260 assert.equal(active, r.main, 'no shown row left: the content');
261});
262
263// ---- single-character shortcuts -----------------------------------------
264
265test('shortcuts off: single characters do nothing, ctrl k, escape and enter still work', () => {
266 const events = () => dom.body.events.splice(0);
267 dom.body.events = [];
268 doc.querySelector = () => null;
269 const press = (key, extra) => keydown({ closest: () => null }, key, null, extra);
270 press('j');
271 assert.deepEqual(events(), ['k-j'], 'on by default');
272
273 K.set(true);
274 assert.equal(store.get('keys-off'), '1', 'persisted');
275 for (const k of ['j', 'k', '?', ':', 'G', 's', 'y']) press(k);
276 press('g'); press('h');
277 assert.deepEqual(events(), [], 'nothing fires');
278 press('k', { ctrlKey: true });
279 press('Escape');
280 assert.deepEqual(events(), ['k-cmd', 'k-esc']);
281
282 K.set(false);
283 assert.equal(store.get('keys-off'), '', 'persisted off');
284 press('j');
285 assert.deepEqual(events(), ['k-j']);
286});
287
288test('shortcuts off: the checkbox, the note and the palette action show the state', () => {
289 const box = el('input', { id: 'keys-off', attrs: { type: 'checkbox' } });
290 const note = el('p', { id: 'keys-note' });
291 const lbl = el('span', { cls: 'lbl' });
292 const list = el('ul', { id: 'cmd-list' }, el('li', {}, el('a', { attrs: { 'data-fire': 'keys' } }, lbl)));
293 dom.body.replaceChildren(box, note, list);
294 const find = doc.querySelector;
295 doc.querySelector = (sel) => (sel === '#cmd-list [data-fire="keys"] .lbl' ? lbl : null);
296 K.set(true);
297 assert.equal(box.checked, true);
298 assert.match(note.textContent, /are off/);
299 assert.match(lbl.textContent, /on$/);
300 K.set(false);
301 assert.equal(box.checked, false);
302 assert.match(note.textContent, /are on/);
303 assert.match(lbl.textContent, /off$/);
304 doc.querySelector = find;
305});
306
307test('the preference is read from storage when the page loads', async () => {
308 store.set('keys-off', '1');
309 listeners.keydown = [];
310 const src = await import('node:fs').then((m) => m.readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'));
311 doc.querySelectorAll = () => [];
312 doc.querySelector = () => null;
313 new Function(src)(); // a second page load
314 assert.equal(window.ermiteKeys.off(), true);
315 dom.body.events = [];
316 keydown({ closest: () => null }, 'j');
317 assert.deepEqual(dom.body.events, []);
318 store.set('keys-off', '');
319});