@remit/web-client 0.0.14 → 0.0.16

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.
@@ -66,13 +66,15 @@ export const ConfirmDialog = ({
66
66
  if (!isOpen) return null;
67
67
 
68
68
  return (
69
- <div
70
- className="fixed inset-0 z-50 flex items-center justify-center"
71
- aria-hidden="true"
72
- onClick={onCancel}
73
- >
74
- {/* Backdrop */}
75
- <div className="absolute inset-0 bg-canvas/80 backdrop-blur-sm" />
69
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
70
+ {/* Backdrop. It carries the click-to-dismiss and the aria-hidden: the
71
+ dialog itself must stay in the accessibility tree, and an
72
+ aria-hidden ancestor would take it out. */}
73
+ <div
74
+ className="absolute inset-0 bg-canvas/80 backdrop-blur-sm"
75
+ aria-hidden="true"
76
+ onClick={onCancel}
77
+ />
76
78
 
77
79
  {/* Dialog */}
78
80
  <div
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
2
2
  import type { TriageAction } from "@/lib/keymap";
3
3
  import {
4
4
  dispatchKey,
5
+ isControlTarget,
5
6
  isEditableTarget,
6
7
  type SequencePrefix,
7
8
  } from "@/lib/keymap-dispatch";
@@ -27,14 +28,17 @@ interface UseTriageKeyboardOptions {
27
28
  * even Esc is left to the focused field's own handler) and carrying the `g …`
28
29
  * go-to sequence prefix across keystrokes with a timeout.
29
30
  *
30
- * Routing is intentionally NOT fully unified yet: this hook owns the action
31
- * verbs + go-to keys, while the list-local navigation keys (j/k/x/Enter/d,
32
- * Shift+arrow, Delete/Backspace) are still routed by MessageList's legacy
33
- * `useKeyboardNavigation`, `/` by SearchBar, and `?` by the mail layout three
34
- * separate window listeners, each with its own (consistent) input-guard. The
35
- * `@/lib/keymap` module is the single source of truth for the DISPLAYED
36
- * bindings (the `?` overlay + tooltips), but not yet for routing. Consolidating
37
- * every binding through `dispatchKey` is a follow-up; see #429.
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.
38
42
  *
39
43
  * Per-action targeting (focused row vs selection) and the actual mutations live
40
44
  * in the handlers the caller passes in — this hook only dispatches.
@@ -70,6 +74,7 @@ export function useTriageKeyboard({
70
74
  ctrlKey: event.ctrlKey,
71
75
  altKey: event.altKey,
72
76
  inEditable: isEditableTarget(event.target),
77
+ onControl: isControlTarget(event.target),
73
78
  },
74
79
  prefixRef.current,
75
80
  );
@@ -12,6 +12,7 @@ const stroke = (partial: Partial<KeyStroke> & { key: string }): KeyStroke => ({
12
12
  ctrlKey: false,
13
13
  altKey: false,
14
14
  inEditable: false,
15
+ onControl: false,
15
16
  ...partial,
16
17
  });
17
18
 
@@ -61,6 +62,82 @@ describe("dispatchKey — plain bindings", () => {
61
62
  });
62
63
  });
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
+
64
141
  describe("dispatchKey — input suppression", () => {
65
142
  test("plain keys are inert in an editable surface", () => {
66
143
  assert.strictEqual(run({ key: "j", inEditable: true }).action, null);
@@ -98,8 +175,8 @@ describe("dispatchKey — modifiers", () => {
98
175
  });
99
176
 
100
177
  test("other meta combos are left to the browser", () => {
101
- assert.strictEqual(run({ key: "a", metaKey: true }).action, null);
102
178
  assert.strictEqual(run({ key: "c", metaKey: true }).action, null);
179
+ assert.strictEqual(run({ key: "f", metaKey: true }).action, null);
103
180
  });
104
181
 
105
182
  test("Shift+J / Shift+K extend the selection", () => {
@@ -19,6 +19,15 @@ export interface KeyStroke {
19
19
  altKey: boolean;
20
20
  /** Whether the event originated inside an editable surface. */
21
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;
22
31
  }
23
32
 
24
33
  /** Pending sequence-prefix state. `"g"` means a `g` was pressed recently. */
@@ -69,7 +78,14 @@ interface PlainBinding {
69
78
  const PLAIN_BINDINGS: Record<string, PlainBinding> = {
70
79
  j: { action: "focusNext" },
71
80
  k: { action: "focusPrevious" },
81
+ arrowdown: { action: "focusNext" },
82
+ arrowup: { action: "focusPrevious" },
83
+ home: { action: "focusFirst" },
84
+ end: { action: "focusLast" },
72
85
  enter: { action: "openFocused" },
86
+ " ": { action: "toggleSelect" },
87
+ delete: { action: "delete" },
88
+ backspace: { action: "delete" },
73
89
  u: { action: "toggleRead" },
74
90
  x: { action: "toggleSelect" },
75
91
  r: { action: "reply" },
@@ -118,12 +134,26 @@ export function dispatchKey(
118
134
  return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
119
135
  }
120
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
+
121
145
  // ⌘N / Ctrl+N → compose. Checked before the prefix/plain tables so the
122
146
  // browser's "new window" is the only thing we intercept among meta combos.
123
147
  if (meta && lower === "n") {
124
148
  return { action: "compose", nextPrefix: null, preventDefault: true };
125
149
  }
126
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
+
127
157
  // Any other meta/ctrl combo is left to the browser/OS.
128
158
  if (meta) return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
129
159
 
@@ -145,13 +175,17 @@ export function dispatchKey(
145
175
  return { action: null, nextPrefix: "g", preventDefault: true };
146
176
  }
147
177
 
148
- // Shift+J / Shift+K extend the selection.
149
- if (stroke.shiftKey && (lower === "j" || lower === "k")) {
150
- return {
151
- action: lower === "j" ? "extendSelectDown" : "extendSelectUp",
152
- nextPrefix: null,
153
- preventDefault: true,
154
- };
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
+ }
155
189
  }
156
190
 
157
191
  // Plain single-key bindings.
@@ -185,3 +219,26 @@ export function isEditableTarget(target: EventTarget | null): boolean {
185
219
  target.isContentEditable
186
220
  );
187
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.ts CHANGED
@@ -13,12 +13,15 @@ export type TriageAction =
13
13
  // list navigation / focus model
14
14
  | "focusNext"
15
15
  | "focusPrevious"
16
+ | "focusFirst"
17
+ | "focusLast"
16
18
  | "openFocused"
17
19
  | "back"
18
20
  // selection
19
21
  | "toggleSelect"
20
22
  | "extendSelectDown"
21
23
  | "extendSelectUp"
24
+ | "selectAll"
22
25
  // message verbs (focused row / selection)
23
26
  | "reply"
24
27
  | "replyAll"
@@ -76,6 +79,14 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
76
79
  keys: ["k"],
77
80
  description: "Focus previous message",
78
81
  },
82
+ { action: "focusNext", keys: ["↓"], description: "Focus next message" },
83
+ {
84
+ action: "focusPrevious",
85
+ keys: ["↑"],
86
+ description: "Focus previous message",
87
+ },
88
+ { action: "focusFirst", keys: ["Home"], description: "Focus first" },
89
+ { action: "focusLast", keys: ["End"], description: "Focus last" },
79
90
  {
80
91
  action: "openFocused",
81
92
  keys: ["Enter"],
@@ -88,6 +99,7 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
88
99
  title: "Selection",
89
100
  hints: [
90
101
  { action: "toggleSelect", keys: ["x"], description: "Toggle select" },
102
+ { action: "toggleSelect", keys: ["Space"], description: "Toggle select" },
91
103
  {
92
104
  action: "extendSelectDown",
93
105
  keys: ["Shift", "j"],
@@ -98,6 +110,17 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
98
110
  keys: ["Shift", "k"],
99
111
  description: "Extend selection up",
100
112
  },
113
+ {
114
+ action: "extendSelectDown",
115
+ keys: ["Shift", "↓"],
116
+ description: "Extend selection down",
117
+ },
118
+ {
119
+ action: "extendSelectUp",
120
+ keys: ["Shift", "↑"],
121
+ description: "Extend selection up",
122
+ },
123
+ { action: "selectAll", keys: ["⌘", "A"], description: "Select all" },
101
124
  ],
102
125
  },
103
126
  {
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { tabStopId } from "./list-focus.ts";
4
+
5
+ const ids = ["a", "b", "c"];
6
+
7
+ describe("tabStopId", () => {
8
+ test("the cursor row holds the tab stop", () => {
9
+ assert.strictEqual(tabStopId(ids, "b"), "b");
10
+ });
11
+
12
+ test("an untouched list puts the tab stop on the first row", () => {
13
+ assert.strictEqual(tabStopId(ids, undefined), "a");
14
+ });
15
+
16
+ test("a cursor that no longer exists falls back to the first row", () => {
17
+ // After a delete or a refetch the cursor can name a row that is gone; the
18
+ // list must keep a tab stop or Tab skips over it entirely.
19
+ assert.strictEqual(tabStopId(ids, "zzz"), "a");
20
+ });
21
+
22
+ test("an empty list has no tab stop", () => {
23
+ assert.strictEqual(tabStopId([], "a"), undefined);
24
+ });
25
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Roving-tabindex math for the message list.
3
+ *
4
+ * A virtualized list of hundreds of rows must expose exactly one tab stop:
5
+ * Tab then moves focus into the list at the keyboard cursor and Shift+Tab moves
6
+ * back out to the side panel, instead of walking every row. Pure so the rule is
7
+ * testable without a DOM.
8
+ */
9
+
10
+ /**
11
+ * The id of the row that holds the list's tab stop. The keyboard cursor owns it
12
+ * while it points at a loaded row; otherwise the first row does, so an untouched
13
+ * list is still reachable with one Tab.
14
+ */
15
+ export function tabStopId(
16
+ orderedIds: string[],
17
+ focusedId: string | undefined,
18
+ ): string | undefined {
19
+ if (orderedIds.length === 0) return undefined;
20
+ if (focusedId !== undefined && orderedIds.includes(focusedId)) {
21
+ return focusedId;
22
+ }
23
+ return orderedIds[0];
24
+ }
@@ -298,7 +298,8 @@ function MailLayout() {
298
298
  />
299
299
  </div>
300
300
  ) : onBriefRoute ? (
301
- // Daily brief (/mail/) — no mailboxId param; 2-pane layout (no intelligence).
301
+ // Daily brief (/mail/) — no mailboxId param; same 3-pane layout as a
302
+ // mailbox: an open message has an intelligence rail here too (#52).
302
303
  <BriefPane selectedMessageId={mobileSelectedMessageId}>
303
304
  {isSinglePane ? (
304
305
  <AppShellSlotted
@@ -316,7 +317,9 @@ function MailLayout() {
316
317
  topBar={topBar}
317
318
  list={<BriefPane.List />}
318
319
  reading={<BriefPane.Reading />}
320
+ intelligence={<BriefPane.Intelligence />}
319
321
  intelligenceOpen={intelligenceOpen}
322
+ hasThread={Boolean(mobileSelectedMessageId)}
320
323
  overlay={overlayContent}
321
324
  skeleton={<AppShellSkeleton />}
322
325
  isLoading={isLoading || hasNoAccounts}
@@ -326,7 +329,7 @@ function MailLayout() {
326
329
  </BriefPane>
327
330
  ) : onFlaggedRoute ? (
328
331
  // Flagged virtual mailbox (/mail/flagged) — flat starred list across
329
- // accounts; 2-pane layout (no intelligence), like the brief.
332
+ // accounts; same slots as the brief, intelligence rail included.
330
333
  <FlaggedPane selectedMessageId={mobileSelectedMessageId}>
331
334
  {isSinglePane ? (
332
335
  <AppShellSlotted
@@ -344,7 +347,9 @@ function MailLayout() {
344
347
  topBar={topBar}
345
348
  list={<FlaggedPane.List />}
346
349
  reading={<FlaggedPane.Reading />}
350
+ intelligence={<FlaggedPane.Intelligence />}
347
351
  intelligenceOpen={intelligenceOpen}
352
+ hasThread={Boolean(mobileSelectedMessageId)}
348
353
  overlay={overlayContent}
349
354
  skeleton={<AppShellSkeleton />}
350
355
  isLoading={isLoading || hasNoAccounts}
@@ -376,6 +381,7 @@ function MailLayout() {
376
381
  reading={<MailboxPane.Reading />}
377
382
  intelligence={<MailboxPane.Intelligence />}
378
383
  intelligenceOpen={intelligenceOpen}
384
+ hasThread={Boolean(mobileSelectedMessageId)}
379
385
  overlay={overlayContent}
380
386
  skeleton={<AppShellSkeleton />}
381
387
  isLoading={isLoading || hasNoAccounts}