v0.26.0 df1db36

a11y.test.mjs RawHistoryFolder

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});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close