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.prepend = function (...nodes) { this.nodes.unshift(...this.adopt(nodes)); };
12El.prototype.replaceWith = function (n) {
13 const p = this.parent;
14 const i = p.nodes.indexOf(this);
15 const [added] = p.adopt([n]);
16 p.nodes.splice(i, 1, added);
17 this.parent = null;
18};
19El.prototype.contains = function (e) { for (let n = e; n; n = n.parent) if (n === this) return true; return false; };
20Object.defineProperty(El.prototype, 'isConnected', { get() { let n = this; while (n.parent) n = n.parent; return n === dom.body; } });
21El.prototype.toggleAttribute = function (k, on) { if (on) this.setAttribute(k, ''); else this.removeAttribute(k); return on; };
22El.prototype.dispatchEvent = function (ev) { (this.events ||= []).push(ev.type); };
23// The focusable controls of a fragment, as the selector of ermiteLive says.
24const nativeAll = El.prototype.querySelectorAll;
25El.prototype.querySelectorAll = function (sel) {
26 if (sel === '[data-rows] > [data-cursor]') return this.querySelectorAll('li[data-cursor]'); // no cursor in these tests
27 if (sel === '[data-rows] > *') return [];
28 if (!sel.startsWith('a[href], button')) return nativeAll.call(this, sel);
29 const out = [];
30 const walk = (e) => e.children.forEach((c) => {
31 if ((c.tagName === 'A' && c.hasAttribute('href')) || ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY'].includes(c.tagName) || c.hasAttribute('tabindex')) out.push(c);
32 walk(c);
33 });
34 walk(this);
35 return out;
36};
37
38let active = null;
39// As in a browser, an element inside a hidden one does not take the focus.
40El.prototype.focus = function (opts) {
41 dom.focused.push({ el: this, opts });
42 if (!this.closest('[hidden]')) active = this;
43};
44const doc = installDom({ parseHTML: (text) => parsed[text] });
45const parsed = {};
46Object.defineProperty(doc, 'activeElement', { get: () => active ?? dom.body });
47doc.documentElement = { classList: { add() {} }, setAttribute() {}, removeAttribute() {} };
48const query = { all: doc.querySelectorAll, one: doc.querySelector };
49doc.querySelectorAll = () => []; // the page-wide queries of the top of ermite.js
50doc.querySelector = () => null;
51
52const store = new Map();
53globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
54const listeners = {};
55globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
56globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
57globalThis.scrollTo = () => {};
58globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });
59
60await import('../static/js/files.js');
61await import('../static/js/store.js'); // loaded before ermite.js, as in the page
62await import('../static/js/ermite.js');
63doc.querySelectorAll = query.all;
64doc.querySelector = query.one;
65const F = window.ermiteFinder;
66const K = window.ermiteKeys;
67
68// keydown runs an element handler, then the window ones unless stopped, as a
69// browser does.
70function keydown(target, key, elementHandler, extra = {}) {
71 const e = {
72 key, target, isComposing: false, prevented: false, stopped: false, blurred: false,
73 preventDefault() { this.prevented = true; },
74 stopPropagation() { this.stopped = true; },
75 ...extra,
76 };
77 target.closest ||= () => null;
78 elementHandler?.(e);
79 if (!e.stopped) for (const f of listeners.keydown) f(e);
80 return e;
81}
82
83// ---- the palette --------------------------------------------------------
84
85function palette() {
86 const li = (k, label) => el('li', { attrs: { 'data-k': k } }, el('a', { attrs: { 'data-href': '/' + label, role: 'presentation' } }));
87 const items = [li('go home', 'home'), li('section files', 'files'), li('branch main', 'main')];
88 items[0].setAttribute('data-cursor', '');
89 const input = el('input', { id: 'cmd-q', attrs: { role: 'combobox', 'aria-controls': 'cmd-list' } });
90 const list = el('ul', { id: 'cmd-list' }, ...items);
91 dom.body.replaceChildren(input, list);
92 return { input, list, items };
93}
94
95test('the palette: options, selected option and aria-activedescendant follow the cursor', () => {
96 const p = palette();
97 window.ermiteCmd.sync();
98 assert.deepEqual(p.items.map((i) => i.getAttribute('role')), ['option', 'option', 'option']);
99 assert.equal(new Set(p.items.map((i) => i.id)).size, 3, 'unique ids');
100 assert.equal(p.input.getAttribute('aria-expanded'), 'true');
101 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[0].id);
102 assert.deepEqual(p.items.map((i) => i.getAttribute('aria-selected')), ['true', 'false', 'false']);
103
104 window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
105 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[1].id);
106 assert.deepEqual(p.items.map((i) => i.getAttribute('aria-selected')), ['false', 'true', 'false']);
107 assert.ok(p.items[1].hasAttribute('data-cursor') && !p.items[0].hasAttribute('data-cursor'));
108
109 window.ermite.cmdKey({ key: 'ArrowUp', preventDefault() {} });
110 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[0].id);
111});
112
113test('the palette: a filter moves the active option, no match empties it and collapses the popup', () => {
114 const p = palette();
115 p.input.value = 'branch';
116 window.ermite.cmdFilter(p.input);
117 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[2].id);
118 assert.equal(p.items[2].getAttribute('aria-selected'), 'true');
119 assert.equal(p.items[0].getAttribute('aria-selected'), 'false');
120 p.input.value = 'zzz';
121 window.ermite.cmdFilter(p.input);
122 assert.equal(p.input.getAttribute('aria-expanded'), 'false');
123 assert.equal(p.input.getAttribute('aria-activedescendant'), null);
124});
125
126test('the palette: the file hits that arrive later get ids, roles and the active option', async () => {
127 const p = palette();
128 dom.body.setAttribute('data-paths', '/paths.json');
129 dom.body.setAttribute('data-blob', '/blob/main');
130 p.input.value = 'main';
131 window.ermite.cmdFilter(p.input); // the branch "main" is the active option
132 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[2].id);
133 F.palette(p.input);
134 flushFrames();
135 await settle();
136 const hits = p.list.children.filter((li) => li.className === 'finder-hit');
137 assert.ok(hits.length > 0, 'a file hit came');
138 for (const h of hits) {
139 assert.equal(h.getAttribute('role'), 'option');
140 assert.equal(h.querySelector('a[href]'), null);
141 assert.equal(h.querySelector('a').hasAttribute('tabindex'), false);
142 assert.ok(h.id);
143 }
144 assert.equal(p.list.children.length, new Set(p.list.children.map((i) => i.id)).size, 'ids stay unique');
145 // down to the hit: the field points at it
146 window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
147 assert.equal(p.input.getAttribute('aria-activedescendant'), hits[0].id);
148 assert.equal(hits[0].getAttribute('aria-selected'), 'true');
149 dom.body.removeAttribute('data-paths');
150});
151
152// ---- the file finder ----------------------------------------------------
153
154function finder() {
155 const q = el('input', { id: 'finder-q', attrs: { role: 'combobox' } });
156 const rows = ['a', 'b'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
157 rows[0].setAttribute('data-cursor', '');
158 const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
159 const list = el('ul', { id: 'finder-list' });
160 dom.body.replaceChildren(q, el('div', { id: 'tree-view' }, treeRows), el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), list));
161 dom.body.setAttribute('data-paths', '/paths.json');
162 dom.body.setAttribute('data-blob', '/blob/main');
163 return { q, list };
164}
165
166test('Escape in the finder clears the query, keeps the focus in the field and does not reach the global handler', async () => {
167 const f = finder();
168 f.q.value = 'main';
169 F.input(f.q);
170 flushFrames();
171 await settle();
172 active = f.q;
173 let blurred = false;
174 f.q.blur = () => { blurred = true; };
175 f.q.closest = () => f.q; // the global handler sees a field being typed in
176 const e = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
177 assert.equal(f.q.value, '');
178 assert.equal(doc.getElementById('finder-status').textContent, '');
179 assert.equal(e.stopped, true);
180 assert.equal(e.prevented, true);
181 assert.equal(blurred, false, 'the field keeps the focus');
182 assert.equal(active, f.q);
183 // Escape again, with nothing typed: the field is left (global handler)
184 const e2 = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
185 assert.equal(e2.stopped, false);
186 assert.equal(blurred, true);
187});
188
189test('the finder follows the cursor moved from outside (j / k)', async () => {
190 const f = finder();
191 f.q.value = 'm';
192 F.input(f.q);
193 flushFrames();
194 await settle();
195 const [first, second] = f.list.children;
196 assert.equal(f.q.getAttribute('aria-activedescendant'), first.id);
197 // ermite.move: the cursor goes to the next option
198 first.removeAttribute('data-cursor');
199 second.setAttribute('data-cursor', '');
200 F.sync();
201 assert.equal(second.getAttribute('aria-selected'), 'true');
202 assert.equal(first.getAttribute('aria-selected'), 'false');
203 assert.equal(f.q.getAttribute('aria-activedescendant'), second.id);
204});
205
206// ---- the focus through a live swap --------------------------------------
207
208function rows() {
209 const mk = (id, href) => el('li', { id }, el('a', { attrs: { href }, id: id + '-a' }), el('button', { id: id + '-b' }));
210 const main = el('main', { id: 'main', attrs: { tabindex: '-1' } });
211 const list = el('ul', { id: 'repo-rows' }, mk('repo-a', '/a'), mk('repo-b', '/b'), mk('repo-c', '/c'));
212 dom.body.replaceChildren(main, list);
213 return { main, list, mk };
214}
215const swapWith = (html, ...nodes) => { parsed[html] = nodes; window.ermiteLive.swap(html); };
216
217test('a swap that replaces the focused row puts the focus on the same control of the new one', () => {
218 const r = rows();
219 const old = r.list.children[1];
220 active = old.children[0];
221 dom.focused = [];
222 const fresh = r.mk('repo-b', '/b');
223 swapWith('b1', fresh);
224 assert.equal(active, fresh.children[0], 'the link with the same href');
225 assert.equal(dom.focused.at(-1).opts.preventScroll, true);
226 // the second control
227 active = fresh.children[1];
228 const fresh2 = r.mk('repo-b', '/b');
229 swapWith('b2', fresh2);
230 assert.equal(active, fresh2.children[1], 'the button with the same id');
231});
232
233test('a swap with the focus elsewhere does not touch it', () => {
234 const r = rows();
235 active = r.list.children[0].children[0];
236 dom.focused = [];
237 swapWith('b3', r.mk('repo-b', '/b'));
238 assert.equal(dom.focused.length, 0);
239 assert.equal(active, r.list.children[0].children[0]);
240});
241
242test('a removed row hands the focus to the next row, else the previous, else the content', () => {
243 const r = rows();
244 active = r.list.children[1].children[0];
245 const gone = (id) => { const g = el('li', { id, attrs: { 'data-gone': '' } }); return g; };
246 swapWith('g1', gone('repo-b'));
247 assert.equal(active, r.list.children[1].children[0], 'repo-c, which took its place');
248 assert.equal(r.list.children[1].id, 'repo-c');
249 active = r.list.children[1].children[0];
250 swapWith('g2', gone('repo-c'));
251 assert.equal(active, r.list.children[0].children[0], 'repo-a, the previous row');
252 swapWith('g3', gone('repo-a'));
253 // focus was on repo-a's link? it was handed there: now it is removed too
254 assert.equal(active, r.main);
255});
256
257test('a removed row skips the rows a filter hides', () => {
258 const r = rows();
259 const [a, b, c] = r.list.children;
260 b.hidden = true; // filtered out
261 active = a.children[0];
262 swapWith('g4', el('li', { id: 'repo-a', attrs: { 'data-gone': '' } }));
263 assert.equal(active, c.children[0], 'repo-c, the next shown row, not the hidden repo-b');
264 c.hidden = true;
265 swapWith('g5', el('li', { id: 'repo-c', attrs: { 'data-gone': '' } }));
266 assert.equal(active, r.main, 'no shown row left: the content');
267});
268
269// ---- single-character shortcuts -----------------------------------------
270
271test('shortcuts off: single characters do nothing, ctrl k, escape and enter still work', () => {
272 const events = () => dom.body.events.splice(0);
273 dom.body.events = [];
274 doc.querySelector = () => null;
275 const press = (key, extra) => keydown({ closest: () => null }, key, null, extra);
276 press('j');
277 assert.deepEqual(events(), ['k-j'], 'on by default');
278
279 K.set(true);
280 assert.equal(store.get('keys-off'), '1', 'persisted');
281 for (const k of ['j', 'k', '?', ':', 'G', 's', 'y']) press(k);
282 press('g'); press('h');
283 assert.deepEqual(events(), [], 'nothing fires');
284 press('k', { ctrlKey: true });
285 press('Escape');
286 assert.deepEqual(events(), ['k-cmd', 'k-esc']);
287
288 K.set(false);
289 assert.equal(store.get('keys-off'), '', 'persisted off');
290 press('j');
291 assert.deepEqual(events(), ['k-j']);
292});
293
294test('shortcuts off: the checkbox, the note and the palette action show the state', () => {
295 const box = el('input', { id: 'keys-off', attrs: { type: 'checkbox' } });
296 const note = el('p', { id: 'keys-note' });
297 const lbl = el('span', { cls: 'lbl' });
298 const list = el('ul', { id: 'cmd-list' }, el('li', {}, el('a', { attrs: { 'data-fire': 'keys' } }, lbl)));
299 dom.body.replaceChildren(box, note, list);
300 const find = doc.querySelector;
301 doc.querySelector = (sel) => (sel === '#cmd-list [data-fire="keys"] .lbl' ? lbl : null);
302 K.set(true);
303 assert.equal(box.checked, true);
304 assert.match(note.textContent, /are off/);
305 assert.match(lbl.textContent, /on$/);
306 K.set(false);
307 assert.equal(box.checked, false);
308 assert.match(note.textContent, /are on/);
309 assert.match(lbl.textContent, /off$/);
310 doc.querySelector = find;
311});
312
313test('shortcuts off: another tab\'s change applies here', () => {
314 // The browser has written the value before the event comes.
315 const storage = (newValue) => {
316 if (newValue === null) store.delete('keys-off'); else store.set('keys-off', newValue);
317 listeners.storage.forEach((f) => f({ key: 'keys-off', newValue }));
318 };
319 storage('1');
320 assert.equal(K.off(), true);
321 storage('');
322 assert.equal(K.off(), false);
323 storage(null);
324 assert.equal(K.off(), false, 'removed: on');
325});
326
327test('the preference is read from storage when the page loads', async () => {
328 store.set('keys-off', '1');
329 listeners.keydown = [];
330 const src = await import('node:fs').then((m) => m.readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'));
331 doc.querySelectorAll = () => [];
332 doc.querySelector = () => null;
333 new Function(src)(); // a second page load
334 assert.equal(window.ermiteKeys.off(), true);
335 dom.body.events = [];
336 keydown({ closest: () => null }, 'j');
337 assert.deepEqual(dom.body.events, []);
338 store.set('keys-off', '');
339});
340
341
342test('activity moves a home row up only in newest activity order, preserving focus and cursor', (t) => {
343 t.mock.timers.enable({ apis: ['setTimeout'] });
344 for (const [sort, desc, activity, moves] of [
345 ['activity', 'true', true, true],
346 ['activity', 'false', true, false],
347 ['name', 'true', true, false],
348 ['name', 'false', true, false],
349 ['activity', 'true', false, false], // CI or metadata, not activity
350 ]) {
351 const r = rows();
352 r.list.dataset.sort = sort;
353 r.list.dataset.desc = desc;
354 const old = r.list.children[1];
355 old.setAttribute('data-cursor', '');
356 active = old.children[0];
357 const fresh = r.mk('repo-b', '/b');
358 if (activity) fresh.setAttribute('data-activity', '');
359 swapWith(`activity-${sort}-${desc}-${activity}`, fresh);
360 assert.equal(r.list.children.indexOf(fresh), moves ? 0 : 1);
361 assert.equal(active, fresh.children[0]);
362 assert.ok(fresh.hasAttribute('data-cursor'));
363 }
364});
365
366test('mobile MR layout shortcuts preserve the saved preference until back on desktop', () => {
367 const diffs = el('div', { id: 'diffs', attrs: { 'data-mr': '', 'data-diff': 'split', 'data-mobile': 'true' } });
368 dom.body.replaceChildren(diffs);
369 const oldQuery = doc.querySelector;
370 doc.querySelector = query.one;
371 const previous = globalThis.sessionStorage;
372 const writes = [];
373 globalThis.sessionStorage = { setItem: (...args) => writes.push(args) };
374 try {
375 window.ermite.layout('unified');
376 window.ermite.layout('split');
377 assert.equal(diffs.dataset.diff, 'split');
378 assert.deepEqual(writes, []);
379 diffs.dataset.mobile = 'false';
380 window.ermite.layout('unified');
381 assert.equal(diffs.dataset.diff, 'unified');
382 assert.deepEqual(writes, [['diff', 'unified']]);
383 } finally {
384 globalThis.sessionStorage = previous;
385 doc.querySelector = oldQuery;
386 }
387});
388
389test('dialog restores its row opener on close, but not over a replacement dialog', () => {
390 const row = el('li', { tabindex: '0' });
391 const first = el('dialog', { id: 'focus-first' });
392 const second = el('dialog', { id: 'focus-second' });
393 dom.body.append(row, first, second);
394 const queued = [];
395 const all = doc.querySelectorAll;
396 const one = doc.querySelector;
397 const open = () => [first, second].filter((d) => d.hasAttribute('open'));
398 doc.querySelectorAll = (sel) => sel === 'dialog[open]' ? open() : all.call(doc, sel);
399 doc.querySelector = (sel) => sel === 'dialog[open]' ? open()[0] ?? null : one.call(doc, sel);
400 for (const d of [first, second]) {
401 let onClose;
402 d.addEventListener = (type, fn) => { if (type === 'close') onClose = fn; };
403 d.showModal = () => { d.setAttribute('open', ''); d.focus(); };
404 d.close = () => { d.removeAttribute('open'); queued.push(onClose); };
405 }
406 row.focus();
407 window.ermite.dialog('focus-first');
408 first.close();
409 queued.shift()();
410 assert.equal(doc.activeElement, row);
411
412 row.focus();
413 window.ermite.dialog('focus-first');
414 window.ermite.dialog('focus-second');
415 queued.shift()();
416 assert.equal(doc.activeElement, second);
417 second.close();
418 queued.shift()();
419 doc.querySelectorAll = all;
420 doc.querySelector = one;
421});
422
423
424test('listbox result wrappers have no links; Enter and delegated clicks navigate', async () => {
425 doc.querySelector = query.one;
426 doc.querySelectorAll = query.all;
427 const assigned = [];
428 location.assign = (href) => assigned.push(href);
429 const activate = (anchor) => {
430 const e = { target: anchor, button: 0, preventDefault() {} };
431 listeners.click.find((fn) => fn.toString().includes('location.assign'))(e);
432 };
433 const p = palette();
434 p.list.setAttribute('role', 'listbox');
435 window.ermiteCmd.sync();
436 for (const item of p.items) assert.equal(item.querySelector('a[href]'), null);
437 const a = p.items[0].querySelector('a');
438 a.click = () => activate(a);
439 window.ermite.cmdKey({ key: 'Enter', preventDefault() {} });
440 assert.deepEqual(assigned, ['/home']);
441 activate(p.items[1].querySelector('a'));
442 assert.deepEqual(assigned, ['/home', '/files']);
443
444 const f = finder();
445 f.list.setAttribute('role', 'listbox');
446 f.q.value = 'main';
447 F.input(f.q);
448 flushFrames();
449 await settle();
450 assert.equal(f.list.querySelector('a[href]'), null);
451 const hit = f.list.children[0].querySelector('a');
452 assert.equal(hit.hasAttribute('tabindex'), false);
453 hit.click = () => activate(hit);
454 F.key(f.q, { key: 'Enter', preventDefault() {} });
455 assert.equal(assigned.at(-1), hit.dataset.href);
456 activate(hit);
457 assert.equal(assigned.at(-1), hit.dataset.href);
458 assert.notEqual(doc.getElementById('finder-status').textContent, '');
459 f.q.value = '';
460 F.input(f.q);
461 assert.equal(doc.getElementById('finder-status').textContent, '');
462});