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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11318-small-the-feed-moves-by-keyboard-j-and-k → main 3 commits fork 3 threads · 3 unresolved
4 files
changed +139
−29
src/web/client.ts +14 −14
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { GO_MATCH_JS, goPagesData } from "./go_pages.ts"; |
| 14 | import { keyDecisionJs } from "./key_decision.ts"; | |
| 14 | 15 | import { actNowJs } from "./act_now.ts"; |
| 15 | 16 | import { pickJs } from "./pick.ts"; |
| 16 | 17 | import { imageViewJs } from "./render/image.ts"; |
| @@ -1459,6 +1460,7 @@ const shellCore = /* js */ ` | ||
| 1459 | 1460 | }); |
| 1460 | 1461 | |
| 1461 | 1462 | // ---- keyboard: sequences from the DOM, so the help can never drift ---- |
| 1463 | ${keyDecisionJs} | |
| 1462 | 1464 | var seq = []; var last = 0; |
| 1463 | 1465 | function rows() { return Array.prototype.slice.call(doc.querySelectorAll('[data-nav]')); } |
| 1464 | 1466 | function move(step) { |
| @@ -1550,20 +1552,18 @@ const shellCore = /* js */ ` | ||
| 1550 | 1552 | window.addEventListener('blur', function () { hints(false); }); |
| 1551 | 1553 | doc.addEventListener('keydown', function (e) { |
| 1552 | 1554 | 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; | |
| 1567 | 1567 | var row = rowForKey(e.key); |
| 1568 | 1568 | if (e.key === 'Escape') { if (row) row[3](e); return; } |
| 1569 | 1569 | 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"; | |
| 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 | ||
| 10 | 10 | const handler = src.slice(handlerStart, helpStart); |
| 11 | 11 | const help = src.slice(helpStart, src.indexOf('// ---- command palette')); |
| 12 | 12 | |
| 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); | |
| 23 | 22 | }); |
| 24 | 23 | |
| 25 | 24 | test('an open dialog owns its keys', () => { |
| 26 | expect(handler).toContain("t.closest('dialog[open]')) return;"); | |
| 25 | expect(handler).toContain("t.closest('dialog[open]')"); | |
| 27 | 26 | }); |
| 28 | 27 | |
| 29 | 28 | 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', () => { | ||
| 101 | 100 | } |
| 102 | 101 | }); |
| 103 | 102 | |
| 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;")); | |
| 106 | 105 | 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({')); | |
| 109 | 108 | expect(before).toContain("rowForKey('Mod+'"); |
| 110 | 109 | expect(before).not.toContain("e.key === 'Enter'"); |
| 111 | 110 | }); |
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.