v0.21.0 ef38ad0

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

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 2

Tags 45

↑↓ moveenter openesc close