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

small: the feed moves by keyboard: j and k step between posts, Enter opens the one in focus, ? lists the keys #11327

Merged opened this

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

Merged

mason/forge:mason/11318-small-the-feed-moves-by-keyboard-j-and-k → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/11318-small-the-feed-moves-by-keyboard-j-and-k was merged into main as 6238a28.

Review

Sign in to review this change.
4 files changed +139 −29
src/web/client.ts +14 −14
@@ -11,6 +11,7 @@
1111 */
1212
1313 import { GO_MATCH_JS, goPagesData } from "./go_pages.ts";
14 import { keyDecisionJs } from "./key_decision.ts";
1415 import { actNowJs } from "./act_now.ts";
1516 import { pickJs } from "./pick.ts";
1617 import { imageViewJs } from "./render/image.ts";
@@ -1459,6 +1460,7 @@ const shellCore = /* js */ `
14591460 });
14601461
14611462 // ---- keyboard: sequences from the DOM, so the help can never drift ----
1463 ${keyDecisionJs}
14621464 var seq = []; var last = 0;
14631465 function rows() { return Array.prototype.slice.call(doc.querySelectorAll('[data-nav]')); }
14641466 function move(step) {
@@ -1550,20 +1552,18 @@ const shellCore = /* js */ `
15501552 window.addEventListener('blur', function () { hints(false); });
15511553 doc.addEventListener('keydown', function (e) {
15521554 var t = e.target;
1553 // Inside an open dialog the dialog owns the keys; none of these apply.
1554 if (t && t.closest && t.closest('dialog[open]')) return;
1555 // Chords answer from the same table, ahead of the field guard: the palette
1556 // is one chord away from anywhere, a field included, and Cmd/Ctrl+Enter
1557 // sends the form a box belongs to.
1558 if ((e.metaKey || e.ctrlKey) && !e.altKey) {
1559 var chord = rowForKey('Mod+' + (e.key === 'K' ? 'k' : e.key));
1560 if (chord) { chord[3](e); return; }
1561 }
1562 if (t && t.matches && t.matches('input, textarea, select, [contenteditable]')) {
1563 if (e.key === 'Escape') { e.preventDefault(); t.blur(); }
1564 return;
1565 }
1566 if (e.metaKey || e.ctrlKey || e.altKey) return;
1555 var chord = (e.metaKey || e.ctrlKey) && !e.altKey ? rowForKey('Mod+' + (e.key === 'K' ? 'k' : e.key)) : null;
1556 // The decision is key_decision.ts: a dialog owns its keys, a chord
1557 // answers from anywhere, a field keeps its typing, a held modifier is
1558 // the browser's.
1559 var what = decideKey({
1560 ctrl: e.ctrlKey, meta: e.metaKey, alt: e.altKey, key: e.key, hasChord: !!chord,
1561 inDialog: !!(t && t.closest && t.closest('dialog[open]')),
1562 inField: !!(t && t.matches && t.matches('input, textarea, select, [contenteditable]')),
1563 });
1564 if (what === 'chord') { chord[3](e); return; }
1565 if (what === 'blur') { e.preventDefault(); t.blur(); return; }
1566 if (what !== 'key') return;
15671567 var row = rowForKey(e.key);
15681568 if (e.key === 'Escape') { if (row) row[3](e); return; }
15691569 if (Date.now() - last > 1000) seq.length = 0;
src/web/key_decision.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * What a keydown means, as plain data in and an action out. The shell's one
3 * handler (client.ts) ships this function's source and obeys its answer, so
4 * the rule that keeps a key inert while someone types lives in one place
5 * and is tested directly.
6 *
7 * - `null`: the key is not ours (an open dialog owns its keys, a modifier
8 * is held, a field has focus).
9 * - `"chord"`: a Cmd/Ctrl chord with a row of its own; it answers even from a field.
10 * - `"blur"`: Escape in a field leaves the field.
11 * - `"key"`: a plain key; the shell looks it up in its table.
12 */
13 export interface KeyFacts {
14 ctrl: boolean;
15 meta: boolean;
16 alt: boolean;
17 inField: boolean;
18 inDialog: boolean;
19 /** A chord row answers this key (read by the shell, ahead of the field guard). */
20 hasChord: boolean;
21 key: string;
22 }
23 export type KeyAction = "chord" | "blur" | "key" | null;
24
25 export function decideKey(f: KeyFacts): KeyAction {
26 if (f.inDialog) return null;
27 if (f.hasChord) return "chord";
28 if (f.inField) return f.key === "Escape" ? "blur" : null;
29 if (f.meta || f.ctrl || f.alt) return null;
30 return "key";
31 }
32
33 /** The source the shell inlines. */
34 export const keyDecisionJs = decideKey.toString();
test/feed-keys.test.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * The feed moves by keyboard: j/k step between posts, Enter opens the one in
3 * focus, ? lists the keys. The decision (key_decision.ts) is tested on plain
4 * data; the rows it hands a key to are the shell's real table.
5 */
6 import { expect, test } from "bun:test";
7 import { shellJs } from "../src/web/client.ts";
8 import { decideKey, keyDecisionJs, type KeyFacts } from "../src/web/key_decision.ts";
outdated

Markers are exact source text; a reformat breaks the test, and a miss gives -1 so slice() takes the wrong code without failing loudly. Extract the decision into a named module and test it with plain data.

9
10 const plain: KeyFacts = { key: "j", ctrl: false, meta: false, alt: false, inField: false, inDialog: false, hasChord: false };
outdated

This table is a fake that answers whatever the test lists, so the first test passes whatever the real keys do. Use the real key rows.

11 const decide = (o: Partial<KeyFacts>) => decideKey({ ...plain, ...o });
12
13 test("a plain key is the table's to answer", () => {
14 for (const key of ["j", "k", "Enter", "?", "Escape", "o"]) expect(decide({ key })).toBe("key");
15 });
16
outdated

The body string and replace(...) are dead work, and new Function over sliced source is the brittle part. Replace it with a direct call to the extracted decision function.

17 test("in a field, a select or a contenteditable nothing runs, and Escape leaves it", () => {
18 // inField is what input, textarea, select and [contenteditable] all become at the boundary.
19 for (const key of ["j", "k", "Enter", "?", "o"]) expect(decide({ key, inField: true })).toBeNull();
20 expect(decide({ key: "Escape", inField: true })).toBe("blur");
21 });
22
23 test("Ctrl, Meta or Alt held leaves the browser's shortcut alone", () => {
24 for (const mod of ["ctrl", "meta", "alt"] as const) expect(decide({ [mod]: true })).toBeNull();
25 });
26
27 test("a chord with a row answers, from a field too; one without is the browser's", () => {
28 expect(decide({ key: "k", ctrl: true, hasChord: true })).toBe("chord");
29 expect(decide({ key: "Enter", meta: true, hasChord: true, inField: true })).toBe("chord");
30 expect(decide({ key: "k", ctrl: true, inField: true })).toBeNull();
31 });
32
33 test("an open dialog owns every key", () => {
34 expect(decide({ inDialog: true })).toBeNull();
35 expect(decide({ key: "Enter", ctrl: true, hasChord: true, inDialog: true })).toBeNull();
36 });
37
38 test("the shell ships the decision and its handler obeys it, ahead of any key lookup", () => {
39 expect(shellJs).toContain(keyDecisionJs);
40 const at = shellJs.indexOf("var what = decideKey(");
41 expect(at).toBeGreaterThan(0);
42 expect(shellJs.indexOf("if (what !== 'key') return;")).toBeGreaterThan(at);
43 expect(shellJs.indexOf("var row = rowForKey(e.key);")).toBeGreaterThan(shellJs.indexOf("if (what !== 'key') return;"));
44 });
45
46 test("the feed's keys are the shell's real rows", () => {
47 const from = shellJs.indexOf("var STATIC_KEYS = [");
48 const to = shellJs.indexOf("// Built with DOM nodes");
49 expect(from).toBeGreaterThan(0);
50 expect(to).toBeGreaterThan(from);
51 const noop = () => {};
52 const rows = new Function(
53 "doc", "pageBox", "focusBox", "openPalette", "move", "openFocused", "openGoto", "hints", "openHelp", "leaveToList", "openNewIssue", "escClosedHints",
54 `function submitBox(){}\n${shellJs.slice(from, to)}\nreturn STATIC_KEYS;`,
55 )({ querySelector: () => null }, noop, noop, noop, noop, noop, noop, noop, noop, noop, noop, false) as [string, string, string[]][];
56 const label = (k: string) => rows.find((r) => r[2].includes(k))?.[0];
57 expect(label("j")).toBe("j / k");
58 expect(label("k")).toBe("j / k");
59 expect(label("Enter")).toBe("o or Enter");
60 expect(label("?")).toBe("?");
61 expect(label("Escape")).toBe("Esc");
62 });
63
64 test("a feed post is a focusable row, and its link is what Enter opens", async () => {
65 const { feedCard } = await import("../src/web/studio/feed.ts");
66 const html = String(
67 feedCard({ id: "i1", app: "a/b", kind: "run", what: "Shipped", reason: "", link: "/a/b/runs/r1", at: Date.now(), actor: "a" } as never),
68 );
69 expect(html).toMatch(/data-nav\s+tabindex="0"/);
70 expect(html).toContain('<a href="/a/b/runs/r1" data-feed-open>');
71 });
72
73 test("the key decision stays under the 1.5 KB the feature may add", () => {
74 expect(Buffer.byteLength(keyDecisionJs)).toBeLessThan(1500);
75 const handler = shellJs.slice(shellJs.indexOf("var what = decideKey("), shellJs.indexOf("if (what !== 'key') return;"));
76 expect(Buffer.byteLength(keyDecisionJs) + Buffer.byteLength(handler)).toBeLessThan(1500);
77 });
test/keyhelp.test.ts +14 −15
@@ -10,20 +10,19 @@ const handlerStart = src.lastIndexOf("doc.addEventListener('keydown'", helpStart
1010 const handler = src.slice(handlerStart, helpStart);
1111 const help = src.slice(helpStart, src.indexOf('// ---- command palette'));
1212
13 test('the field guard returns before any plain key is read', () => {
14 const guard = handler.indexOf("t.matches('input, textarea, select, [contenteditable]')");
15 expect(guard).toBeGreaterThan(0);
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);
20 // The guard block ends in a bare return: nothing typed in a field falls through.
21 const block = handler.slice(guard, handler.indexOf('if (e.metaKey || e.ctrlKey || e.altKey) return;'));
22 expect(block).toMatch(/\n\s+return;\n\s+\}\n/);
13 test('the field guard is the shared decision, read before any plain key is looked up', () => {
14 // The rule itself is tested on plain data in feed-keys.test.ts.
15 const decided = handler.indexOf("decideKey({");
16 expect(decided).toBeGreaterThan(0);
17 expect(handler).toContain("t.matches('input, textarea, select, [contenteditable]')");
18 expect(handler.indexOf("if (what !== 'key') return;")).toBeGreaterThan(decided);
19 expect(handler.indexOf('rowForKey(e.key)')).toBeGreaterThan(handler.indexOf("if (what !== 'key') return;"));
20 // The chords are looked up ahead of the decision.
21 expect(handler.indexOf("rowForKey('Mod+'")).toBeLessThan(decided);
2322 });
2423
2524 test('an open dialog owns its keys', () => {
26 expect(handler).toContain("t.closest('dialog[open]')) return;");
25 expect(handler).toContain("t.closest('dialog[open]')");
2726 });
2827
2928 test('j/k, enter, esc and ? are rows of the one table the sheet renders', () => {
@@ -101,11 +100,11 @@ test('the label of each row names the keys it answers', () => {
101100 }
102101 });
103102
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;'));
103 test('after the decision the handler reads no key literally but Escape; the table answers', () => {
104 const after = handler.slice(handler.indexOf("if (what !== 'key') return;"));
106105 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]')"));
106 // Before it the chords are looked up in the table too, not written out.
107 const before = handler.slice(0, handler.indexOf('decideKey({'));
109108 expect(before).toContain("rowForKey('Mod+'");
110109 expect(before).not.toContain("e.key === 'Enter'");
111110 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs