@remit/web-client 0.0.222 → 0.0.223

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.222",
3
+ "version": "0.0.223",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { ConfirmDialog } from "@remit/ui";
2
+ import { ConfirmDialog, useOverlayScope } from "@remit/ui";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
  import { useCallback, useRef, useState } from "react";
5
5
  import { Drawer } from "vaul";
@@ -75,6 +75,10 @@ export const MobileComposeSheet = () => {
75
75
  setShowConfirm(false);
76
76
  }, []);
77
77
 
78
+ // vaul answers Escape itself; declaring the scope is what keeps the rest of
79
+ // the keyboard off the list behind the sheet.
80
+ useOverlayScope({ id: "mobile-compose", open: true });
81
+
78
82
  return (
79
83
  <Drawer.Root
80
84
  open
@@ -1,4 +1,4 @@
1
- import { cn } from "@remit/ui";
1
+ import { cn, type OverlayAnswers, useOverlayScope } from "@remit/ui";
2
2
  import { X } from "lucide-react";
3
3
  import { type ReactNode, useEffect, useRef } from "react";
4
4
 
@@ -40,6 +40,13 @@ interface DrawerProps {
40
40
  ariaLabel?: string;
41
41
  side?: "left" | "right";
42
42
  widthClassName?: string;
43
+ /**
44
+ * Shortcuts the drawer keeps serving while it is up, beyond Escape. The key
45
+ * that opened it is the one that has to reach it — `i` closes the
46
+ * intelligence drawer it opened — and everything left undeclared is
47
+ * contained, so no verb acts on the list behind the scrim.
48
+ */
49
+ answers?: OverlayAnswers;
43
50
  }
44
51
 
45
52
  /**
@@ -58,21 +65,23 @@ export const Drawer = ({
58
65
  ariaLabel = "Navigation",
59
66
  side = "left",
60
67
  widthClassName = "w-[80vw] max-w-[320px]",
68
+ answers,
61
69
  }: DrawerProps) => {
62
70
  const drawerRef = useRef<HTMLDivElement>(null);
63
71
  const previouslyFocusedRef = useRef<HTMLElement | null>(null);
64
72
 
73
+ useOverlayScope({
74
+ id: "drawer",
75
+ open: isOpen,
76
+ answers: { back: onClose, ...answers },
77
+ });
78
+
65
79
  useEffect(() => {
66
80
  if (!isOpen) return;
67
81
 
68
82
  previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
69
83
 
70
84
  const handleKey = (event: KeyboardEvent) => {
71
- if (event.key === "Escape") {
72
- event.preventDefault();
73
- onClose();
74
- return;
75
- }
76
85
  // Trap Tab inside the drawer's ring: walking off either end cycles
77
86
  // round, and focus that is in the drawer but outside the ring — a
78
87
  // control behind a nested dialog's backdrop — is pulled in, so Tab
@@ -115,7 +124,7 @@ export const Drawer = ({
115
124
  document.body.style.overflow = previousOverflow;
116
125
  previouslyFocusedRef.current?.focus();
117
126
  };
118
- }, [isOpen, onClose]);
127
+ }, [isOpen]);
119
128
 
120
129
  if (!isOpen) return null;
121
130
 
@@ -292,25 +292,62 @@ export const ConversationView = ({
292
292
  ...(listOnScreen
293
293
  ? []
294
294
  : [
295
- { key: "j", handler: focusNext, preventDefault: true },
296
- { key: "ArrowDown", handler: focusNext, preventDefault: true },
297
- { key: "k", handler: focusPrevious, preventDefault: true },
298
- { key: "ArrowUp", handler: focusPrevious, preventDefault: true },
295
+ {
296
+ key: "j",
297
+ action: "focusNext" as const,
298
+ handler: focusNext,
299
+ preventDefault: true,
300
+ },
301
+ {
302
+ key: "ArrowDown",
303
+ action: "focusNext" as const,
304
+ handler: focusNext,
305
+ preventDefault: true,
306
+ },
307
+ {
308
+ key: "k",
309
+ action: "focusPrevious" as const,
310
+ handler: focusPrevious,
311
+ preventDefault: true,
312
+ },
313
+ {
314
+ key: "ArrowUp",
315
+ action: "focusPrevious" as const,
316
+ handler: focusPrevious,
317
+ preventDefault: true,
318
+ },
299
319
  {
300
320
  key: "Enter",
321
+ action: "openFocused" as const,
322
+ handler: toggleFocusedMessage,
323
+ preventDefault: true,
324
+ },
325
+ {
326
+ key: "o",
327
+ action: "openFocused" as const,
301
328
  handler: toggleFocusedMessage,
302
329
  preventDefault: true,
303
330
  },
304
- { key: "o", handler: toggleFocusedMessage, preventDefault: true },
305
331
  ]),
306
- { key: "r", handler: handleReply, preventDefault: true },
332
+ {
333
+ key: "r",
334
+ action: "reply" as const,
335
+ handler: handleReply,
336
+ preventDefault: true,
337
+ },
307
338
  {
308
339
  key: "R",
340
+ action: "replyAll" as const,
309
341
  handler: handleReplyAll,
310
342
  noModifiers: false,
311
343
  preventDefault: true,
312
344
  },
313
- { key: "f", handler: handleForward, preventDefault: true },
345
+ {
346
+ key: "f",
347
+ action: "forward" as const,
348
+ handler: handleForward,
349
+ preventDefault: true,
350
+ },
314
351
  ],
315
352
  });
316
353
 
@@ -873,7 +873,6 @@ export function DailyBrief({
873
873
  onOpen={openRow}
874
874
  onDeleteMessages={onDeleteMessages}
875
875
  onSelectionVerb={wizard.start}
876
- wizardOpen={wizard.isOpen}
877
876
  commandsRef={commandsRef}
878
877
  onTriageContextChange={onTriageContextChange}
879
878
  >
@@ -268,7 +268,6 @@ export function FlaggedList({
268
268
  onOpen={openRow}
269
269
  onDeleteMessages={onDeleteMessages}
270
270
  onSelectionVerb={wizard.start}
271
- wizardOpen={wizard.isOpen}
272
271
  commandsRef={commandsRef}
273
272
  onTriageContextChange={onTriageContextChange}
274
273
  >
@@ -30,6 +30,9 @@ export function IntelligenceDrawer({
30
30
  onClose={onClose}
31
31
  ariaLabel="Message details"
32
32
  side="right"
33
+ // The key that raised it takes it away again (#840); every other verb
34
+ // stays with the list behind the scrim, which is not the subject here.
35
+ answers={{ toggleIntelligence: onClose }}
33
36
  >
34
37
  <IntelligencePane
35
38
  onClose={onClose}
@@ -214,7 +214,6 @@ interface MailboxPaneContextValue {
214
214
  focusedMessageId: string | undefined;
215
215
  selectedIds: string[];
216
216
  hasList: boolean;
217
- blocksKeyboard: boolean;
218
217
  }) => void;
219
218
  /** Where the list publishes the commands the keyboard layer drives. */
220
219
  listCommandsRef: RefObject<MessageListCommands | null>;
@@ -160,14 +160,6 @@ interface MessageListProps {
160
160
  * owns (Enter, Space, ⌘A) are left to the browser everywhere else.
161
161
  */
162
162
  hasList: boolean;
163
- /**
164
- * Whether the list has a modal open that owns the keyboard — the delete
165
- * confirmation, or the wizard. The route suspends the whole triage layer
166
- * while it does, so no shortcut can act behind it: a second Delete press
167
- * must not reach a delete, and none may start a second flow behind the
168
- * screen already asking about one.
169
- */
170
- blocksKeyboard: boolean;
171
163
  }) => void;
172
164
  /**
173
165
  * Ref the list publishes its {@link MessageListCommands} into, so the route's
@@ -432,9 +424,8 @@ export const MessageList = ({
432
424
  // Whether the list can serve keyboard commands at all. It stays true while
433
425
  // the delete confirmation is open — withdrawing the commands there would let
434
426
  // the route fall through to its own unconfirmed delete on a second Delete
435
- // press. The route suspends the whole keyboard layer for the dialog instead.
427
+ // press. The dialog is on the overlay stack, which suspends every layer.
436
428
  const commandsAvailable = !isLoading && threads.length > 0;
437
- const confirmOpen = pendingDelete !== null;
438
429
 
439
430
  const virtualizer = useVirtualizer({
440
431
  count: threads.length,
@@ -908,16 +899,8 @@ export const MessageList = ({
908
899
  focusedMessageId,
909
900
  selectedIds: Array.from(selectedIds),
910
901
  hasList: commandsAvailable,
911
- blocksKeyboard: confirmOpen || wizard.isOpen,
912
902
  });
913
- }, [
914
- focusedMessageId,
915
- selectedIds,
916
- commandsAvailable,
917
- confirmOpen,
918
- wizard.isOpen,
919
- onTriageContextChange,
920
- ]);
903
+ }, [focusedMessageId, selectedIds, commandsAvailable, onTriageContextChange]);
921
904
 
922
905
  // Retract the context when the list goes away (drafts view, phone reading
923
906
  // view). Without this the route keeps its list key handlers registered
@@ -931,7 +914,6 @@ export const MessageList = ({
931
914
  focusedMessageId: undefined,
932
915
  selectedIds: [],
933
916
  hasList: false,
934
- blocksKeyboard: false,
935
917
  }),
936
918
  [],
937
919
  );
@@ -174,13 +174,24 @@ describe("MoveToTrigger", () => {
174
174
  const isOpen = () =>
175
175
  dom.byLabel("Move to folder").getAttribute("aria-expanded") === "true";
176
176
 
177
+ // The window-level triage layer maps Escape to `back`, which closes the
178
+ // conversation behind the picker; one press must not do both (#732, #958).
179
+ const seen: string[] = [];
180
+ const listener = (event: Event) => seen.push((event as KeyboardEvent).key);
181
+ dom.window.addEventListener("keydown", listener);
182
+
177
183
  dom.click(dom.byLabel("Move to folder"));
178
184
  assert.equal(isOpen(), true);
179
185
  dom.dispatch(
180
- dom.window.document,
181
- new dom.window.KeyboardEvent("keydown", { key: "Escape" }),
186
+ dom.window.document.body,
187
+ new dom.window.KeyboardEvent("keydown", {
188
+ key: "Escape",
189
+ bubbles: true,
190
+ }),
182
191
  );
183
192
  assert.equal(isOpen(), false);
193
+ assert.deepEqual(seen, [], "the layer behind the picker saw the press");
194
+ dom.window.removeEventListener("keydown", listener);
184
195
 
185
196
  dom.click(dom.byLabel("Move to folder"));
186
197
  assert.equal(isOpen(), true);
@@ -5,6 +5,7 @@ import {
5
5
  type FolderTreeNode,
6
6
  FolderTreePicker,
7
7
  PopoverMenuPortal,
8
+ useOverlayScope,
8
9
  } from "@remit/ui";
9
10
  import { useQuery } from "@tanstack/react-query";
10
11
  import { FolderInput } from "lucide-react";
@@ -145,7 +146,14 @@ export const MoveToTrigger = ({
145
146
  onMove(picked.id);
146
147
  }, [picked, close, onMove]);
147
148
 
148
- // Desktop popover: dismiss on outside click + Escape.
149
+ // The picker owns Escape while it is open (#732): on the shared overlay stack,
150
+ // so the triage layer underneath never maps the same press to `back` and
151
+ // closes the conversation with it. A second Escape closes as usual. Both
152
+ // surfaces register — the phone's vaul drawer is a modal like any other, and
153
+ // a narrow window with a keyboard attached is an ordinary way to meet it.
154
+ useOverlayScope({ id: "move-to", open: isOpen, answers: { back: close } });
155
+
156
+ // Desktop popover: dismiss on outside click.
149
157
  useEffect(() => {
150
158
  if (!isOpen || !isDesktop) return;
151
159
  const handlePointer = (event: MouseEvent) => {
@@ -157,22 +165,8 @@ export const MoveToTrigger = ({
157
165
  if (panelRef.current?.contains(target)) return;
158
166
  close();
159
167
  };
160
- // The popover owns Escape while it is open (#732). The global triage
161
- // layer listens on window and maps Escape to `back`, which closes the
162
- // conversation; stopping propagation keeps one press from dismissing
163
- // both. A second Escape then reaches the layer and closes as usual.
164
- const handleKey = (event: KeyboardEvent) => {
165
- if (event.key === "Escape") {
166
- event.stopPropagation();
167
- close();
168
- }
169
- };
170
168
  document.addEventListener("mousedown", handlePointer);
171
- document.addEventListener("keydown", handleKey);
172
- return () => {
173
- document.removeEventListener("mousedown", handlePointer);
174
- document.removeEventListener("keydown", handleKey);
175
- };
169
+ return () => document.removeEventListener("mousedown", handlePointer);
176
170
  }, [isOpen, isDesktop, close]);
177
171
 
178
172
  const anchorRect = () =>
@@ -22,6 +22,7 @@ import {
22
22
  stepIndex,
23
23
  stepsFor,
24
24
  suggestRuleName,
25
+ useOverlayScope,
25
26
  type Verb,
26
27
  type WizardDraft,
27
28
  type WizardMessage,
@@ -1097,6 +1098,10 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
1097
1098
  const { step, goToStep, goBack, closeWizard } = useWizardStep(
1098
1099
  fromSearch ? "properties" : "match",
1099
1100
  );
1101
+ // A walk in progress owns the keyboard. It answers nothing itself — Back and
1102
+ // Close are on screen, and Escape abandoning a half-run selection is not what
1103
+ // the reader asked for — so every triage key is contained until it closes.
1104
+ useOverlayScope({ id: "selection-wizard", open: step !== undefined });
1100
1105
  if (!step) return null;
1101
1106
  const conversion = fromSearch
1102
1107
  ? (searchConversion ?? NO_CONVERSION)
@@ -177,8 +177,6 @@ interface ThreadListInteractionProps {
177
177
  * second unreviewed route to the same verb.
178
178
  */
179
179
  onSelectionVerb: (verb: Verb) => void;
180
- /** The wizard owns the screen, so this list's keyboard layer stands down. */
181
- wizardOpen?: boolean;
182
180
  isDeleting?: boolean;
183
181
  commandsRef?: RefObject<MessageListCommands | null>;
184
182
  onTriageContextChange?: (context: TriageContextUpdate) => void;
@@ -191,7 +189,6 @@ export function ThreadListInteraction({
191
189
  onOpen,
192
190
  onDeleteMessages,
193
191
  onSelectionVerb,
194
- wizardOpen = false,
195
192
  isDeleting = false,
196
193
  commandsRef,
197
194
  onTriageContextChange,
@@ -408,11 +405,6 @@ export function ThreadListInteraction({
408
405
  selectedIds: selectedIdList,
409
406
  orderedIds,
410
407
  hasList,
411
- // The dialog and the wizard each own the keyboard while they are up, so
412
- // the triage layer suspends rather than acting behind them: a second
413
- // Delete must not reach a delete, and no shortcut may start a second flow
414
- // behind the screen already asking about one.
415
- blocksKeyboard: confirmOpen || wizardOpen,
416
408
  });
417
409
  }, [
418
410
  onTriageContextChange,
@@ -420,8 +412,6 @@ export function ThreadListInteraction({
420
412
  selectedIdList,
421
413
  orderedIds,
422
414
  hasList,
423
- confirmOpen,
424
- wizardOpen,
425
415
  ]);
426
416
 
427
417
  const tabStop = tabStopId(orderedIds, focusedMessageId);
@@ -201,7 +201,6 @@ function PaneShell({
201
201
  focusedMessageId: MESSAGE_ID,
202
202
  selectedIds: [],
203
203
  hasList,
204
- blocksKeyboard: false,
205
204
  orderedIds: [MESSAGE_ID, OTHER_MESSAGE_ID],
206
205
  });
207
206
  }, []);
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Escape on an open overlay closes the overlay and nothing else (#958), and no
3
+ * other shortcut acts through one (#959).
4
+ *
5
+ * The window-level triage layer maps Escape to `back` — close the conversation,
6
+ * or the list behind it — and `c` to compose. The client's three own overlays
7
+ * used to let both reach it, so one Escape dismissed two things and `c` opened
8
+ * compose out from under a modal. They share the kit's overlay stack now, and
9
+ * the reading is the same for each: a listener on `window` never sees the key.
10
+ */
11
+ import "@remit/test-dom";
12
+ import assert from "node:assert/strict";
13
+ import { afterEach, beforeEach, describe, it } from "node:test";
14
+ import { ConfirmDialog, useTriageKeyboard } from "@remit/ui";
15
+ import { act, createElement, type ReactNode } from "react";
16
+ import { createRoot, type Root } from "react-dom/client";
17
+ import { Drawer } from "./layout/Drawer";
18
+ import { DropdownMenu, DropdownMenuItem } from "./ui/DropdownMenu";
19
+ import { KeyboardShortcutsModal } from "./ui/KeyboardShortcutsModal";
20
+
21
+ let root: Root;
22
+ let container: HTMLElement;
23
+ let seen: string[];
24
+ let listener: (event: KeyboardEvent) => void;
25
+
26
+ beforeEach(() => {
27
+ container = document.createElement("div");
28
+ document.body.appendChild(container);
29
+ root = createRoot(container);
30
+ seen = [];
31
+ listener = (event) => seen.push(event.key);
32
+ window.addEventListener("keydown", listener);
33
+ });
34
+
35
+ afterEach(() => {
36
+ window.removeEventListener("keydown", listener);
37
+ act(() => root.unmount());
38
+ container.remove();
39
+ });
40
+
41
+ const render = (element: ReactNode) => {
42
+ act(() => root.render(element));
43
+ };
44
+
45
+ const press = (key: string) => {
46
+ act(() => {
47
+ document.body.dispatchEvent(
48
+ new window.KeyboardEvent("keydown", { key, bubbles: true }),
49
+ );
50
+ });
51
+ };
52
+
53
+ const assertScoped = (closed: number) => {
54
+ assert.equal(closed, 1, "the overlay did not close itself");
55
+ assert.deepEqual(seen, [], "the layer behind the overlay saw the same press");
56
+ };
57
+
58
+ describe("Escape is scoped to the overlay it lands on (#958)", () => {
59
+ it("Drawer closes and swallows the key", () => {
60
+ let closed = 0;
61
+ render(
62
+ createElement(Drawer, {
63
+ isOpen: true,
64
+ onClose: () => closed++,
65
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
66
+ children: createElement("button", { type: "button" }, "Inbox"),
67
+ }),
68
+ );
69
+
70
+ press("Escape");
71
+
72
+ assertScoped(closed);
73
+ });
74
+
75
+ it("KeyboardShortcutsModal closes and swallows the key", () => {
76
+ let closed = 0;
77
+ render(
78
+ createElement(KeyboardShortcutsModal, {
79
+ isOpen: true,
80
+ onClose: () => closed++,
81
+ }),
82
+ );
83
+
84
+ press("Escape");
85
+
86
+ assertScoped(closed);
87
+ });
88
+
89
+ it("DropdownMenu closes and swallows the key", () => {
90
+ render(
91
+ createElement(DropdownMenu, {
92
+ trigger: "More",
93
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
94
+ children: createElement(DropdownMenuItem, {
95
+ onClick: () => undefined,
96
+ // biome-ignore lint/correctness/noChildrenProp: same
97
+ children: "Mark as read",
98
+ }),
99
+ }),
100
+ );
101
+ const trigger = container.querySelector("button") as HTMLButtonElement;
102
+ act(() => trigger.click());
103
+ assert.ok(container.textContent?.includes("Mark as read"));
104
+
105
+ press("Escape");
106
+
107
+ assert.equal(
108
+ container.textContent?.includes("Mark as read"),
109
+ false,
110
+ "the menu did not close itself",
111
+ );
112
+ assert.deepEqual(seen, [], "the layer behind the menu saw the same press");
113
+ });
114
+ });
115
+
116
+ /**
117
+ * The route-level compose layer, mounted the way `/mail` mounts it: `c` off a
118
+ * mailbox is served there and nowhere else, so on the brief and on Flagged it
119
+ * was the only layer answering the key — and the only one with no guard.
120
+ */
121
+ function ComposeLayer({
122
+ modal,
123
+ onCompose,
124
+ }: {
125
+ modal: boolean;
126
+ onCompose: () => void;
127
+ }) {
128
+ useTriageKeyboard({ handlers: { compose: onCompose } });
129
+ return createElement(ConfirmDialog, {
130
+ isOpen: modal,
131
+ title: "Delete 3 messages?",
132
+ confirmLabel: "Delete",
133
+ onConfirm: () => undefined,
134
+ onCancel: () => undefined,
135
+ });
136
+ }
137
+
138
+ describe("c under a confirmation on the brief (#959)", () => {
139
+ it("stays inert while the dialog is up, and works once it is gone", () => {
140
+ const composed: string[] = [];
141
+ const onCompose = () => composed.push("c");
142
+
143
+ render(createElement(ComposeLayer, { modal: true, onCompose }));
144
+ press("c");
145
+ assert.deepEqual(composed, [], "c opened compose from under the dialog");
146
+
147
+ render(createElement(ComposeLayer, { modal: false, onCompose }));
148
+ press("c");
149
+ assert.deepEqual(composed, ["c"], "c stayed dead after the dialog closed");
150
+ });
151
+ });
@@ -1,4 +1,4 @@
1
- import { cn } from "@remit/ui";
1
+ import { cn, useOverlayScope } from "@remit/ui";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
 
4
4
  interface DropdownMenuProps {
@@ -21,22 +21,16 @@ export const DropdownMenu = ({
21
21
  }
22
22
  }, []);
23
23
 
24
- const handleKeyDown = useCallback((event: KeyboardEvent) => {
25
- if (event.key === "Escape") {
26
- setIsOpen(false);
27
- }
28
- }, []);
24
+ useOverlayScope({
25
+ id: "dropdown-menu",
26
+ open: isOpen,
27
+ answers: { back: () => setIsOpen(false) },
28
+ });
29
29
 
30
30
  useEffect(() => {
31
- if (isOpen) {
32
- document.addEventListener("mousedown", handleClickOutside);
33
- document.addEventListener("keydown", handleKeyDown);
34
- }
35
- return () => {
36
- document.removeEventListener("mousedown", handleClickOutside);
37
- document.removeEventListener("keydown", handleKeyDown);
38
- };
39
- }, [isOpen, handleClickOutside, handleKeyDown]);
31
+ if (isOpen) document.addEventListener("mousedown", handleClickOutside);
32
+ return () => document.removeEventListener("mousedown", handleClickOutside);
33
+ }, [isOpen, handleClickOutside]);
40
34
 
41
35
  return (
42
36
  <div ref={menuRef} className="relative">
@@ -1,3 +1,4 @@
1
+ import { useOverlayScope } from "@remit/ui";
1
2
  import { AlertOctagon } from "lucide-react";
2
3
  import { useEffect, useState } from "react";
3
4
  import {
@@ -65,6 +66,9 @@ const seedFromFatal = (fatal: FatalError): BugReportSeed => ({
65
66
  * by the route-level error boundary without going through the subscription.
66
67
  */
67
68
  export const FatalErrorScreen = ({ fatal, onRetry }: FatalErrorScreenProps) => {
69
+ // Nothing under it is in a state to act, and there is no dismissal to offer:
70
+ // the screen's own controls are the only way out.
71
+ useOverlayScope({ id: "fatal-error", open: true });
68
72
  const [copied, setCopied] = useState(false);
69
73
 
70
74
  const handleReload = () => {
@@ -1,6 +1,12 @@
1
- import { cn, DialogBackdrop, Kbd, KEY_HINT_GROUPS } from "@remit/ui";
1
+ import {
2
+ cn,
3
+ DialogBackdrop,
4
+ Kbd,
5
+ KEY_HINT_GROUPS,
6
+ useOverlayScope,
7
+ } from "@remit/ui";
2
8
  import { X } from "lucide-react";
3
- import { Fragment, useCallback, useEffect } from "react";
9
+ import { Fragment } from "react";
4
10
 
5
11
  interface KeyboardShortcutsModalProps {
6
12
  isOpen: boolean;
@@ -16,27 +22,11 @@ export const KeyboardShortcutsModal = ({
16
22
  isOpen,
17
23
  onClose,
18
24
  }: KeyboardShortcutsModalProps) => {
19
- // Close on Escape
20
- const handleKeyDown = useCallback(
21
- (event: KeyboardEvent) => {
22
- if (event.key === "Escape") {
23
- event.preventDefault();
24
- event.stopPropagation();
25
- event.stopImmediatePropagation();
26
- onClose();
27
- }
28
- },
29
- [onClose],
30
- );
31
-
32
- useEffect(() => {
33
- if (!isOpen) return;
34
- // Capture phase, as `ConfirmDialog` does: the sheet is the topmost surface,
35
- // so Escape dismisses it and nothing else. Shared with the list's own
36
- // Escape, the one keystroke also closed the conversation underneath.
37
- window.addEventListener("keydown", handleKeyDown, true);
38
- return () => window.removeEventListener("keydown", handleKeyDown, true);
39
- }, [isOpen, handleKeyDown]);
25
+ useOverlayScope({
26
+ id: "shortcuts",
27
+ open: isOpen,
28
+ answers: { back: onClose },
29
+ });
40
30
 
41
31
  if (!isOpen) return null;
42
32
 
@@ -1,3 +1,4 @@
1
+ import { resolveAgainstOverlays, type TriageAction } from "@remit/ui";
1
2
  import { useCallback, useEffect } from "react";
2
3
 
3
4
  type KeyHandler = (event: KeyboardEvent) => void;
@@ -29,6 +30,14 @@ interface KeyBinding {
29
30
  * pre-empt a route-level Esc that would also navigate).
30
31
  */
31
32
  stopPropagation?: boolean;
33
+ /**
34
+ * Which triage action this key means. Naming it puts the binding under the
35
+ * overlay stack: an open overlay answers the action itself or contains it,
36
+ * and either way this binding stands down. Every key that also exists in the
37
+ * global key map owes one, or it acts through whatever is on top of it —
38
+ * `r` opening a reply behind the Move popover (#959) is that bug.
39
+ */
40
+ action?: TriageAction;
32
41
  }
33
42
 
34
43
  interface UseKeyboardNavigationOptions {
@@ -71,6 +80,8 @@ export const useKeyboardNavigation = ({
71
80
  event.key.toLowerCase() === binding.key.toLowerCase();
72
81
  if (!keyMatches) continue;
73
82
 
83
+ if (binding.action && resolveAgainstOverlays(binding.action)) continue;
84
+
74
85
  // Shift gate: when a binding pins shift on/off, it only matches that
75
86
  // exact state so plain and shift variants stay distinct.
76
87
  if (
@@ -14,6 +14,11 @@
14
14
  * context comes first, the pane builds its handlers from it, and the keyboard
15
15
  * registration comes last.
16
16
  *
17
+ * A modal open over the pane is not this layer's problem. Every overlay declares
18
+ * itself on the shared stack (`@remit/ui`'s `overlay-scope`) and `useTriageKeyboard`
19
+ * contains what the top frame does not serve, so the pane no longer reports which
20
+ * of its own dialogs is up and every other layer on the page suspends with it.
21
+ *
17
22
  * While a thread is open, `ConversationView` binds its own window listener for
18
23
  * the messages inside it, and both used to act on one press (#723). The split
19
24
  * is the one `shortcut-tree` already declares: the cursor keys belong to
@@ -30,7 +35,6 @@ export interface TriageContextUpdate {
30
35
  focusedMessageId: string | undefined;
31
36
  selectedIds: string[];
32
37
  hasList: boolean;
33
- blocksKeyboard: boolean;
34
38
  /** Row ids in display order, when the list knows them. Feeds adjacency. */
35
39
  orderedIds?: string[];
36
40
  }
@@ -45,7 +49,6 @@ export interface TriageContext {
45
49
  /** Row ids in display order, as last reported by the list. */
46
50
  orderedIds: string[];
47
51
  hasList: boolean;
48
- blocksKeyboard: boolean;
49
52
  }
50
53
 
51
54
  export const useTriageContext = (): TriageContext => {
@@ -58,14 +61,12 @@ export const useTriageContext = (): TriageContext => {
58
61
  // keyboard layer then simply has nothing to drive.
59
62
  const listCommandsRef = useRef<MessageListCommands | null>(null);
60
63
  const [hasList, setHasList] = useState(false);
61
- const [blocksKeyboard, setBlocksKeyboard] = useState(false);
62
64
 
63
65
  const onTriageContextChange = useCallback((context: TriageContextUpdate) => {
64
66
  setFocusedMessageId(context.focusedMessageId);
65
67
  setSelectedIds(context.selectedIds);
66
68
  if (context.orderedIds) setOrderedIds(context.orderedIds);
67
69
  setHasList(context.hasList);
68
- setBlocksKeyboard(context.blocksKeyboard);
69
70
  }, []);
70
71
 
71
72
  return {
@@ -75,7 +76,6 @@ export const useTriageContext = (): TriageContext => {
75
76
  selectedIds,
76
77
  orderedIds,
77
78
  hasList,
78
- blocksKeyboard,
79
79
  };
80
80
  };
81
81
 
@@ -118,7 +118,7 @@ export const useTriageLayer = ({
118
118
  handlers,
119
119
  hasOpenThread = false,
120
120
  }: UseTriageLayerOptions): TriageLayer => {
121
- const { listCommandsRef, hasList, blocksKeyboard } = context;
121
+ const { listCommandsRef, hasList } = context;
122
122
 
123
123
  const goBack = useCallback(() => {
124
124
  if (listCommandsRef.current?.clearSelection()) return;
@@ -132,10 +132,7 @@ export const useTriageLayer = ({
132
132
  const { reply, replyAll, forward, ...restHandlers } = handlers;
133
133
 
134
134
  useTriageKeyboard({
135
- // A modal owns the keyboard outright. Suspending the layer is what keeps a
136
- // second Delete press from reaching a delete while the confirmation for the
137
- // first one is still on screen.
138
- enabled: enabled && !blocksKeyboard,
135
+ enabled,
139
136
  handlers: {
140
137
  // Registered only while a list is mounted to serve them. An unregistered
141
138
  // action is never preventDefault-ed, so with no list the browser keeps
@@ -128,6 +128,7 @@ function MailLayout() {
128
128
  bindings: [
129
129
  {
130
130
  key: "?",
131
+ action: "help",
131
132
  handler: () => showOverlay("shortcuts"),
132
133
  noModifiers: false, // Allow shift+/
133
134
  preventDefault: true,
@@ -138,7 +139,9 @@ function MailLayout() {
138
139
  // `c` / ⌘N off the mailbox routes — the brief, Flagged, the outbox. On a
139
140
  // mailbox the pane's own triage layer owns the key, so this is disabled there
140
141
  // rather than firing a second compose alongside it, and while the composer is
141
- // up the key belongs to whatever is being typed.
142
+ // up the key belongs to whatever is being typed. An open modal contains the
143
+ // key through the shared overlay stack, so this layer never opens compose out
144
+ // from under one (#959).
142
145
  const openCompose = useOpenCompose();
143
146
  const isComposing = useIsComposing();
144
147
  const onMailbox = useRouterState({