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() {} } };
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: { href: '/' + label } }));
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.ok(h.id);
141 }
142 assert.equal(p.list.children.length, new Set(p.list.children.map((i) => i.id)).size, 'ids stay unique');
143 // down to the hit: the field points at it
144 window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
145 assert.equal(p.input.getAttribute('aria-activedescendant'), hits[0].id);
146 assert.equal(hits[0].getAttribute('aria-selected'), 'true');
147 dom.body.removeAttribute('data-paths');
148});
149
150// ---- the file finder ----------------------------------------------------
151
152function finder() {
153 const q = el('input', { id: 'finder-q', attrs: { role: 'combobox' } });
154 const rows = ['a', 'b'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
155 rows[0].setAttribute('data-cursor', '');
156 const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
157 const list = el('ul', { id: 'finder-list' });
158 dom.body.replaceChildren(q, el('div', { id: 'tree-view' }, treeRows), el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), list));
159 dom.body.setAttribute('data-paths', '/paths.json');
160 dom.body.setAttribute('data-blob', '/blob/main');
161 return { q, list };
162}
163
164test('Escape in the finder clears the query, keeps the focus in the field and does not reach the global handler', async () => {
165 const f = finder();
166 f.q.value = 'main';
167 F.input(f.q);
168 flushFrames();
169 await settle();
170 active = f.q;
171 let blurred = false;
172 f.q.blur = () => { blurred = true; };
173 f.q.closest = () => f.q; // the global handler sees a field being typed in
174 const e = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
175 assert.equal(f.q.value, '');
176 assert.equal(e.stopped, true);
177 assert.equal(e.prevented, true);
178 assert.equal(blurred, false, 'the field keeps the focus');
179 assert.equal(active, f.q);
180 // Escape again, with nothing typed: the field is left (global handler)
181 const e2 = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
182 assert.equal(e2.stopped, false);
183 assert.equal(blurred, true);
184});
185
186test('the finder follows the cursor moved from outside (j / k)', async () => {
187 const f = finder();
188 f.q.value = 'm';
189 F.input(f.q);
190 flushFrames();
191 await settle();
192 const [first, second] = f.list.children;
193 assert.equal(f.q.getAttribute('aria-activedescendant'), first.id);
194 // ermite.move: the cursor goes to the next option
195 first.removeAttribute('data-cursor');
196 second.setAttribute('data-cursor', '');
197 F.sync();
198 assert.equal(second.getAttribute('aria-selected'), 'true');
199 assert.equal(first.getAttribute('aria-selected'), 'false');
200 assert.equal(f.q.getAttribute('aria-activedescendant'), second.id);
201});
202
203// ---- the focus through a live swap --------------------------------------
204
205function rows() {
206 const mk = (id, href) => el('li', { id }, el('a', { attrs: { href }, id: id + '-a' }), el('button', { id: id + '-b' }));
207 const main = el('main', { id: 'main', attrs: { tabindex: '-1' } });
208 const list = el('ul', { id: 'repo-rows' }, mk('repo-a', '/a'), mk('repo-b', '/b'), mk('repo-c', '/c'));
209 dom.body.replaceChildren(main, list);
210 return { main, list, mk };
211}
212const swapWith = (html, ...nodes) => { parsed[html] = nodes; window.ermiteLive.swap(html); };
213
214test('a swap that replaces the focused row puts the focus on the same control of the new one', () => {
215 const r = rows();
216 const old = r.list.children[1];
217 active = old.children[0];
218 dom.focused = [];
219 const fresh = r.mk('repo-b', '/b');
220 swapWith('b1', fresh);
221 assert.equal(active, fresh.children[0], 'the link with the same href');
222 assert.equal(dom.focused.at(-1).opts.preventScroll, true);
223 // the second control
224 active = fresh.children[1];
225 const fresh2 = r.mk('repo-b', '/b');
226 swapWith('b2', fresh2);
227 assert.equal(active, fresh2.children[1], 'the button with the same id');
228});
229
230test('a swap with the focus elsewhere does not touch it', () => {
231 const r = rows();
232 active = r.list.children[0].children[0];
233 dom.focused = [];
234 swapWith('b3', r.mk('repo-b', '/b'));
235 assert.equal(dom.focused.length, 0);
236 assert.equal(active, r.list.children[0].children[0]);
237});
238
239test('a removed row hands the focus to the next row, else the previous, else the content', () => {
240 const r = rows();
241 active = r.list.children[1].children[0];
242 const gone = (id) => { const g = el('li', { id, attrs: { 'data-gone': '' } }); return g; };
243 swapWith('g1', gone('repo-b'));
244 assert.equal(active, r.list.children[1].children[0], 'repo-c, which took its place');
245 assert.equal(r.list.children[1].id, 'repo-c');
246 active = r.list.children[1].children[0];
247 swapWith('g2', gone('repo-c'));
248 assert.equal(active, r.list.children[0].children[0], 'repo-a, the previous row');
249 swapWith('g3', gone('repo-a'));
250 // focus was on repo-a's link? it was handed there: now it is removed too
251 assert.equal(active, r.main);
252});
253
254test('a removed row skips the rows a filter hides', () => {
255 const r = rows();
256 const [a, b, c] = r.list.children;
257 b.hidden = true; // filtered out
258 active = a.children[0];
259 swapWith('g4', el('li', { id: 'repo-a', attrs: { 'data-gone': '' } }));
260 assert.equal(active, c.children[0], 'repo-c, the next shown row, not the hidden repo-b');
261 c.hidden = true;
262 swapWith('g5', el('li', { id: 'repo-c', attrs: { 'data-gone': '' } }));
263 assert.equal(active, r.main, 'no shown row left: the content');
264});
265
266// ---- single-character shortcuts -----------------------------------------
267
268test('shortcuts off: single characters do nothing, ctrl k, escape and enter still work', () => {
269 const events = () => dom.body.events.splice(0);
270 dom.body.events = [];
271 doc.querySelector = () => null;
272 const press = (key, extra) => keydown({ closest: () => null }, key, null, extra);
273 press('j');
274 assert.deepEqual(events(), ['k-j'], 'on by default');
275
276 K.set(true);
277 assert.equal(store.get('keys-off'), '1', 'persisted');
278 for (const k of ['j', 'k', '?', ':', 'G', 's', 'y']) press(k);
279 press('g'); press('h');
280 assert.deepEqual(events(), [], 'nothing fires');
281 press('k', { ctrlKey: true });
282 press('Escape');
283 assert.deepEqual(events(), ['k-cmd', 'k-esc']);
284
285 K.set(false);
286 assert.equal(store.get('keys-off'), '', 'persisted off');
287 press('j');
288 assert.deepEqual(events(), ['k-j']);
289});
290
291test('shortcuts off: the checkbox, the note and the palette action show the state', () => {
292 const box = el('input', { id: 'keys-off', attrs: { type: 'checkbox' } });
293 const note = el('p', { id: 'keys-note' });
294 const lbl = el('span', { cls: 'lbl' });
295 const list = el('ul', { id: 'cmd-list' }, el('li', {}, el('a', { attrs: { 'data-fire': 'keys' } }, lbl)));
296 dom.body.replaceChildren(box, note, list);
297 const find = doc.querySelector;
298 doc.querySelector = (sel) => (sel === '#cmd-list [data-fire="keys"] .lbl' ? lbl : null);
299 K.set(true);
300 assert.equal(box.checked, true);
301 assert.match(note.textContent, /are off/);
302 assert.match(lbl.textContent, /on$/);
303 K.set(false);
304 assert.equal(box.checked, false);
305 assert.match(note.textContent, /are on/);
306 assert.match(lbl.textContent, /off$/);
307 doc.querySelector = find;
308});
309
310test('shortcuts off: another tab\'s change applies here', () => {
311 // The browser has written the value before the event comes.
312 const storage = (newValue) => {
313 if (newValue === null) store.delete('keys-off'); else store.set('keys-off', newValue);
314 listeners.storage.forEach((f) => f({ key: 'keys-off', newValue }));
315 };
316 storage('1');
317 assert.equal(K.off(), true);
318 storage('');
319 assert.equal(K.off(), false);
320 storage(null);
321 assert.equal(K.off(), false, 'removed: on');
322});
323
324test('the preference is read from storage when the page loads', async () => {
325 store.set('keys-off', '1');
326 listeners.keydown = [];
327 const src = await import('node:fs').then((m) => m.readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'));
328 doc.querySelectorAll = () => [];
329 doc.querySelector = () => null;
330 new Function(src)(); // a second page load
331 assert.equal(window.ermiteKeys.off(), true);
332 dom.body.events = [];
333 keydown({ closest: () => null }, 'j');
334 assert.deepEqual(dom.body.events, []);
335 store.set('keys-off', '');
336});
337
338
339test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {
340 for (const [sort, desc, activity, moves] of [
341 ['activity', 'true', true, true],
342 ['activity', 'false', true, false],
343 ['name', 'true', true, false],
344 ['name', 'false', true, false],
345 ['activity', 'true', false, false], // CI or metadata, not activity
346 ]) {
347 const r = rows();
348 r.list.dataset.sort = sort;
349 r.list.dataset.desc = desc;
350 const old = r.list.children[1];
351 old.setAttribute('data-cursor', '');
352 active = old.children[0];
353 const fresh = r.mk('repo-b', '/b');
354 if (activity) fresh.setAttribute('data-activity', '');
355 swapWith(`activity-${sort}-${desc}-${activity}`, fresh);
356 assert.equal(r.list.children.indexOf(fresh), moves ? 0 : 1);
357 assert.equal(active, fresh.children[0]);
358 assert.ok(fresh.hasAttribute('data-cursor'));
359 }
360});