@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 +1 -1
- package/src/components/compose/MobileComposeSheet.tsx +5 -1
- package/src/components/layout/Drawer.tsx +16 -7
- package/src/components/mail/ConversationView.tsx +44 -7
- package/src/components/mail/DailyBrief.tsx +0 -1
- package/src/components/mail/FlaggedList.tsx +0 -1
- package/src/components/mail/IntelligenceDrawer.tsx +3 -0
- package/src/components/mail/MailboxPane.tsx +0 -1
- package/src/components/mail/MessageList.tsx +2 -20
- package/src/components/mail/MoveToTrigger.render.test.ts +13 -2
- package/src/components/mail/MoveToTrigger.tsx +10 -16
- package/src/components/mail/SelectionWizardHost.tsx +5 -0
- package/src/components/mail/ThreadListInteraction.tsx +0 -10
- package/src/components/mail/two-window-listeners-one-key.render.test.ts +0 -1
- package/src/components/overlay-escape.render.test.ts +151 -0
- package/src/components/ui/DropdownMenu.tsx +9 -15
- package/src/components/ui/FatalErrorOverlay.tsx +4 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +13 -23
- package/src/hooks/useKeyboardNavigation.ts +11 -0
- package/src/hooks/useTriageLayer.ts +7 -10
- package/src/routes/mail.tsx +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
|
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
|
-
{
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
345
|
+
{
|
|
346
|
+
key: "f",
|
|
347
|
+
action: "forward" as const,
|
|
348
|
+
handler: handleForward,
|
|
349
|
+
preventDefault: true,
|
|
350
|
+
},
|
|
314
351
|
],
|
|
315
352
|
});
|
|
316
353
|
|
|
@@ -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
|
|
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", {
|
|
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
|
-
//
|
|
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.
|
|
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);
|
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/src/routes/mail.tsx
CHANGED
|
@@ -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({
|