Public: anyone can read this; the crew can change it.

Every page key is handled from the one table the `?` sheet lists, so the sheet cannot name a key that does nothing #11004

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10994-every-page-key-is-handled-from-the-one-t → main 5 commits fork

  • Merged. mason/10994-every-page-key-is-handled-from-the-one-t was merged into main as 0b78a28.

Review

Sign in to review this change.
7 files changed +122 −106
src/web/client.ts +43 −36
@@ -1563,27 +1563,20 @@ const shellCore = /* js */ `
15631563 var t = e.target;
15641564 // Inside an open dialog the dialog owns the keys; none of these apply.
15651565 if (t && t.closest && t.closest('dialog[open]')) return;
1566 // The palette is one chord away from anywhere, a field included.
1567 if ((e.metaKey || e.ctrlKey) && !e.altKey && (e.key === 'k' || e.key === 'K')) {
1568 e.preventDefault();
1569 openPalette();
1570 return;
1566 // Chords answer from the same table, ahead of the field guard: the palette
1567 // is one chord away from anywhere, a field included, and Cmd/Ctrl+Enter
1568 // sends the form a box belongs to.
1569 if ((e.metaKey || e.ctrlKey) && !e.altKey) {
1570 var chord = rowForKey('Mod+' + (e.key === 'K' ? 'k' : e.key));
1571 if (chord) { chord[3](e); return; }
15711572 }
15721573 if (t && t.matches && t.matches('input, textarea, select, [contenteditable]')) {
15731574 if (e.key === 'Escape') { e.preventDefault(); t.blur(); }
1574 // Cmd/Ctrl+Enter sends the form a box belongs to, and only a form that
1575 // asked for it (\`data-submit-key\`): the button's own submit, with its
1576 // validation. Plain Enter stays a newline.
1577 else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && t.form && t.form.hasAttribute('data-submit-key')) {
1578 e.preventDefault();
1579 if (t.form.requestSubmit) t.form.requestSubmit(); else t.form.submit();
1580 }
15811575 return;
15821576 }
15831577 if (e.metaKey || e.ctrlKey || e.altKey) return;
1584 if (e.key === 'Escape' && e.defaultPrevented) return;
1585 if (e.key === 'Escape' && escClosedHints) { escClosedHints = false; e.preventDefault(); return; }
1586 if (e.key === 'Escape' && leaveToList()) { e.preventDefault(); return; }
1578 var row = rowForKey(e.key);
1579 if (e.key === 'Escape') { if (row) row[3](e); return; }
15871580 if (Date.now() - last > 1000) seq.length = 0;
15881581 last = Date.now();
15891582 seq.push(e.key);
@@ -1603,39 +1596,53 @@ const shellCore = /* js */ `
16031596 var gp = gotoPages().filter(function (p) { return p[3] !== '' && p[3] === e.key; })[0];
16041597 if (gp) { seq.length = 0; e.preventDefault(); location.assign(gp[1]); return; }
16051598 }
1606 if (e.key === 'j') { seq.length = 0; e.preventDefault(); move(1); }
1607 else if (e.key === 'k') { seq.length = 0; e.preventDefault(); move(-1); }
1608 else if (e.key === 'o' || e.key === 'Enter') { seq.length = 0; openFocused(); }
1609 else if (e.key === 'c') {
1610 if (focusBox()) { seq.length = 0; e.preventDefault(); } else openNewIssue(e);
1611 } else if (e.key === 'C') openNewIssue(e);
1612 else if (e.key === '/') {
1613 var box = doc.querySelector('[data-search-input]');
1614 if (box) { seq.length = 0; e.preventDefault(); box.focus(); box.select(); }
1615 } else if (e.key === '?') {
1616 seq.length = 0; e.preventDefault();
1617 // One press, one sheet: the help opens at once (Option still lays the keys over the page).
1618 hints(false); openHelp();
1619 }
1620 else if (e.key === ':') { seq.length = 0; e.preventDefault(); openGoto(); }
1599 if (row) { seq.length = 0; row[3](e); }
16211600 });
16221601
16231602 // ---- help overlay, generated from the shortcuts actually on the page ----
1603 // One table: the sheet lists its rows and the keydown handler runs them.
1604 // A row is [shown, what it does, the e.key values it answers, its action];
1605 // a chord is written 'Mod+<key>' (Cmd or Ctrl) and is read ahead of the field guard.
1606 function submitBox(f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
16241607 var STATIC_KEYS = [
1625 ['j / k', 'Move between rows'], ['o or Enter', 'Open the focused row'],
1626 ['/', 'Focus search'], ['Cmd/Ctrl K', 'Jump to anywhere'], [':', 'Go to a page by name'], ['?', 'This help'],
1627 ['Esc', 'Leave a box, close a menu or dialog'],
1608 ['j / k', 'Move between rows', ['j', 'k'], function (e) { e.preventDefault(); move(e.key === 'j' ? 1 : -1); }],
1609 ['o or Enter', 'Open the focused row', ['o', 'Enter'], openFocused],
1610 ['/', 'Focus search', ['/'], function (e) {
1611 var box = doc.querySelector('[data-search-input]');
1612 if (box) { e.preventDefault(); box.focus(); box.select(); }
1613 }],
1614 ['Cmd/Ctrl K', 'Jump to anywhere', ['Mod+k'], function (e) { e.preventDefault(); openPalette(); }],
1615 [':', 'Go to a page by name', [':'], function (e) { e.preventDefault(); openGoto(); }],
1616 ['?', 'This help', ['?'], function (e) { e.preventDefault(); hints(false); openHelp(); }],
1617 ['Esc', 'Leave a box, close a menu or dialog', ['Escape'], function (e) {
1618 if (e.defaultPrevented) return;
1619 if (escClosedHints) { escClosedHints = false; e.preventDefault(); return; }
1620 if (leaveToList()) e.preventDefault();
1621 }],
16281622 ];
1623 // The keys a page row may answer: only these cost a look at the page.
1624 var PAGE_KEYS = ['c', 'C', 'Mod+Enter'];
1625 function answers(r, k) { return r[2].indexOf(k) >= 0; }
1626 function rowForKey(k) {
1627 var row = STATIC_KEYS.filter(function (r) { return answers(r, k); })[0];
1628 if (row || PAGE_KEYS.indexOf(k) < 0) return row;
1629 return pageKeys().filter(function (r) { return answers(r, k); })[0];
1630 }
16291631 // The rows a page earns by what it carries: a box to type in, a form
16301632 // that sends on Cmd/Ctrl+Enter.
16311633 function pageKeys() {
16321634 var out = [];
1633 if (pageBox() && !doc.querySelector('[data-shortcut="c"]')) out.push(['c', doc.querySelector('[data-box]').getAttribute('data-box') || 'Write here']);
1635 if (pageBox() && !doc.querySelector('[data-shortcut="c"]')) out.push(['c', doc.querySelector('[data-box]').getAttribute('data-box') || 'Write here', ['c'], function (e) { if (focusBox()) e.preventDefault(); else openNewIssue(e); }]);
16341636 // A page that keeps c for its own box shows Shift C alone; any other
16351637 // page opens the new issue on either.
16361638 var ownC = pageBox() || doc.querySelector('[data-shortcut="c"]');
1637 out.push([ownC ? 'Shift C' : 'c / Shift C', 'New issue from this page']);
1638 if (doc.querySelector('form[data-submit-key]')) out.push(['Cmd/Ctrl Enter', 'Submit the box you are typing in']);
1639 out.push(ownC ? ['Shift C', 'New issue from this page', ['C'], openNewIssue] : ['c / Shift C', 'New issue from this page', ['c', 'C'], openNewIssue]);
1640 // Only a form that asked for it (\`data-submit-key\`) is sent: the button's own
1641 // submit, with its validation. Plain Enter stays a newline.
1642 if (doc.querySelector('form[data-submit-key]')) out.push(['Cmd/Ctrl Enter', 'Submit the box you are typing in', ['Mod+Enter'], function (e) {
1643 var f = e.target && e.target.form;
1644 if (f && f.hasAttribute('data-submit-key')) { e.preventDefault(); submitBox(f); }
1645 }]);
16391646 return out;
16401647 }
16411648 // Built with DOM nodes, never innerHTML: every string below is page text,
test/flow-system-keys.test.ts +1 −1
@@ -26,7 +26,7 @@ for (const path of ["/flow", "/system"]) {
2626 expect(shellSrc(await get("/boards"))).toBe(src);
2727 expect(html).not.toContain("openHelp");
2828 const js = await (await fetch(`${f.base}${src}`)).text();
29 expect(js).toContain("e.key === '?'");
29 expect(js).toContain("['?'], function (e)");
3030 expect(js).toContain("help-dialog");
3131 });
3232 }
test/help-keys.test.ts deleted −56
@@ -1,56 +0,0 @@
1 import { expect, test } from 'bun:test';
2 import { readFileSync } from 'node:fs';
3
4 const src = readFileSync(new URL('../src/web/client.ts', import.meta.url), 'utf8');
5
6 // The document keydown handler, up to the help overlay that follows it.
7 const end = src.indexOf('// ---- help overlay');
8 const start = src.lastIndexOf("doc.addEventListener('keydown'", end);
9 const handler = src.slice(start, end);
10
11 const answered = (): Set<string> => {
12 const keys = new Set<string>();
13 for (const m of handler.matchAll(/e\.key === '([^']+)'/g)) keys.add((m[1] ?? '').toLowerCase());
14 return keys;
15 };
16
17 // A STATIC_KEYS label ("j / k", "o or Enter", "Cmd/Ctrl K", "Esc") as key names.
18 const named = (label: string): string[] => {
19 const words = label
20 .replace('Cmd/Ctrl', ' ')
21 .split(/\s+or\s+|\s+\/\s+|\s+/)
22 .filter(Boolean)
23 .map((w) => (w === 'Esc' ? 'escape' : w.toLowerCase()));
24 return words;
25 };
26
27 const listed = (): Set<string> => {
28 const block = src.match(/var STATIC_KEYS = \[([\s\S]*?)\n \];/)?.[1] ?? '';
29 expect(block).not.toBe('');
30 const keys = new Set<string>();
31 for (const m of block.matchAll(/\[\s*'([^']+)'\s*,\s*'[^']*'\s*\]/g)) for (const k of named(m[1] ?? '')) keys.add(k);
32 // `c` is a row pageKeys() adds on pages that carry a box.
33 if (/out\.push\(\['c',/.test(src)) keys.add('c');
34 return keys;
35 };
36
37 test('the handler and the help overlay are both found', () => {
38 expect(start).toBeGreaterThan(0);
39 expect(end).toBeGreaterThan(start);
40 expect(answered().size).toBeGreaterThan(5);
41 });
42
43 test('every key the handler answers has a row in STATIC_KEYS', () => {
44 const rows = listed();
45 expect([...answered()].filter((k) => !rows.has(k))).toEqual([]);
46 });
47
48 test('every key STATIC_KEYS names is answered by the handler', () => {
49 const keys = answered();
50 expect([...listed()].filter((k) => !keys.has(k))).toEqual([]);
51 });
52
53 test(': opens the palette and is listed', () => {
54 expect(handler).toContain("e.key === ':'");
55 expect(src).toContain("[':', 'Go to a page by name']");
56 });
test/keyhelp.test.ts +70 −4
@@ -1,5 +1,6 @@
11 import { expect, test } from 'bun:test';
22 import { readFileSync } from 'node:fs';
3 import { shellJs } from '../src/web/client.ts';
34
45 const src = readFileSync(new URL('../src/web/client.ts', import.meta.url), 'utf8');
56 const layout = readFileSync(new URL('../src/web/layout.ts', import.meta.url), 'utf8');
@@ -12,10 +13,10 @@ const help = src.slice(helpStart, src.indexOf('// ---- command palette'));
1213 test('the field guard returns before any plain key is read', () => {
1314 const guard = handler.indexOf("t.matches('input, textarea, select, [contenteditable]')");
1415 expect(guard).toBeGreaterThan(0);
15 for (const key of ['j', 'k', 'o', '?', '/', ':', 'c', 'C']) {
16 const at = handler.lastIndexOf(`e.key === '${key}'`);
17 expect(at).toBeGreaterThan(guard);
18 }
16 // The keys are looked up in the table, after the guard.
17 expect(handler.indexOf('rowForKey(e.key)')).toBeGreaterThan(guard);
18 // The chords are looked up ahead of it.
19 expect(handler.indexOf("rowForKey('Mod+'")).toBeLessThan(guard);
1920 // The guard block ends in a bare return: nothing typed in a field falls through.
2021 const block = handler.slice(guard, handler.indexOf('if (e.metaKey || e.ctrlKey || e.altKey) return;'));
2122 expect(block).toMatch(/\n\s+return;\n\s+\}\n/);
@@ -43,3 +44,68 @@ test('the dialog is out of the flow and in the page chrome, built without innerH
4344 expect(layout).toContain('id="help-dialog"');
4445 expect(help).not.toMatch(/\.innerHTML\s*[=+]/);
4546 });
47
48 // Evaluate the table itself: the rows between `function submitBox` and the sheet's builder,
49 // with the handler's helpers stubbed, on a page that carries every page row.
50 function table(page: { box?: boolean; submit?: boolean } = { box: true, submit: true }) {
51 const code = shellJs.slice(shellJs.indexOf('function submitBox'), shellJs.indexOf('// Built with DOM nodes'));
52 expect(code).toContain('var STATIC_KEYS');
53 const doc = {
54 querySelector: (q: string) => (q === 'form[data-submit-key]' ? (page.submit ? {} : null) : q === '[data-box]' ? { getAttribute: () => 'Reply' } : null),
55 };
56 const f = new Function(
57 'doc', 'pageBox', 'focusBox', 'openPalette', 'move', 'openFocused', 'openGoto', 'hints', 'openHelp', 'leaveToList', 'openNewIssue', 'escClosedHints',
58 `${code}\nreturn { STATIC_KEYS: STATIC_KEYS, PAGE_KEYS: PAGE_KEYS, pageKeys: pageKeys, rowForKey: rowForKey };`,
59 );
60 const noop = () => {};
61 return f(doc, () => (page.box ? {} : null), () => true, noop, noop, noop, noop, noop, noop, noop, () => false, noop, false) as {
62 STATIC_KEYS: any[][]; PAGE_KEYS: string[]; pageKeys: () => any[][]; rowForKey: (k: string) => any[] | undefined;
63 };
64 }
65
66 test('every row of the table, and of a page that earns them all, has an action', () => {
67 const t = table();
68 const rows = [...t.STATIC_KEYS, ...t.pageKeys()];
69 expect(rows.length).toBeGreaterThanOrEqual(10);
70 for (const r of rows) {
71 expect(typeof r[0]).toBe('string');
72 expect(Array.isArray(r[2]) && r[2].length > 0).toBe(true);
73 expect(typeof r[3]).toBe('function');
74 }
75 });
76
77 test('every key a row lists resolves to that row, chords included', () => {
78 const t = table();
79 for (const r of [...t.STATIC_KEYS, ...t.pageKeys()]) for (const k of r[2] as string[]) expect(t.rowForKey(k)?.[0]).toBe(r[0]);
80 expect(t.rowForKey('Mod+k')?.[0]).toBe('Cmd/Ctrl K');
81 expect(t.rowForKey('Mod+Enter')?.[0]).toBe('Cmd/Ctrl Enter');
82 expect(t.rowForKey('x')).toBeUndefined();
83 });
84
85 test('the keys a page row may answer are the ones the cheap lookup lets through', () => {
86 const t = table();
87 const keys = t.pageKeys().flatMap((r) => r[2] as string[]);
88 for (const k of keys) expect(t.PAGE_KEYS).toContain(k);
89 // A page without a submit form lists no chord for it.
90 expect(table({ box: false, submit: false }).rowForKey('Mod+Enter')).toBeUndefined();
91 });
92
93 test('the label of each row names the keys it answers', () => {
94 const t = table();
95 for (const r of [...t.STATIC_KEYS, ...t.pageKeys()]) {
96 const shown = (r[0] as string).toLowerCase().replace('esc', 'escape').replace('cmd/ctrl', 'mod').replace(/ /g, '');
97 for (const k of r[2] as string[]) {
98 const key = k.toLowerCase().replace('mod+', 'mod');
99 expect(shown.includes(key.replace(/^mod/, '')) || shown.includes(key)).toBe(true);
100 }
101 }
102 });
103
104 test('after the guards the handler reads no key literally but Escape; the table answers', () => {
105 const after = handler.slice(handler.indexOf('if (e.metaKey || e.ctrlKey || e.altKey) return;'));
106 expect([...after.matchAll(/e\.key === '([^']+)'/g)].map((m) => m[1])).toEqual(['Escape']);
107 // Before the field guard the chords are looked up in the table too, not written out.
108 const before = handler.slice(0, handler.indexOf("t.matches('input, textarea, select, [contenteditable]')"));
109 expect(before).toContain("rowForKey('Mod+'");
110 expect(before).not.toContain("e.key === 'Enter'");
111 });
test/keys-palette.test.ts +1 −2
@@ -76,8 +76,7 @@ test("the keys script: : opens the combobox prompt, announces the lead, says whe
7676 const page = await (await fetch(`${f.base}/`)).text();
7777 const path = /src="([^"]*\.js[^"]*)"/.exec(page.slice(page.indexOf("<body")))![1]!;
7878 const js = await (await fetch(`${f.base}${path}`)).text();
79 expect(js).toContain("e.key === ':'");
80 expect(js).toMatch(/e\.key === ':'\) \{ seq\.length = 0; e\.preventDefault\(\); openGoto\(\)/);
79 expect(js).toContain("[':'], function (e) { e.preventDefault(); openGoto(); }");
8180 expect(js).toContain("function goMatch(pages, q)");
8281 for (const a of ["'combobox'", "'aria-controls', 'goto-list'", "aria-activedescendant", "'listbox'", "'option'"]) expect(js).toContain(a);
8382 expect(js).not.toContain("data-goto");
test/new-issue-anywhere.test.ts +4 −3
@@ -47,12 +47,13 @@ describe("a new issue from any page", () => {
4747 });
4848
4949 test("c and Shift C open the door from the page, and the help names each case", () => {
50 expect(shellJs).toContain("e.key === 'C') openNewIssue(e)");
51 expect(shellJs).toContain("if (focusBox()) { seq.length = 0; e.preventDefault(); } else openNewIssue(e);");
50 expect(shellJs).toContain("['c', 'C'], openNewIssue]");
51 expect(shellJs).toContain("['C'], openNewIssue]");
52 expect(shellJs).toContain("['c'], function (e) { if (focusBox()) e.preventDefault(); else openNewIssue(e); }]");
5253 expect(shellJs).toContain("'/new/issue?from=' + encodeURIComponent(p)");
5354 // A page with its own box (or its own c) shows Shift C alone; any other, both keys.
5455 expect(shellJs).toContain("var ownC = pageBox() || doc.querySelector('[data-shortcut=\"c\"]');");
55 expect(shellJs).toContain("ownC ? 'Shift C' : 'c / Shift C', 'New issue from this page'");
56 expect(shellJs).toContain("ownC ? ['Shift C', 'New issue from this page', ['C'], openNewIssue] : ['c / Shift C'");
5657 expect(shellJs).toContain("out.push(['c', doc.querySelector('[data-box]')");
5758 });
5859
test/web-ui.test.ts +3 −4
@@ -988,7 +988,7 @@ describe("web ui", () => {
988988 const seeded = openIssue(app, mel, proj, { title: "dismiss me" });
989989 if (!seeded.ok) throw new Error(seeded.error);
990990 expect(assignIssue(app, mel, proj, seeded.value, mel).ok).toBe(true);
991 expect(shellJs).toContain(`e.key === '${NEW_ISSUE_KEY}'`);
991 expect(shellJs).toContain(`['${NEW_ISSUE_KEY}'], openNewIssue]`);
992992 for (const k of SCRIPT_KEYS) {
993993 const page = await (await fetch(`${base}${k.page}`, { headers: { cookie: `${owner}; forge_mode=studio` } })).text();
994994 // A menu row draws the key in its column; anything else carries it as data-hint (#8930).
@@ -1064,9 +1064,8 @@ describe("web ui", () => {
10641064
10651065 // The shell sends a form on Enter only with a modifier held, only for a
10661066 // form that opted in, and nowhere else.
1067 expect(shellJs).toContain(
1068 "(e.metaKey || e.ctrlKey) && e.key === 'Enter' && t.form && t.form.hasAttribute('data-submit-key')",
1069 );
1067 expect(shellJs).toContain("['Mod+Enter'], function (e) {");
1068 expect(shellJs).toContain("if (f && f.hasAttribute('data-submit-key')) { e.preventDefault(); submitBox(f); }");
10701069 const sends = shellJs
10711070 .split("\n")
10721071 .filter((l) => /requestSubmit|\.submit\(/.test(l));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs