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)), removeItem: (k) => store.delete(k) };
53const listeners = {};
54globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
55globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
56globalThis.scrollTo = () => {};
57globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });
58
59await import('../static/js/files.js');
60await import('../static/js/store.js'); // loaded before ermite.js, as in the page
61await import('../static/js/ermite.js');
62doc.querySelectorAll = query.all;
63doc.querySelector = query.one;
64const F = window.ermiteFinder;
65const K = window.ermiteKeys;
66
67// keydown runs an element handler, then the window ones unless stopped, as a
68// browser does.
69function keydown(target, key, elementHandler, extra = {}) {
70 const e = {
71 key, target, isComposing: false, prevented: false, stopped: false, blurred: false,
72 preventDefault() { this.prevented = true; },
73 stopPropagation() { this.stopped = true; },
74 ...extra,
75 };
76 target.closest ||= () => null;
77 elementHandler?.(e);
78 if (!e.stopped) for (const f of listeners.keydown) f(e);
79 return e;
80}
81
82// ---- the palette --------------------------------------------------------
83
84function palette() {
85 const li = (k, label) => el('li', { attrs: { 'data-k': k } }, el('a', { attrs: { href: '/' + label } }));
86 const items = [li('go home', 'home'), li('section files', 'files'), li('branch main', 'main')];
87 items[0].setAttribute('data-cursor', '');
88 const input = el('input', { id: 'cmd-q', attrs: { role: 'combobox', 'aria-controls': 'cmd-list' } });
89 const list = el('ul', { id: 'cmd-list' }, ...items);
90 dom.body.replaceChildren(input, list);
91 return { input, list, items };
92}
93
94test('the palette: options, selected option and aria-activedescendant follow the cursor', () => {
95 const p = palette();
96 window.ermiteCmd.sync();
97 assert.deepEqual(p.items.map((i) => i.getAttribute('role')), ['option', 'option', 'option']);
98 assert.equal(new Set(p.items.map((i) => i.id)).size, 3, 'unique ids');
99 assert.equal(p.input.getAttribute('aria-expanded'), 'true');
100 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[0].id);
101 assert.deepEqual(p.items.map((i) => i.getAttribute('aria-selected')), ['true', 'false', 'false']);
102
103 window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
104 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[1].id);
105 assert.deepEqual(p.items.map((i) => i.getAttribute('aria-selected')), ['false', 'true', 'false']);
106 assert.ok(p.items[1].hasAttribute('data-cursor') && !p.items[0].hasAttribute('data-cursor'));
107
108 window.ermite.cmdKey({ key: 'ArrowUp', preventDefault() {} });
109 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[0].id);
110});
111
112test('the palette: a filter moves the active option, no match empties it and collapses the popup', () => {
113 const p = palette();
114 p.input.value = 'branch';
115 window.ermite.cmdFilter(p.input);
116 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[2].id);
117 assert.equal(p.items[2].getAttribute('aria-selected'), 'true');
118 assert.equal(p.items[0].getAttribute('aria-selected'), 'false');
119 p.input.value = 'zzz';
120 window.ermite.cmdFilter(p.input);
121 assert.equal(p.input.getAttribute('aria-expanded'), 'false');
122 assert.equal(p.input.getAttribute('aria-activedescendant'), null);
123});
124
125test('the palette: the file hits that arrive later get ids, roles and the active option', async () => {
126 const p = palette();
127 dom.body.setAttribute('data-paths', '/paths.json');
128 dom.body.setAttribute('data-blob', '/blob/main');
129 p.input.value = 'main';
130 window.ermite.cmdFilter(p.input); // the branch "main" is the active option
131 assert.equal(p.input.getAttribute('aria-activedescendant'), p.items[2].id);
132 F.palette(p.input);
133 flushFrames();
134 await settle();
135 const hits = p.list.children.filter((li) => li.className === 'finder-hit');
136 assert.ok(hits.length > 0, 'a file hit came');
137 for (const h of hits) {
138 assert.equal(h.getAttribute('role'), 'option');
139 assert.ok(h.id);
140 }
141 assert.equal(p.list.children.length, new Set(p.list.children.map((i) => i.id)).size, 'ids stay unique');
142 // down to the hit: the field points at it
143 window.ermite.cmdKey({ key: 'ArrowDown', preventDefault() {} });
144 assert.equal(p.input.getAttribute('aria-activedescendant'), hits[0].id);
145 assert.equal(hits[0].getAttribute('aria-selected'), 'true');
146 dom.body.removeAttribute('data-paths');
147});
148
149// ---- the file finder ----------------------------------------------------
150
151function finder() {
152 const q = el('input', { id: 'finder-q', attrs: { role: 'combobox' } });
153 const rows = ['a', 'b'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
154 rows[0].setAttribute('data-cursor', '');
155 const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
156 const list = el('ul', { id: 'finder-list' });
157 dom.body.replaceChildren(q, el('div', { id: 'tree-view' }, treeRows), el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), list));
158 dom.body.setAttribute('data-paths', '/paths.json');
159 dom.body.setAttribute('data-blob', '/blob/main');
160 return { q, list };
161}
162
163test('Escape in the finder clears the query, keeps the focus in the field and does not reach the global handler', async () => {
164 const f = finder();
165 f.q.value = 'main';
166 F.input(f.q);
167 flushFrames();
168 await settle();
169 active = f.q;
170 let blurred = false;
171 f.q.blur = () => { blurred = true; };
172 f.q.closest = () => f.q; // the global handler sees a field being typed in
173 const e = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
174 assert.equal(f.q.value, '');
175 assert.equal(e.stopped, true);
176 assert.equal(e.prevented, true);
177 assert.equal(blurred, false, 'the field keeps the focus');
178 assert.equal(active, f.q);
179 // Escape again, with nothing typed: the field is left (global handler)
180 const e2 = keydown(f.q, 'Escape', (ev) => F.key(f.q, ev));
181 assert.equal(e2.stopped, false);
182 assert.equal(blurred, true);
183});
184
185test('the finder follows the cursor moved from outside (j / k)', async () => {
186 const f = finder();
187 f.q.value = 'm';
188 F.input(f.q);
189 flushFrames();
190 await settle();
191 const [first, second] = f.list.children;
192 assert.equal(f.q.getAttribute('aria-activedescendant'), first.id);
193 // ermite.move: the cursor goes to the next option
194 first.removeAttribute('data-cursor');
195 second.setAttribute('data-cursor', '');
196 F.sync();
197 assert.equal(second.getAttribute('aria-selected'), 'true');
198 assert.equal(first.getAttribute('aria-selected'), 'false');
199 assert.equal(f.q.getAttribute('aria-activedescendant'), second.id);
200});
201
202// ---- the focus through a live swap --------------------------------------
203
204function rows() {
205 const mk = (id, href) => el('li', { id }, el('a', { attrs: { href }, id: id + '-a' }), el('button', { id: id + '-b' }));
206 const main = el('main', { id: 'main', attrs: { tabindex: '-1' } });
207 const list = el('ul', { id: 'repo-rows' }, mk('repo-a', '/a'), mk('repo-b', '/b'), mk('repo-c', '/c'));
208 dom.body.replaceChildren(main, list);
209 return { main, list, mk };
210}
211const swapWith = (html, ...nodes) => { parsed[html] = nodes; window.ermiteLive.swap(html); };
212
213test('a swap that replaces the focused row puts the focus on the same control of the new one', () => {
214 const r = rows();
215 const old = r.list.children[1];
216 active = old.children[0];
217 dom.focused = [];
218 const fresh = r.mk('repo-b', '/b');
219 swapWith('b1', fresh);
220 assert.equal(active, fresh.children[0], 'the link with the same href');
221 assert.equal(dom.focused.at(-1).opts.preventScroll, true);
222 // the second control
223 active = fresh.children[1];
224 const fresh2 = r.mk('repo-b', '/b');
225 swapWith('b2', fresh2);
226 assert.equal(active, fresh2.children[1], 'the button with the same id');
227});
228
229test('a swap with the focus elsewhere does not touch it', () => {
230 const r = rows();
231 active = r.list.children[0].children[0];
232 dom.focused = [];
233 swapWith('b3', r.mk('repo-b', '/b'));
234 assert.equal(dom.focused.length, 0);
235 assert.equal(active, r.list.children[0].children[0]);
236});
237
238test('a removed row hands the focus to the next row, else the previous, else the content', () => {
239 const r = rows();
240 active = r.list.children[1].children[0];
241 const gone = (id) => { const g = el('li', { id, attrs: { 'data-gone': '' } }); return g; };
242 swapWith('g1', gone('repo-b'));
243 assert.equal(active, r.list.children[1].children[0], 'repo-c, which took its place');
244 assert.equal(r.list.children[1].id, 'repo-c');
245 active = r.list.children[1].children[0];
246 swapWith('g2', gone('repo-c'));
247 assert.equal(active, r.list.children[0].children[0], 'repo-a, the previous row');
248 swapWith('g3', gone('repo-a'));
249 // focus was on repo-a's link? it was handed there: now it is removed too
250 assert.equal(active, r.main);
251});
252
253test('a removed row skips the rows a filter hides', () => {
254 const r = rows();
255 const [a, b, c] = r.list.children;
256 b.hidden = true; // filtered out
257 active = a.children[0];
258 swapWith('g4', el('li', { id: 'repo-a', attrs: { 'data-gone': '' } }));
259 assert.equal(active, c.children[0], 'repo-c, the next shown row, not the hidden repo-b');
260 c.hidden = true;
261 swapWith('g5', el('li', { id: 'repo-c', attrs: { 'data-gone': '' } }));
262 assert.equal(active, r.main, 'no shown row left: the content');
263});
264
265// ---- single-character shortcuts -----------------------------------------
266
267test('shortcuts off: single characters do nothing, ctrl k, escape and enter still work', () => {
268 const events = () => dom.body.events.splice(0);
269 dom.body.events = [];
270 doc.querySelector = () => null;
271 const press = (key, extra) => keydown({ closest: () => null }, key, null, extra);
272 press('j');
273 assert.deepEqual(events(), ['k-j'], 'on by default');
274
275 K.set(true);
276 assert.equal(store.get('keys-off'), '1', 'persisted');
277 for (const k of ['j', 'k', '?', ':', 'G', 's', 'y']) press(k);
278 press('g'); press('h');
279 assert.deepEqual(events(), [], 'nothing fires');
280 press('k', { ctrlKey: true });
281 press('Escape');
282 assert.deepEqual(events(), ['k-cmd', 'k-esc']);
283
284 K.set(false);
285 assert.equal(store.get('keys-off'), '', 'persisted off');
286 press('j');
287 assert.deepEqual(events(), ['k-j']);
288});
289
290test('shortcuts off: the checkbox, the note and the palette action show the state', () => {
291 const box = el('input', { id: 'keys-off', attrs: { type: 'checkbox' } });
292 const note = el('p', { id: 'keys-note' });
293 const lbl = el('span', { cls: 'lbl' });
294 const list = el('ul', { id: 'cmd-list' }, el('li', {}, el('a', { attrs: { 'data-fire': 'keys' } }, lbl)));
295 dom.body.replaceChildren(box, note, list);
296 const find = doc.querySelector;
297 doc.querySelector = (sel) => (sel === '#cmd-list [data-fire="keys"] .lbl' ? lbl : null);
298 K.set(true);
299 assert.equal(box.checked, true);
300 assert.match(note.textContent, /are off/);
301 assert.match(lbl.textContent, /on$/);
302 K.set(false);
303 assert.equal(box.checked, false);
304 assert.match(note.textContent, /are on/);
305 assert.match(lbl.textContent, /off$/);
306 doc.querySelector = find;
307});
308
309test('shortcuts off: another tab\'s change applies here', () => {
310 // The browser has written the value before the event comes.
311 const storage = (newValue) => {
312 if (newValue === null) store.delete('keys-off'); else store.set('keys-off', newValue);
313 listeners.storage.forEach((f) => f({ key: 'keys-off', newValue }));
314 };
315 storage('1');
316 assert.equal(K.off(), true);
317 storage('');
318 assert.equal(K.off(), false);
319 storage(null);
320 assert.equal(K.off(), false, 'removed: on');
321});
322
323test('the preference is read from storage when the page loads', async () => {
324 store.set('keys-off', '1');
325 listeners.keydown = [];
326 const src = await import('node:fs').then((m) => m.readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'));
327 doc.querySelectorAll = () => [];
328 doc.querySelector = () => null;
329 new Function(src)(); // a second page load
330 assert.equal(window.ermiteKeys.off(), true);
331 dom.body.events = [];
332 keydown({ closest: () => null }, 'j');
333 assert.deepEqual(dom.body.events, []);
334 store.set('keys-off', '');
335});