@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.
- package/package.json +1 -1
- package/src/components/layout/MailTopBar.tsx +1 -2
- package/src/components/mail/FlaggedList.tsx +0 -1
- package/src/components/mail/MessageList.tsx +0 -1
- package/src/components/mail/MessageToolbar.tsx +1 -1
- package/src/components/mail/ThreadListInteraction.tsx +1 -2
- package/src/components/ui/KeyboardShortcutsModal.tsx +2 -3
- package/src/hooks/useTriageLayer.ts +1 -4
- package/src/hooks/useListCursor.test.ts +0 -273
- package/src/hooks/useListCursor.ts +0 -259
- package/src/hooks/useTriageKeyboard.ts +0 -108
- package/src/lib/keymap-dispatch.test.ts +0 -255
- package/src/lib/keymap-dispatch.ts +0 -244
- package/src/lib/keymap.test.ts +0 -68
- package/src/lib/keymap.ts +0 -210
|
@@ -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
|
-
}
|
package/src/lib/keymap.test.ts
DELETED
|
@@ -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
|
-
});
|