@remit/web-client 0.0.115 → 0.0.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,108 +0,0 @@
1
- import { useEffect, useRef } from "react";
2
- import type { TriageAction } from "@/lib/keymap";
3
- import {
4
- dispatchKey,
5
- isControlTarget,
6
- isEditableTarget,
7
- type SequencePrefix,
8
- } from "@/lib/keymap-dispatch";
9
-
10
- /** Map of action → handler. Omitted actions are inert (no-op). */
11
- export type TriageHandlers = Partial<Record<TriageAction, () => void>>;
12
-
13
- interface UseTriageKeyboardOptions {
14
- handlers: TriageHandlers;
15
- /** Disable the whole layer (e.g. a blocking modal owns the keyboard). */
16
- enabled?: boolean;
17
- /**
18
- * Reset window (ms) for a pending `g …` sequence prefix. After this with no
19
- * second key, the prefix is dropped. ~1s per the spec.
20
- */
21
- sequenceTimeoutMs?: number;
22
- }
23
-
24
- /**
25
- * Global keydown handler for the triage layer's VERBS (#429). Routes keystrokes
26
- * through the pure {@link dispatchKey} core to the supplied handler table,
27
- * staying fully inert while focus is in an editable surface (input/textarea/CE;
28
- * even Esc is left to the focused field's own handler) and carrying the `g …`
29
- * go-to sequence prefix across keystrokes with a timeout.
30
- *
31
- * List navigation and selection route through here and nowhere else: the
32
- * message list publishes its commands upward (see `MessageListCommands`) and
33
- * the route wires them into the handler table, so `@/lib/keymap` is the source
34
- * of truth for both the displayed bindings and the routed ones. The list used
35
- * to run a second window listener claiming the same keys, which is what made
36
- * Enter unusable on every focused button in the app (#43).
37
- *
38
- * Other window-level keydown listeners still exist for keys this layer does not
39
- * own — `?` at the mail layout, `/` in SearchBar, Esc in the compose and
40
- * conversation views. They bind disjoint keys; only the list's competing
41
- * listener was removed.
42
- *
43
- * Per-action targeting (focused row vs selection) and the actual mutations live
44
- * in the handlers the caller passes in — this hook only dispatches.
45
- */
46
- export function useTriageKeyboard({
47
- handlers,
48
- enabled = true,
49
- sequenceTimeoutMs = 1000,
50
- }: UseTriageKeyboardOptions): void {
51
- // Latest handlers without re-subscribing the listener every render.
52
- const handlersRef = useRef(handlers);
53
- handlersRef.current = handlers;
54
-
55
- const prefixRef = useRef<SequencePrefix>(null);
56
- const prefixTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
57
-
58
- useEffect(() => {
59
- if (!enabled) return;
60
-
61
- const clearPrefixTimer = () => {
62
- if (prefixTimerRef.current !== null) {
63
- clearTimeout(prefixTimerRef.current);
64
- prefixTimerRef.current = null;
65
- }
66
- };
67
-
68
- const onKeyDown = (event: KeyboardEvent) => {
69
- const result = dispatchKey(
70
- {
71
- key: event.key,
72
- shiftKey: event.shiftKey,
73
- metaKey: event.metaKey,
74
- ctrlKey: event.ctrlKey,
75
- altKey: event.altKey,
76
- inEditable: isEditableTarget(event.target),
77
- onControl: isControlTarget(event.target),
78
- },
79
- prefixRef.current,
80
- );
81
-
82
- // Update the pending prefix and (re)arm / clear its reset timer.
83
- clearPrefixTimer();
84
- prefixRef.current = result.nextPrefix;
85
- if (result.nextPrefix !== null) {
86
- prefixTimerRef.current = setTimeout(() => {
87
- prefixRef.current = null;
88
- prefixTimerRef.current = null;
89
- }, sequenceTimeoutMs);
90
- }
91
-
92
- if (result.action === null) return;
93
-
94
- const handler = handlersRef.current[result.action];
95
- if (!handler) return;
96
-
97
- if (result.preventDefault) event.preventDefault();
98
- handler();
99
- };
100
-
101
- window.addEventListener("keydown", onKeyDown);
102
- return () => {
103
- window.removeEventListener("keydown", onKeyDown);
104
- clearPrefixTimer();
105
- prefixRef.current = null;
106
- };
107
- }, [enabled, sequenceTimeoutMs]);
108
- }
@@ -1,255 +0,0 @@
1
- import assert from "node:assert";
2
- import { describe, test } from "node:test";
3
- import {
4
- dispatchKey,
5
- type KeyStroke,
6
- type SequencePrefix,
7
- } from "./keymap-dispatch.ts";
8
-
9
- const stroke = (partial: Partial<KeyStroke> & { key: string }): KeyStroke => ({
10
- shiftKey: false,
11
- metaKey: false,
12
- ctrlKey: false,
13
- altKey: false,
14
- inEditable: false,
15
- onControl: false,
16
- ...partial,
17
- });
18
-
19
- const run = (
20
- s: Partial<KeyStroke> & { key: string },
21
- prefix: SequencePrefix = null,
22
- ) => dispatchKey(stroke(s), prefix);
23
-
24
- describe("dispatchKey — plain bindings", () => {
25
- const cases: Array<[string, string]> = [
26
- ["j", "focusNext"],
27
- ["k", "focusPrevious"],
28
- ["Enter", "openFocused"],
29
- ["u", "toggleRead"],
30
- ["x", "toggleSelect"],
31
- ["r", "reply"],
32
- ["a", "replyAll"],
33
- ["f", "forward"],
34
- ["#", "delete"],
35
- ["s", "toggleStar"],
36
- ["m", "muteSender"],
37
- ["b", "blockSender"],
38
- ["v", "vipSender"],
39
- ["!", "markJunk"],
40
- ["i", "toggleIntelligence"],
41
- ["d", "toggleDensity"],
42
- ["/", "focusSearch"],
43
- ["c", "compose"],
44
- ["?", "help"],
45
- ];
46
-
47
- for (const [key, action] of cases) {
48
- test(`'${key}' → ${action}`, () => {
49
- const result = run({ key });
50
- assert.strictEqual(result.action, action);
51
- assert.strictEqual(result.nextPrefix, null);
52
- });
53
- }
54
-
55
- test("uppercase letters match case-insensitively", () => {
56
- assert.strictEqual(run({ key: "R", shiftKey: true }).action, null);
57
- assert.strictEqual(run({ key: "R" }).action, "reply");
58
- });
59
-
60
- test("unknown key maps to nothing", () => {
61
- assert.strictEqual(run({ key: "q" }).action, null);
62
- });
63
- });
64
-
65
- describe("dispatchKey — list navigation", () => {
66
- const cases: Array<[string, string]> = [
67
- ["ArrowDown", "focusNext"],
68
- ["ArrowUp", "focusPrevious"],
69
- ["Home", "focusFirst"],
70
- ["End", "focusLast"],
71
- [" ", "toggleSelect"],
72
- ["Delete", "delete"],
73
- ["Backspace", "delete"],
74
- ];
75
-
76
- for (const [key, action] of cases) {
77
- test(`'${key}' → ${action}`, () => {
78
- const result = run({ key });
79
- assert.strictEqual(result.action, action);
80
- assert.strictEqual(result.preventDefault, true);
81
- });
82
- }
83
-
84
- test("Shift+Arrow extends the selection instead of moving the cursor", () => {
85
- assert.strictEqual(
86
- run({ key: "ArrowDown", shiftKey: true }).action,
87
- "extendSelectDown",
88
- );
89
- assert.strictEqual(
90
- run({ key: "ArrowUp", shiftKey: true }).action,
91
- "extendSelectUp",
92
- );
93
- });
94
-
95
- test("⌘A / Ctrl+A selects every loaded row", () => {
96
- assert.strictEqual(run({ key: "a", metaKey: true }).action, "selectAll");
97
- assert.strictEqual(run({ key: "a", ctrlKey: true }).action, "selectAll");
98
- assert.strictEqual(
99
- run({ key: "a", metaKey: true }).preventDefault,
100
- true,
101
- "the browser's select-all-text default must be replaced",
102
- );
103
- });
104
- });
105
-
106
- describe("dispatchKey — controls keep their activation keys", () => {
107
- // The regression behind #43: a global Enter binding cancelled the default
108
- // action of whatever the user had tabbed to, so no button in the app could
109
- // be activated from the keyboard.
110
- test("Enter on a focused control is left to the control", () => {
111
- const result = run({ key: "Enter", onControl: true });
112
- assert.strictEqual(result.action, null);
113
- assert.strictEqual(result.preventDefault, false);
114
- });
115
-
116
- test("Space on a focused control is left to the control", () => {
117
- const result = run({ key: " ", onControl: true });
118
- assert.strictEqual(result.action, null);
119
- assert.strictEqual(result.preventDefault, false);
120
- });
121
-
122
- test("Enter and Space still drive the list away from a control", () => {
123
- assert.strictEqual(run({ key: "Enter" }).action, "openFocused");
124
- assert.strictEqual(run({ key: " " }).action, "toggleSelect");
125
- });
126
-
127
- test("non-activation keys still fire from a focused control", () => {
128
- // Tabbing to a toolbar button must not strand the rest of the keymap.
129
- assert.strictEqual(run({ key: "j", onControl: true }).action, "focusNext");
130
- assert.strictEqual(run({ key: "r", onControl: true }).action, "reply");
131
- });
132
-
133
- test("an Enter released to a control still cancels a pending g prefix", () => {
134
- assert.strictEqual(
135
- run({ key: "Enter", onControl: true }, "g").nextPrefix,
136
- null,
137
- );
138
- });
139
- });
140
-
141
- describe("dispatchKey — input suppression", () => {
142
- test("plain keys are inert in an editable surface", () => {
143
- assert.strictEqual(run({ key: "j", inEditable: true }).action, null);
144
- assert.strictEqual(run({ key: "e", inEditable: true }).action, null);
145
- assert.strictEqual(run({ key: "c", inEditable: true }).action, null);
146
- });
147
-
148
- test("Esc is inert in an editable surface (field owns its own Esc)", () => {
149
- // The layer must NOT emit `back` for Esc-while-typing: that would
150
- // double-fire with SearchBar's own Esc (clear query AND close thread).
151
- const result = run({ key: "Escape", inEditable: true });
152
- assert.strictEqual(result.action, null);
153
- assert.strictEqual(result.preventDefault, false);
154
- });
155
-
156
- test("Esc in an input clears a pending g prefix", () => {
157
- const result = run({ key: "Escape", inEditable: true }, "g");
158
- assert.strictEqual(result.action, null);
159
- assert.strictEqual(result.nextPrefix, null);
160
- });
161
-
162
- test("any key in an editable surface clears a pending g prefix", () => {
163
- // You can't be mid-`g`-sequence while typing in a field; entering an
164
- // editable surface drops any stale prefix so it can't leak back out.
165
- const result = run({ key: "x", inEditable: true }, "g");
166
- assert.strictEqual(result.action, null);
167
- assert.strictEqual(result.nextPrefix, null);
168
- });
169
- });
170
-
171
- describe("dispatchKey — modifiers", () => {
172
- test("⌘N / Ctrl+N → compose", () => {
173
- assert.strictEqual(run({ key: "n", metaKey: true }).action, "compose");
174
- assert.strictEqual(run({ key: "n", ctrlKey: true }).action, "compose");
175
- });
176
-
177
- test("other meta combos are left to the browser", () => {
178
- assert.strictEqual(run({ key: "c", metaKey: true }).action, null);
179
- assert.strictEqual(run({ key: "f", metaKey: true }).action, null);
180
- });
181
-
182
- test("Shift+J / Shift+K extend the selection", () => {
183
- assert.strictEqual(
184
- run({ key: "j", shiftKey: true }).action,
185
- "extendSelectDown",
186
- );
187
- assert.strictEqual(
188
- run({ key: "k", shiftKey: true }).action,
189
- "extendSelectUp",
190
- );
191
- });
192
-
193
- test("Shift on a letter verb suppresses it (no Shift+R reply)", () => {
194
- assert.strictEqual(run({ key: "r", shiftKey: true }).action, null);
195
- assert.strictEqual(run({ key: "e", shiftKey: true }).action, null);
196
- });
197
-
198
- test("Shift on a shifted-punctuation binding still fires (#, !, ?)", () => {
199
- assert.strictEqual(run({ key: "#", shiftKey: true }).action, "delete");
200
- assert.strictEqual(run({ key: "!", shiftKey: true }).action, "markJunk");
201
- assert.strictEqual(run({ key: "?", shiftKey: true }).action, "help");
202
- });
203
- });
204
-
205
- describe("dispatchKey — g … sequences", () => {
206
- test("g arms the prefix without an action", () => {
207
- const result = run({ key: "g" });
208
- assert.strictEqual(result.action, null);
209
- assert.strictEqual(result.nextPrefix, "g");
210
- assert.strictEqual(result.preventDefault, true);
211
- });
212
-
213
- const seq: Array<[string, string]> = [
214
- ["b", "goBrief"],
215
- ["i", "goInbox"],
216
- ["s", "goSent"],
217
- ["f", "goFlagged"],
218
- [",", "goSettings"],
219
- ];
220
- for (const [key, action] of seq) {
221
- test(`g then '${key}' → ${action}`, () => {
222
- const result = run({ key }, "g");
223
- assert.strictEqual(result.action, action);
224
- assert.strictEqual(result.nextPrefix, null);
225
- });
226
- }
227
-
228
- test("g then an unmapped key cancels the prefix and is inert", () => {
229
- const result = run({ key: "q" }, "g");
230
- assert.strictEqual(result.action, null);
231
- assert.strictEqual(result.nextPrefix, null);
232
- });
233
-
234
- test("Shift+G does not arm the prefix", () => {
235
- assert.strictEqual(run({ key: "g", shiftKey: true }).nextPrefix, null);
236
- });
237
-
238
- test("after a sequence resolves, the next key is a plain binding again", () => {
239
- const first = run({ key: "g" });
240
- assert.strictEqual(first.nextPrefix, "g");
241
- const second = run({ key: "j" }, first.nextPrefix);
242
- // 'j' is not a go-to key → cancels, inert.
243
- assert.strictEqual(second.action, null);
244
- const third = run({ key: "j" }, second.nextPrefix);
245
- assert.strictEqual(third.action, "focusNext");
246
- });
247
- });
248
-
249
- describe("dispatchKey — escape", () => {
250
- test("Esc maps to back and clears any prefix", () => {
251
- const result = run({ key: "Escape" }, "g");
252
- assert.strictEqual(result.action, "back");
253
- assert.strictEqual(result.nextPrefix, null);
254
- });
255
- });
@@ -1,244 +0,0 @@
1
- import type { TriageAction } from "./keymap.js";
2
-
3
- /**
4
- * Pure dispatch core for the global triage keyboard layer (#429).
5
- *
6
- * The React hook (`useTriageKeyboard`) owns DOM wiring and the handler table;
7
- * this module owns the *decision* — given a normalized keystroke and the
8
- * current sequence-prefix state, which {@link TriageAction} (if any) fires, and
9
- * what the next sequence-prefix state is. Keeping it pure makes the routing,
10
- * input suppression and `g`-prefix sequencing unit-testable without JSDOM.
11
- */
12
-
13
- /** The subset of a KeyboardEvent the dispatcher reads. */
14
- export interface KeyStroke {
15
- key: string;
16
- shiftKey: boolean;
17
- metaKey: boolean;
18
- ctrlKey: boolean;
19
- altKey: boolean;
20
- /** Whether the event originated inside an editable surface. */
21
- inEditable: boolean;
22
- /**
23
- * Whether the event originated on an activatable control that is not a
24
- * message-list row — a button, link, `role="button"`, `<summary>`. Enter and
25
- * Space are that control's activation keys, so the layer releases them; every
26
- * other binding still fires. Without this a global Enter binding cancels the
27
- * default action of whatever the user tabbed to, and no button in the app can
28
- * be activated from the keyboard.
29
- */
30
- onControl: boolean;
31
- }
32
-
33
- /** Pending sequence-prefix state. `"g"` means a `g` was pressed recently. */
34
- export type SequencePrefix = null | "g";
35
-
36
- export interface DispatchResult {
37
- /** The action to run, or null when the stroke maps to nothing actionable. */
38
- action: TriageAction | null;
39
- /** The sequence-prefix state to carry into the next stroke. */
40
- nextPrefix: SequencePrefix;
41
- /** Whether the host should preventDefault on this stroke. */
42
- preventDefault: boolean;
43
- }
44
-
45
- const NONE: DispatchResult = {
46
- action: null,
47
- nextPrefix: null,
48
- preventDefault: false,
49
- };
50
-
51
- /**
52
- * Second key of a `g …` go-to sequence → action. Keys are lowercased; `,`
53
- * stays as-is.
54
- */
55
- const GO_TO_SEQUENCE: Record<string, TriageAction> = {
56
- b: "goBrief",
57
- i: "goInbox",
58
- s: "goSent",
59
- f: "goFlagged",
60
- ",": "goSettings",
61
- };
62
-
63
- /**
64
- * Plain single-key bindings (no meta, no `g` prefix). `shift` here means the
65
- * binding *requires* shift; absence means shift must be absent.
66
- */
67
- interface PlainBinding {
68
- action: TriageAction;
69
- requireShift?: boolean;
70
- }
71
-
72
- /**
73
- * Lowercased key → binding. `event.key` is lowercased before lookup, so the
74
- * shifted variants (`#`, `!`, `?`) are matched by their produced character, and
75
- * letter keys ignore caps. Shift-j/k extend selection and are handled before
76
- * this table.
77
- */
78
- const PLAIN_BINDINGS: Record<string, PlainBinding> = {
79
- j: { action: "focusNext" },
80
- k: { action: "focusPrevious" },
81
- arrowdown: { action: "focusNext" },
82
- arrowup: { action: "focusPrevious" },
83
- home: { action: "focusFirst" },
84
- end: { action: "focusLast" },
85
- enter: { action: "openFocused" },
86
- " ": { action: "toggleSelect" },
87
- delete: { action: "delete" },
88
- backspace: { action: "delete" },
89
- u: { action: "toggleRead" },
90
- x: { action: "toggleSelect" },
91
- r: { action: "reply" },
92
- a: { action: "replyAll" },
93
- f: { action: "forward" },
94
- "#": { action: "delete" },
95
- s: { action: "toggleStar" },
96
- m: { action: "muteSender" },
97
- b: { action: "blockSender" },
98
- v: { action: "vipSender" },
99
- "!": { action: "markJunk" },
100
- i: { action: "toggleIntelligence" },
101
- d: { action: "toggleDensity" },
102
- "/": { action: "focusSearch" },
103
- c: { action: "compose" },
104
- "?": { action: "help" },
105
- };
106
-
107
- /**
108
- * Resolve a keystroke into an action and the next sequence-prefix state. Pure:
109
- * no DOM, no side effects.
110
- *
111
- * Rules:
112
- * - In an editable surface only `Esc` fires (clears any pending prefix).
113
- * - `⌘N` / `Ctrl+N` → compose (the only meta combo we own).
114
- * - A pending `g` prefix consumes the next key as a go-to sequence; an
115
- * unmatched second key cancels the prefix and is otherwise inert.
116
- * - `g` (no modifiers) arms the prefix.
117
- * - `Shift+J` / `Shift+K` extend the selection.
118
- * - Otherwise a plain single-key binding fires.
119
- * - Any non-Esc keystroke clears a stale prefix.
120
- */
121
- export function dispatchKey(
122
- stroke: KeyStroke,
123
- prefix: SequencePrefix,
124
- ): DispatchResult {
125
- const lower = stroke.key.toLowerCase();
126
- const meta = stroke.metaKey || stroke.ctrlKey;
127
-
128
- // Editable surfaces: the layer is fully inert. Even Esc is left to the
129
- // focused field's own handler (SearchBar clears the query / blurs on Esc);
130
- // emitting `back` here would double-fire — clearing search AND closing the
131
- // open thread on one keypress. We still clear any pending `g` prefix so a
132
- // stray sequence can't leak across a focus change into the field.
133
- if (stroke.inEditable) {
134
- return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
135
- }
136
-
137
- // Enter / Space belong to whatever control has focus. Releasing them here is
138
- // what keeps Tab-to-a-button-then-Enter working anywhere in the app; the
139
- // list's own rows are excluded from `onControl`, so the roving cursor still
140
- // opens and selects.
141
- if (stroke.onControl && (lower === "enter" || lower === " ")) {
142
- return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
143
- }
144
-
145
- // ⌘N / Ctrl+N → compose. Checked before the prefix/plain tables so the
146
- // browser's "new window" is the only thing we intercept among meta combos.
147
- if (meta && lower === "n") {
148
- return { action: "compose", nextPrefix: null, preventDefault: true };
149
- }
150
-
151
- // ⌘A / Ctrl+A → select every loaded row, replacing the browser's
152
- // select-all-text default.
153
- if (meta && lower === "a") {
154
- return { action: "selectAll", nextPrefix: null, preventDefault: true };
155
- }
156
-
157
- // Any other meta/ctrl combo is left to the browser/OS.
158
- if (meta) return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
159
-
160
- // Esc: back/close. Always clears the prefix.
161
- if (lower === "escape") {
162
- return { action: "back", nextPrefix: null, preventDefault: false };
163
- }
164
-
165
- // Resolve a pending `g …` sequence.
166
- if (prefix === "g") {
167
- const seqKey = lower === "," ? "," : lower;
168
- const action = GO_TO_SEQUENCE[seqKey] ?? null;
169
- // Consume the second key whether or not it matched; the prefix resets.
170
- return { action, nextPrefix: null, preventDefault: action !== null };
171
- }
172
-
173
- // Arm the `g` prefix (no shift, no modifiers).
174
- if (lower === "g" && !stroke.shiftKey) {
175
- return { action: null, nextPrefix: "g", preventDefault: true };
176
- }
177
-
178
- // Shift+J/K and Shift+Arrow extend the selection.
179
- if (stroke.shiftKey) {
180
- const down = lower === "j" || lower === "arrowdown";
181
- const up = lower === "k" || lower === "arrowup";
182
- if (down || up) {
183
- return {
184
- action: down ? "extendSelectDown" : "extendSelectUp",
185
- nextPrefix: null,
186
- preventDefault: true,
187
- };
188
- }
189
- }
190
-
191
- // Plain single-key bindings.
192
- const binding = PLAIN_BINDINGS[lower];
193
- if (binding) {
194
- const needsShift = binding.requireShift === true;
195
- // `?`, `#`, `!` are produced with Shift on most layouts; their entries
196
- // are keyed by the produced character so we don't gate on shift here.
197
- // Plain letter bindings must NOT fire when shift is held (e.g. Shift+R).
198
- const isPunctuation = lower.length === 1 && !/[a-z0-9]/.test(lower);
199
- if (!needsShift && stroke.shiftKey && !isPunctuation) {
200
- return NONE;
201
- }
202
- return { action: binding.action, nextPrefix: null, preventDefault: true };
203
- }
204
-
205
- return NONE;
206
- }
207
-
208
- /**
209
- * Whether a DOM event target is an editable surface (input/textarea/select/
210
- * contenteditable). Exposed so the hook and tests share one definition.
211
- */
212
- export function isEditableTarget(target: EventTarget | null): boolean {
213
- if (!(target instanceof HTMLElement)) return false;
214
- const tag = target.tagName;
215
- return (
216
- tag === "INPUT" ||
217
- tag === "TEXTAREA" ||
218
- tag === "SELECT" ||
219
- target.isContentEditable
220
- );
221
- }
222
-
223
- /**
224
- * Marks an element as a message-list row. Rows are anchors, so they would
225
- * otherwise read as ordinary controls; the list owns Enter/Space on them.
226
- */
227
- export const ROW_ATTRIBUTE = "data-message-row";
228
-
229
- const CONTROL_SELECTOR =
230
- 'button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"], [role="switch"], [role="checkbox"]';
231
-
232
- /**
233
- * Whether the event target sits inside an activatable control that is not a
234
- * message-list row. See {@link KeyStroke.onControl}.
235
- */
236
- export function isControlTarget(target: EventTarget | null): boolean {
237
- if (!(target instanceof HTMLElement)) return false;
238
- const control = target.closest(CONTROL_SELECTOR);
239
- if (!control) return false;
240
- // The nearest control wins. A row is an anchor and so matches the selector,
241
- // but the list owns Enter/Space on its rows; a control nested *inside* a row
242
- // (the select checkbox) is a control like any other and keeps its own keys.
243
- return !control.hasAttribute(ROW_ATTRIBUTE);
244
- }
@@ -1,68 +0,0 @@
1
- import assert from "node:assert";
2
- import { describe, test } from "node:test";
3
- import {
4
- KEY_HINT_GROUPS,
5
- keysForAction,
6
- shortcutHintForAction,
7
- tooltipForAction,
8
- } from "./keymap.ts";
9
-
10
- describe("keymap module", () => {
11
- test("exposes the documented groups in reading order", () => {
12
- const titles = KEY_HINT_GROUPS.map((g) => g.title);
13
- assert.deepStrictEqual(titles, [
14
- "Navigation",
15
- "Selection",
16
- "Actions",
17
- "Sender",
18
- "Go to",
19
- "View & global",
20
- ]);
21
- });
22
-
23
- test("keysForAction returns the first hint's tokens", () => {
24
- assert.deepStrictEqual(keysForAction("reply"), ["r"]);
25
- assert.deepStrictEqual(keysForAction("goBrief"), ["g", "b"]);
26
- });
27
-
28
- test("keysForAction is undefined for an action with no hint", () => {
29
- // `back` has a hint; a contrived missing lookup returns undefined.
30
- assert.strictEqual(
31
- keysForAction("totallyMissing" as Parameters<typeof keysForAction>[0]),
32
- undefined,
33
- );
34
- });
35
-
36
- test("tooltipForAction renders single keys, sequences and combos", () => {
37
- assert.strictEqual(tooltipForAction("reply"), "(r)");
38
- assert.strictEqual(tooltipForAction("goBrief"), "(g then b)");
39
- // compose's first hint is the single 'c' key.
40
- assert.strictEqual(tooltipForAction("compose"), "(c)");
41
- });
42
-
43
- test("shortcutHintForAction renders the binding without the parens", () => {
44
- assert.strictEqual(shortcutHintForAction("reply"), "r");
45
- assert.strictEqual(shortcutHintForAction("goBrief"), "g then b");
46
- assert.strictEqual(shortcutHintForAction("compose"), "c");
47
- });
48
-
49
- test("an action with no binding gets no hint and no empty parens", () => {
50
- const unbound = "totallyMissing" as Parameters<
51
- typeof shortcutHintForAction
52
- >[0];
53
- assert.strictEqual(shortcutHintForAction(unbound), "");
54
- assert.strictEqual(tooltipForAction(unbound), "");
55
- });
56
-
57
- test("every hint's action is a non-empty key list", () => {
58
- for (const group of KEY_HINT_GROUPS) {
59
- for (const hint of group.hints) {
60
- assert.ok(hint.keys.length > 0, `${hint.action} has keys`);
61
- assert.ok(
62
- hint.description.length > 0,
63
- `${hint.action} has a description`,
64
- );
65
- }
66
- }
67
- });
68
- });