@remit/web-client 0.0.13 → 0.0.15
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/auth/better-auth-config.ts +4 -1
- package/src/components/compose/ComposeForm.tsx +3 -0
- package/src/components/mail/IntelligencePane.tsx +10 -8
- package/src/components/mail/MailboxPane.tsx +53 -2
- package/src/components/mail/MessageActionMenu.tsx +18 -25
- package/src/components/mail/MessageDetail.tsx +1 -0
- package/src/components/mail/MessageList.tsx +196 -78
- package/src/components/mail/MessageListItem.tsx +60 -13
- package/src/components/mail/SwipeableMessageRow.tsx +8 -0
- package/src/components/ui/ConfirmDialog.tsx +9 -7
- package/src/components/ui/ErrorBanner.tsx +6 -3
- package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
- package/src/components/ui/ErrorBannerProvider.tsx +18 -0
- package/src/components/ui/error-banners.ts +9 -0
- package/src/hooks/useDeleteMessages.ts +14 -21
- package/src/hooks/useIntelligenceData.ts +14 -3
- package/src/hooks/useMarkAsRead.ts +14 -23
- package/src/hooks/useMessageBodyContent.ts +2 -1
- package/src/hooks/useMoveMessages.ts +14 -21
- package/src/hooks/useStaleAccountSync.test.ts +22 -1
- package/src/hooks/useToggleStar.test.ts +31 -0
- package/src/hooks/useToggleStar.ts +27 -31
- package/src/hooks/useTriageKeyboard.ts +13 -8
- package/src/hooks/useUpdateAddressFlags.ts +3 -1
- package/src/lib/api.ts +3 -1
- package/src/lib/client.ts +3 -0
- package/src/lib/error-classifier.test.ts +156 -8
- package/src/lib/error-classifier.ts +49 -9
- package/src/lib/keymap-dispatch.test.ts +78 -1
- package/src/lib/keymap-dispatch.ts +64 -7
- package/src/lib/keymap.ts +23 -0
- package/src/lib/list-focus.test.ts +25 -0
- package/src/lib/list-focus.ts +24 -0
- package/src/lib/network-error.ts +58 -0
- package/src/lib/query-error-handler.test.ts +6 -2
- package/src/lib/query-escalation.integration.test.ts +3 -2
- package/src/lib/sender-address.test.ts +60 -0
- package/src/lib/sender-address.ts +37 -0
- package/src/lib/thread-cache.test.ts +65 -0
- package/src/lib/thread-cache.ts +76 -0
|
@@ -3,10 +3,10 @@ import { type Density, MessageListPane, SelectionTopBar } from "@remit/ui";
|
|
|
3
3
|
import { useNavigate } from "@tanstack/react-router";
|
|
4
4
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
5
5
|
import { Search, Sparkles } from "lucide-react";
|
|
6
|
+
import type { RefObject } from "react";
|
|
6
7
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
8
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
8
9
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
9
|
-
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
|
10
10
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
11
11
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
12
12
|
import {
|
|
@@ -16,11 +16,39 @@ import {
|
|
|
16
16
|
} from "@/hooks/useSelection";
|
|
17
17
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
18
18
|
import { formatDeleteToTrashTitle } from "@/lib/format";
|
|
19
|
+
import { tabStopId } from "@/lib/list-focus";
|
|
19
20
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
20
21
|
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
21
22
|
import { SelectionToolbar } from "./SelectionToolbar";
|
|
22
23
|
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The list operations the global keyboard layer drives. The list publishes an
|
|
27
|
+
* implementation into a ref the route owns, so navigation and selection keys
|
|
28
|
+
* are routed by the one dispatcher in `useTriageKeyboard` instead of a second
|
|
29
|
+
* window listener of the list's own (#43).
|
|
30
|
+
*/
|
|
31
|
+
export interface MessageListCommands {
|
|
32
|
+
focusNext: () => void;
|
|
33
|
+
focusPrevious: () => void;
|
|
34
|
+
focusFirst: () => void;
|
|
35
|
+
focusLast: () => void;
|
|
36
|
+
openFocused: () => void;
|
|
37
|
+
toggleSelect: () => void;
|
|
38
|
+
extendSelectDown: () => void;
|
|
39
|
+
extendSelectUp: () => void;
|
|
40
|
+
selectAll: () => void;
|
|
41
|
+
/** Returns true when there was a selection to clear — Esc consumes it. */
|
|
42
|
+
clearSelection: () => boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Opens the move-to-Trash confirmation for the selection, or the focused row
|
|
45
|
+
* when nothing is selected. Returns false when there is nothing to delete, so
|
|
46
|
+
* the route can fall back to its own reading-pane delete.
|
|
47
|
+
*/
|
|
48
|
+
requestDelete: () => boolean;
|
|
49
|
+
toggleDensity: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
24
52
|
interface MessageListProps {
|
|
25
53
|
mailboxId: string;
|
|
26
54
|
threads: RemitImapThreadMessageResponse[];
|
|
@@ -64,7 +92,24 @@ interface MessageListProps {
|
|
|
64
92
|
onTriageContextChange?: (context: {
|
|
65
93
|
focusedMessageId: string | undefined;
|
|
66
94
|
selectedIds: string[];
|
|
95
|
+
/**
|
|
96
|
+
* Whether a list is mounted with commands published. The route registers
|
|
97
|
+
* its list-driven key handlers only while this holds, so keys the list
|
|
98
|
+
* owns (Enter, Space, ⌘A) are left to the browser everywhere else.
|
|
99
|
+
*/
|
|
100
|
+
hasList: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Whether the list has a modal open that owns the keyboard. The route
|
|
103
|
+
* suspends the whole triage layer while it does, so no shortcut can act
|
|
104
|
+
* behind the dialog — a second Delete press must not reach a delete.
|
|
105
|
+
*/
|
|
106
|
+
blocksKeyboard: boolean;
|
|
67
107
|
}) => void;
|
|
108
|
+
/**
|
|
109
|
+
* Ref the list publishes its {@link MessageListCommands} into, so the route's
|
|
110
|
+
* keyboard dispatcher can drive navigation and selection. Cleared on unmount.
|
|
111
|
+
*/
|
|
112
|
+
commandsRef?: RefObject<MessageListCommands | null>;
|
|
68
113
|
/**
|
|
69
114
|
* Suppress the pane's built-in title header — the shared `MailHeader` above
|
|
70
115
|
* the list owns it (the inbox renders inside `MailViewChrome`).
|
|
@@ -123,6 +168,7 @@ export const MessageList = ({
|
|
|
123
168
|
listTitle,
|
|
124
169
|
listMeta,
|
|
125
170
|
onTriageContextChange,
|
|
171
|
+
commandsRef,
|
|
126
172
|
hideHeader = false,
|
|
127
173
|
}: MessageListProps) => {
|
|
128
174
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
@@ -179,6 +225,20 @@ export const MessageList = ({
|
|
|
179
225
|
null,
|
|
180
226
|
);
|
|
181
227
|
|
|
228
|
+
// Set when a keyboard command moves the roving cursor. Real DOM focus then
|
|
229
|
+
// follows the cursor onto the row once the virtualizer has rendered it, so
|
|
230
|
+
// the browser's own focus — and therefore Tab, Shift+Tab and the focus ring
|
|
231
|
+
// — agree with what the list highlights (#43). Mouse-driven focus changes
|
|
232
|
+
// leave this null, so the list never yanks focus out of the reading pane.
|
|
233
|
+
const pendingDomFocusRef = useRef<string | null>(null);
|
|
234
|
+
|
|
235
|
+
// Whether the list can serve keyboard commands at all. It stays true while
|
|
236
|
+
// the delete confirmation is open — withdrawing the commands there would let
|
|
237
|
+
// the route fall through to its own unconfirmed delete on a second Delete
|
|
238
|
+
// press. The route suspends the whole keyboard layer for the dialog instead.
|
|
239
|
+
const commandsAvailable = !isLoading && threads.length > 0;
|
|
240
|
+
const confirmOpen = pendingDeleteIds !== null;
|
|
241
|
+
|
|
182
242
|
// Auto-exit multi-select when selection becomes empty
|
|
183
243
|
useEffect(() => {
|
|
184
244
|
if (isMultiSelectMode && selectedCount === 0) {
|
|
@@ -214,6 +274,7 @@ export const MessageList = ({
|
|
|
214
274
|
toggleCheck(thread.messageId);
|
|
215
275
|
return;
|
|
216
276
|
}
|
|
277
|
+
pendingDomFocusRef.current = thread.messageId;
|
|
217
278
|
setFocusedMessageId(thread.messageId);
|
|
218
279
|
},
|
|
219
280
|
[threads, isMultiSelectMode, toggleCheck],
|
|
@@ -235,6 +296,12 @@ export const MessageList = ({
|
|
|
235
296
|
moveFocusToIndex(prevIndex);
|
|
236
297
|
}, [threads.length, focusIndex, moveFocusToIndex]);
|
|
237
298
|
|
|
299
|
+
const focusFirst = useCallback(() => moveFocusToIndex(0), [moveFocusToIndex]);
|
|
300
|
+
const focusLast = useCallback(
|
|
301
|
+
() => moveFocusToIndex(threads.length - 1),
|
|
302
|
+
[moveFocusToIndex, threads.length],
|
|
303
|
+
);
|
|
304
|
+
|
|
238
305
|
// Toggle selection on the focused row with x.
|
|
239
306
|
const toggleFocusedSelection = useCallback(() => {
|
|
240
307
|
if (focusedMessageId) {
|
|
@@ -292,6 +359,7 @@ export const MessageList = ({
|
|
|
292
359
|
// Shift+arrow moves the focus cursor (not the open thread) and grows
|
|
293
360
|
// the selection from the anchor — the keyboard equivalent of
|
|
294
361
|
// shift-click.
|
|
362
|
+
pendingDomFocusRef.current = target;
|
|
295
363
|
setFocusedMessageId(target);
|
|
296
364
|
},
|
|
297
365
|
[orderedIds, focusedMessageId, selectRange],
|
|
@@ -309,13 +377,31 @@ export const MessageList = ({
|
|
|
309
377
|
|
|
310
378
|
// Delete / Backspace: confirm-delete the selection, or the focused row when
|
|
311
379
|
// nothing is selected.
|
|
312
|
-
|
|
380
|
+
// Returns true when it took the keypress, so the route's fallback delete
|
|
381
|
+
// (which skips the confirmation) doesn't also fire.
|
|
382
|
+
const handleDeleteKey = useCallback((): boolean => {
|
|
383
|
+
// The confirmation is already asking about a delete: the keypress belongs
|
|
384
|
+
// to it, and answering it is the Confirm button's job. Claiming the press
|
|
385
|
+
// here is what stops a second Delete from reaching an unconfirmed delete.
|
|
386
|
+
if (pendingDeleteIds !== null) return true;
|
|
387
|
+
if (!onDeleteMessages) return false;
|
|
313
388
|
if (selectedCount > 0) {
|
|
314
389
|
requestDelete(Array.from(selectedIds));
|
|
315
|
-
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
if (focusedMessageId) {
|
|
316
393
|
requestDelete([focusedMessageId]);
|
|
394
|
+
return true;
|
|
317
395
|
}
|
|
318
|
-
|
|
396
|
+
return false;
|
|
397
|
+
}, [
|
|
398
|
+
pendingDeleteIds,
|
|
399
|
+
onDeleteMessages,
|
|
400
|
+
selectedCount,
|
|
401
|
+
selectedIds,
|
|
402
|
+
focusedMessageId,
|
|
403
|
+
requestDelete,
|
|
404
|
+
]);
|
|
319
405
|
|
|
320
406
|
// Enter: open the focused row in the reading pane (sets selected/URL). This
|
|
321
407
|
// is the focus→open transition of the 2-state model.
|
|
@@ -503,8 +589,33 @@ export const MessageList = ({
|
|
|
503
589
|
onTriageContextChange?.({
|
|
504
590
|
focusedMessageId,
|
|
505
591
|
selectedIds: Array.from(selectedIds),
|
|
592
|
+
hasList: commandsAvailable,
|
|
593
|
+
blocksKeyboard: confirmOpen,
|
|
506
594
|
});
|
|
507
|
-
}, [
|
|
595
|
+
}, [
|
|
596
|
+
focusedMessageId,
|
|
597
|
+
selectedIds,
|
|
598
|
+
commandsAvailable,
|
|
599
|
+
confirmOpen,
|
|
600
|
+
onTriageContextChange,
|
|
601
|
+
]);
|
|
602
|
+
|
|
603
|
+
// Retract the context when the list goes away (drafts view, phone reading
|
|
604
|
+
// view). Without this the route keeps its list key handlers registered
|
|
605
|
+
// against a list that no longer exists, and goes on swallowing Enter, Space
|
|
606
|
+
// and ⌘A on a screen that has no rows.
|
|
607
|
+
const bridgeRef = useRef(onTriageContextChange);
|
|
608
|
+
bridgeRef.current = onTriageContextChange;
|
|
609
|
+
useEffect(
|
|
610
|
+
() => () =>
|
|
611
|
+
bridgeRef.current?.({
|
|
612
|
+
focusedMessageId: undefined,
|
|
613
|
+
selectedIds: [],
|
|
614
|
+
hasList: false,
|
|
615
|
+
blocksKeyboard: false,
|
|
616
|
+
}),
|
|
617
|
+
[],
|
|
618
|
+
);
|
|
508
619
|
|
|
509
620
|
// Clear selection when threads change (e.g., after delete)
|
|
510
621
|
useEffect(() => {
|
|
@@ -541,81 +652,65 @@ export const MessageList = ({
|
|
|
541
652
|
return () => scrollElement.removeEventListener("scroll", handleScroll);
|
|
542
653
|
}, [hasMore, isLoadingMore, onLoadMore]);
|
|
543
654
|
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
preventDefault: true,
|
|
558
|
-
requireShift: false,
|
|
559
|
-
},
|
|
560
|
-
{ key: "k", handler: focusPrevious, preventDefault: true },
|
|
561
|
-
{
|
|
562
|
-
key: "ArrowUp",
|
|
563
|
-
handler: focusPrevious,
|
|
564
|
-
preventDefault: true,
|
|
565
|
-
requireShift: false,
|
|
566
|
-
},
|
|
567
|
-
// Shift+Arrow: extend the selection range.
|
|
568
|
-
{
|
|
569
|
-
key: "ArrowDown",
|
|
570
|
-
handler: extendRangeDown,
|
|
571
|
-
preventDefault: true,
|
|
572
|
-
requireShift: true,
|
|
573
|
-
},
|
|
574
|
-
{
|
|
575
|
-
key: "ArrowUp",
|
|
576
|
-
handler: extendRangeUp,
|
|
577
|
-
preventDefault: true,
|
|
578
|
-
requireShift: true,
|
|
579
|
-
},
|
|
580
|
-
// Toggle focused row (x or Space) and re-anchor.
|
|
581
|
-
{ key: "x", handler: toggleFocusedSelection, preventDefault: true },
|
|
582
|
-
{ key: " ", handler: toggleFocusedSelection, preventDefault: true },
|
|
583
|
-
// Cmd/Ctrl+A select all loaded rows.
|
|
584
|
-
{
|
|
585
|
-
key: "a",
|
|
586
|
-
handler: handleSelectAll,
|
|
587
|
-
preventDefault: true,
|
|
588
|
-
requireMeta: true,
|
|
589
|
-
},
|
|
590
|
-
// Enter opens the focused message.
|
|
591
|
-
{ key: "Enter", handler: handleOpenFocused, preventDefault: true },
|
|
592
|
-
// Delete / Backspace: confirm-delete selection or focused row.
|
|
593
|
-
{ key: "Delete", handler: handleDeleteKey, preventDefault: true },
|
|
594
|
-
{ key: "Backspace", handler: handleDeleteKey, preventDefault: true },
|
|
595
|
-
// d: toggle density (comfortable / compact).
|
|
596
|
-
{ key: "d", handler: toggleDensity, preventDefault: true },
|
|
597
|
-
],
|
|
655
|
+
// Move real DOM focus onto the roving cursor after a keyboard move. The
|
|
656
|
+
// virtualizer may not have rendered the row yet on the commit that moved the
|
|
657
|
+
// cursor; the scroll effect above brings it in and this runs again on the
|
|
658
|
+
// next commit, so the lookup retries until the row exists.
|
|
659
|
+
useEffect(() => {
|
|
660
|
+
const messageId = pendingDomFocusRef.current;
|
|
661
|
+
if (!messageId) return;
|
|
662
|
+
const row = parentRef.current?.querySelector<HTMLElement>(
|
|
663
|
+
`[data-message-id="${messageId}"]`,
|
|
664
|
+
);
|
|
665
|
+
if (!row) return;
|
|
666
|
+
pendingDomFocusRef.current = null;
|
|
667
|
+
row.focus({ preventScroll: true });
|
|
598
668
|
});
|
|
599
669
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
670
|
+
useEffect(() => {
|
|
671
|
+
if (!commandsRef) return;
|
|
672
|
+
if (!commandsAvailable) {
|
|
673
|
+
commandsRef.current = null;
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
commandsRef.current = {
|
|
677
|
+
focusNext,
|
|
678
|
+
focusPrevious,
|
|
679
|
+
focusFirst,
|
|
680
|
+
focusLast,
|
|
681
|
+
openFocused: handleOpenFocused,
|
|
682
|
+
toggleSelect: toggleFocusedSelection,
|
|
683
|
+
extendSelectDown: extendRangeDown,
|
|
684
|
+
extendSelectUp: extendRangeUp,
|
|
685
|
+
selectAll: handleSelectAll,
|
|
686
|
+
clearSelection: () => {
|
|
687
|
+
if (!hasSelection) return false;
|
|
688
|
+
clearSelection();
|
|
689
|
+
return true;
|
|
616
690
|
},
|
|
617
|
-
|
|
618
|
-
|
|
691
|
+
requestDelete: handleDeleteKey,
|
|
692
|
+
toggleDensity,
|
|
693
|
+
};
|
|
694
|
+
return () => {
|
|
695
|
+
commandsRef.current = null;
|
|
696
|
+
};
|
|
697
|
+
}, [
|
|
698
|
+
commandsRef,
|
|
699
|
+
commandsAvailable,
|
|
700
|
+
focusNext,
|
|
701
|
+
focusPrevious,
|
|
702
|
+
focusFirst,
|
|
703
|
+
focusLast,
|
|
704
|
+
handleOpenFocused,
|
|
705
|
+
toggleFocusedSelection,
|
|
706
|
+
extendRangeDown,
|
|
707
|
+
extendRangeUp,
|
|
708
|
+
handleSelectAll,
|
|
709
|
+
hasSelection,
|
|
710
|
+
clearSelection,
|
|
711
|
+
handleDeleteKey,
|
|
712
|
+
toggleDensity,
|
|
713
|
+
]);
|
|
619
714
|
|
|
620
715
|
// Derive the MessageListPane listState from the loading/error/empty signals.
|
|
621
716
|
const listState = isLoading
|
|
@@ -694,6 +789,17 @@ export const MessageList = ({
|
|
|
694
789
|
|
|
695
790
|
const activeSelectionBar = desktopSelectionBar ?? mobileSelectionBar;
|
|
696
791
|
|
|
792
|
+
// Roving tabindex: exactly one row is in the tab order, so Tab moves focus
|
|
793
|
+
// into the list at the cursor and Shift+Tab moves back out to the side panel
|
|
794
|
+
// instead of walking every row (#43).
|
|
795
|
+
const tabStopMessageId = tabStopId(orderedIds, focusedMessageId);
|
|
796
|
+
|
|
797
|
+
// A row focused by Tab or click becomes the cursor, so the keys act on what
|
|
798
|
+
// the browser says is focused.
|
|
799
|
+
const handleRowFocus = useCallback((messageId: string) => {
|
|
800
|
+
setFocusedMessageId(messageId);
|
|
801
|
+
}, []);
|
|
802
|
+
|
|
697
803
|
const isSearching = !!searchQuery?.trim();
|
|
698
804
|
|
|
699
805
|
// The virtualized list body: rows + search header + load-more indicator.
|
|
@@ -705,8 +811,17 @@ export const MessageList = ({
|
|
|
705
811
|
{isSearching && searchQuery && (
|
|
706
812
|
<SearchResultsHeader query={searchQuery} count={threads.length} />
|
|
707
813
|
)}
|
|
708
|
-
<div
|
|
814
|
+
<div
|
|
815
|
+
ref={parentRef}
|
|
816
|
+
role="listbox"
|
|
817
|
+
aria-multiselectable
|
|
818
|
+
aria-label={listTitle}
|
|
819
|
+
className="flex-1 overflow-y-auto"
|
|
820
|
+
>
|
|
821
|
+
{/* Virtualizer scaffolding — presentational so the listbox sees the
|
|
822
|
+
rows as its options rather than these positioning wrappers. */}
|
|
709
823
|
<div
|
|
824
|
+
role="presentation"
|
|
710
825
|
className="relative w-full"
|
|
711
826
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
|
712
827
|
>
|
|
@@ -715,6 +830,7 @@ export const MessageList = ({
|
|
|
715
830
|
return (
|
|
716
831
|
<div
|
|
717
832
|
key={virtualRow.key}
|
|
833
|
+
role="presentation"
|
|
718
834
|
data-index={virtualRow.index}
|
|
719
835
|
ref={virtualizer.measureElement}
|
|
720
836
|
className="absolute left-0 top-0 w-full border-b border-line"
|
|
@@ -726,6 +842,8 @@ export const MessageList = ({
|
|
|
726
842
|
accountId={accountId}
|
|
727
843
|
isSelected={selectedMessageId === thread.messageId}
|
|
728
844
|
isFocused={focusedMessageId === thread.messageId}
|
|
845
|
+
isTabStop={tabStopMessageId === thread.messageId}
|
|
846
|
+
onFocusRow={handleRowFocus}
|
|
729
847
|
isChecked={isChecked(thread.messageId)}
|
|
730
848
|
onToggleCheck={toggleCheck}
|
|
731
849
|
onRowSelect={handleRowSelect}
|
|
@@ -38,6 +38,14 @@ interface MessageListItemProps {
|
|
|
38
38
|
* row shows the full highlight. Both can be true (the open row stays focused).
|
|
39
39
|
*/
|
|
40
40
|
isFocused?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Whether this row holds the list's single tab stop (roving tabindex). Every
|
|
43
|
+
* other row is `tabIndex={-1}`, so Tab enters the list at the cursor and
|
|
44
|
+
* Shift+Tab leaves it rather than stepping through hundreds of rows.
|
|
45
|
+
*/
|
|
46
|
+
isTabStop?: boolean;
|
|
47
|
+
/** Called when the row takes DOM focus, so the roving cursor follows it. */
|
|
48
|
+
onFocusRow?: (messageId: string) => void;
|
|
41
49
|
isChecked: boolean;
|
|
42
50
|
onToggleCheck: (id: string) => void;
|
|
43
51
|
/**
|
|
@@ -94,6 +102,8 @@ const MessageListItemComponent = ({
|
|
|
94
102
|
accountId,
|
|
95
103
|
isSelected,
|
|
96
104
|
isFocused = false,
|
|
105
|
+
isTabStop = false,
|
|
106
|
+
onFocusRow,
|
|
97
107
|
isChecked,
|
|
98
108
|
onToggleCheck,
|
|
99
109
|
onRowSelect,
|
|
@@ -116,22 +126,41 @@ const MessageListItemComponent = ({
|
|
|
116
126
|
// Desktop mouse selection semantics. Plain click falls through to the Link's
|
|
117
127
|
// navigation; shift / cmd / ctrl click is routed to selection and the
|
|
118
128
|
// navigation is suppressed. On mobile this is a no-op so taps still open.
|
|
129
|
+
//
|
|
130
|
+
// A modified click must preventDefault: the router skips navigation for any
|
|
131
|
+
// modified click and leaves the anchor's own default in place, which in a
|
|
132
|
+
// browser means shift-click opens a new window and cmd-click a new tab.
|
|
133
|
+
// Shift-click also drags a native text selection across the rows it spans,
|
|
134
|
+
// so drop it — the row highlight is the selection the user asked for.
|
|
119
135
|
const handleRowClick = useCallback(
|
|
120
136
|
(e: MouseEvent) => {
|
|
121
137
|
if (!isDesktop) return;
|
|
122
|
-
const
|
|
138
|
+
const modifiers = {
|
|
123
139
|
shiftKey: e.shiftKey,
|
|
124
140
|
metaKey: e.metaKey,
|
|
125
141
|
ctrlKey: e.ctrlKey,
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
142
|
+
};
|
|
143
|
+
const handled = onRowSelect(messageId, modifiers);
|
|
144
|
+
if (!handled) return;
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
if (modifiers.shiftKey) {
|
|
148
|
+
window.getSelection()?.removeAllRanges();
|
|
130
149
|
}
|
|
131
150
|
},
|
|
132
151
|
[isDesktop, onRowSelect, messageId],
|
|
133
152
|
);
|
|
134
153
|
|
|
154
|
+
// Shift-click starts a native text selection on mousedown; suppressing it
|
|
155
|
+
// there keeps the drag from painting a text range over the rows.
|
|
156
|
+
const handleRowMouseDown = useCallback(
|
|
157
|
+
(e: MouseEvent) => {
|
|
158
|
+
if (!isDesktop) return;
|
|
159
|
+
if (e.shiftKey) e.preventDefault();
|
|
160
|
+
},
|
|
161
|
+
[isDesktop],
|
|
162
|
+
);
|
|
163
|
+
|
|
135
164
|
const handleLongPress = useCallback(() => {
|
|
136
165
|
onLongPress?.(messageId);
|
|
137
166
|
}, [onLongPress, messageId]);
|
|
@@ -151,6 +180,24 @@ const MessageListItemComponent = ({
|
|
|
151
180
|
);
|
|
152
181
|
}, [queryClient, thread.messageId]);
|
|
153
182
|
|
|
183
|
+
const handleRowFocus = useCallback(() => {
|
|
184
|
+
prefetchMessage();
|
|
185
|
+
onFocusRow?.(messageId);
|
|
186
|
+
}, [prefetchMessage, onFocusRow, messageId]);
|
|
187
|
+
|
|
188
|
+
// Listbox semantics + roving tabindex, shared by both densities.
|
|
189
|
+
const rowInteractionProps = {
|
|
190
|
+
"data-message-row": true,
|
|
191
|
+
"data-message-id": messageId,
|
|
192
|
+
role: "option" as const,
|
|
193
|
+
"aria-selected": isChecked,
|
|
194
|
+
tabIndex: isTabStop ? 0 : -1,
|
|
195
|
+
onClick: handleRowClick,
|
|
196
|
+
onMouseDown: handleRowMouseDown,
|
|
197
|
+
onMouseEnter: prefetchMessage,
|
|
198
|
+
onFocus: handleRowFocus,
|
|
199
|
+
};
|
|
200
|
+
|
|
154
201
|
const unread = !thread.isRead;
|
|
155
202
|
|
|
156
203
|
if (density === "compact") {
|
|
@@ -162,13 +209,11 @@ const MessageListItemComponent = ({
|
|
|
162
209
|
...prev,
|
|
163
210
|
selectedMessageId: thread.messageId,
|
|
164
211
|
})}
|
|
165
|
-
|
|
166
|
-
onClick={handleRowClick}
|
|
167
|
-
onMouseEnter={prefetchMessage}
|
|
168
|
-
onFocus={prefetchMessage}
|
|
212
|
+
{...rowInteractionProps}
|
|
169
213
|
{...(!isDesktop && longPressHandlers.handlers)}
|
|
170
214
|
className={cn(
|
|
171
215
|
compactRowClass({ active: isSelected, focused: isFocused }),
|
|
216
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
|
|
172
217
|
isChecked && "bg-accent-soft",
|
|
173
218
|
!isDesktop && "min-h-11",
|
|
174
219
|
)}
|
|
@@ -189,14 +234,12 @@ const MessageListItemComponent = ({
|
|
|
189
234
|
...prev,
|
|
190
235
|
selectedMessageId: thread.messageId,
|
|
191
236
|
})}
|
|
192
|
-
|
|
193
|
-
onClick={handleRowClick}
|
|
194
|
-
onMouseEnter={prefetchMessage}
|
|
195
|
-
onFocus={prefetchMessage}
|
|
237
|
+
{...rowInteractionProps}
|
|
196
238
|
{...(!isDesktop && longPressHandlers.handlers)}
|
|
197
239
|
className={cn(
|
|
198
240
|
"group",
|
|
199
241
|
comfortableRowClass({ active: isSelected, focused: isFocused }),
|
|
242
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
|
|
200
243
|
isChecked && "bg-accent-soft",
|
|
201
244
|
!isDesktop && "min-h-11",
|
|
202
245
|
)}
|
|
@@ -222,6 +265,10 @@ const MessageListItemComponent = ({
|
|
|
222
265
|
/>
|
|
223
266
|
<button
|
|
224
267
|
type="button"
|
|
268
|
+
// Out of the tab order: the row is the list's single tab stop, and
|
|
269
|
+
// this control is `opacity-0` until hover, so a tabbable one would
|
|
270
|
+
// put focus on something invisible on every row.
|
|
271
|
+
tabIndex={-1}
|
|
225
272
|
onClick={handleCheckboxClick}
|
|
226
273
|
className={cn(
|
|
227
274
|
"absolute inset-0 size-7 rounded-full border items-center justify-center transition-opacity",
|
|
@@ -25,6 +25,10 @@ interface SwipeableMessageRowProps {
|
|
|
25
25
|
isSelected: boolean;
|
|
26
26
|
/** Roving keyboard focus cursor — renders the left accent rail (#429). */
|
|
27
27
|
isFocused?: boolean;
|
|
28
|
+
/** The one row in the tab order (roving tabindex). */
|
|
29
|
+
isTabStop?: boolean;
|
|
30
|
+
/** Called when the row takes DOM focus, so the cursor follows it. */
|
|
31
|
+
onFocusRow?: (messageId: string) => void;
|
|
28
32
|
isChecked: boolean;
|
|
29
33
|
onToggleCheck: (id: string) => void;
|
|
30
34
|
onRowSelect: (messageId: string, modifiers: SelectionModifiers) => boolean;
|
|
@@ -63,6 +67,8 @@ export const SwipeableMessageRow = ({
|
|
|
63
67
|
accountId,
|
|
64
68
|
isSelected,
|
|
65
69
|
isFocused,
|
|
70
|
+
isTabStop,
|
|
71
|
+
onFocusRow,
|
|
66
72
|
isChecked,
|
|
67
73
|
onToggleCheck,
|
|
68
74
|
onRowSelect,
|
|
@@ -104,6 +110,8 @@ export const SwipeableMessageRow = ({
|
|
|
104
110
|
accountId={accountId}
|
|
105
111
|
isSelected={isSelected}
|
|
106
112
|
isFocused={isFocused}
|
|
113
|
+
isTabStop={isTabStop}
|
|
114
|
+
onFocusRow={onFocusRow}
|
|
107
115
|
isChecked={isChecked}
|
|
108
116
|
onToggleCheck={onToggleCheck}
|
|
109
117
|
onRowSelect={onRowSelect}
|
|
@@ -66,13 +66,15 @@ export const ConfirmDialog = ({
|
|
|
66
66
|
if (!isOpen) return null;
|
|
67
67
|
|
|
68
68
|
return (
|
|
69
|
-
<div
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
@@ -15,12 +15,12 @@ const SEVERITY_STYLES: Record<
|
|
|
15
15
|
{ container: string; icon: string; title: string }
|
|
16
16
|
> = {
|
|
17
17
|
error: {
|
|
18
|
-
container: "border-danger/50 bg-danger
|
|
18
|
+
container: "border-danger/50 bg-danger-soft",
|
|
19
19
|
icon: "text-danger",
|
|
20
20
|
title: "text-danger",
|
|
21
21
|
},
|
|
22
22
|
warning: {
|
|
23
|
-
container: "border-warning/50 bg-warning
|
|
23
|
+
container: "border-warning/50 bg-warning-soft",
|
|
24
24
|
icon: "text-warning",
|
|
25
25
|
title: "text-warning",
|
|
26
26
|
},
|
|
@@ -61,7 +61,10 @@ export const ErrorBanner = ({
|
|
|
61
61
|
role={severity === "error" ? "alert" : "status"}
|
|
62
62
|
aria-live={severity === "error" ? "assertive" : "polite"}
|
|
63
63
|
className={cn(
|
|
64
|
-
|
|
64
|
+
// Opaque, not translucent: a banner overlaps the toolbar and the
|
|
65
|
+
// message list, and see-through text on top of see-through text is
|
|
66
|
+
// unreadable (issue #55).
|
|
67
|
+
"pointer-events-auto flex items-start gap-3 rounded-md border bg-canvas px-3 py-2 shadow-lg",
|
|
65
68
|
styles.container,
|
|
66
69
|
)}
|
|
67
70
|
>
|