@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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/auth/better-auth-config.ts +4 -1
  3. package/src/components/compose/ComposeForm.tsx +3 -0
  4. package/src/components/mail/IntelligencePane.tsx +10 -8
  5. package/src/components/mail/MailboxPane.tsx +53 -2
  6. package/src/components/mail/MessageActionMenu.tsx +18 -25
  7. package/src/components/mail/MessageDetail.tsx +1 -0
  8. package/src/components/mail/MessageList.tsx +196 -78
  9. package/src/components/mail/MessageListItem.tsx +60 -13
  10. package/src/components/mail/SwipeableMessageRow.tsx +8 -0
  11. package/src/components/ui/ConfirmDialog.tsx +9 -7
  12. package/src/components/ui/ErrorBanner.tsx +6 -3
  13. package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
  14. package/src/components/ui/ErrorBannerProvider.tsx +18 -0
  15. package/src/components/ui/error-banners.ts +9 -0
  16. package/src/hooks/useDeleteMessages.ts +14 -21
  17. package/src/hooks/useIntelligenceData.ts +14 -3
  18. package/src/hooks/useMarkAsRead.ts +14 -23
  19. package/src/hooks/useMessageBodyContent.ts +2 -1
  20. package/src/hooks/useMoveMessages.ts +14 -21
  21. package/src/hooks/useStaleAccountSync.test.ts +22 -1
  22. package/src/hooks/useToggleStar.test.ts +31 -0
  23. package/src/hooks/useToggleStar.ts +27 -31
  24. package/src/hooks/useTriageKeyboard.ts +13 -8
  25. package/src/hooks/useUpdateAddressFlags.ts +3 -1
  26. package/src/lib/api.ts +3 -1
  27. package/src/lib/client.ts +3 -0
  28. package/src/lib/error-classifier.test.ts +156 -8
  29. package/src/lib/error-classifier.ts +49 -9
  30. package/src/lib/keymap-dispatch.test.ts +78 -1
  31. package/src/lib/keymap-dispatch.ts +64 -7
  32. package/src/lib/keymap.ts +23 -0
  33. package/src/lib/list-focus.test.ts +25 -0
  34. package/src/lib/list-focus.ts +24 -0
  35. package/src/lib/network-error.ts +58 -0
  36. package/src/lib/query-error-handler.test.ts +6 -2
  37. package/src/lib/query-escalation.integration.test.ts +3 -2
  38. package/src/lib/sender-address.test.ts +60 -0
  39. package/src/lib/sender-address.ts +37 -0
  40. package/src/lib/thread-cache.test.ts +65 -0
  41. 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
- const handleDeleteKey = useCallback(() => {
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
- } else if (focusedMessageId) {
390
+ return true;
391
+ }
392
+ if (focusedMessageId) {
316
393
  requestDelete([focusedMessageId]);
394
+ return true;
317
395
  }
318
- }, [selectedCount, selectedIds, focusedMessageId, requestDelete]);
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
- }, [focusedMessageId, selectedIds, onTriageContextChange]);
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
- // Keyboard navigation. The dialog owns the keyboard while open, so list
545
- // shortcuts pause to avoid double-handling (e.g. a second Delete press).
546
- useKeyboardNavigation({
547
- enabled: !isLoading && threads.length > 0 && pendingDeleteIds === null,
548
- bindings: [
549
- // Focus movement (plain). requireShift:false so Shift+Arrow falls
550
- // through to the range-extend bindings below instead of also moving
551
- // focus without selecting. j/k move the roving cursor WITHOUT opening
552
- // the thread (#429) — only Enter / click open.
553
- { key: "j", handler: focusNext, preventDefault: true },
554
- {
555
- key: "ArrowDown",
556
- handler: focusNext,
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
- // Esc-to-clear-selection on the capture phase, enabled only when a
601
- // selection exists. Capture + stopPropagation makes a single Esc clear the
602
- // selection and consume the keypress, so the route-level Esc (go back) does
603
- // NOT also fire. With no selection this listener is disabled and Esc falls
604
- // through to the route as before. The ConfirmDialog (when open) registers
605
- // its own capture-phase Esc and pauses these list shortcuts, so its cancel
606
- // still wins first.
607
- useKeyboardNavigation({
608
- enabled: hasSelection && pendingDeleteIds === null,
609
- capture: true,
610
- bindings: [
611
- {
612
- key: "Escape",
613
- handler: clearSelection,
614
- preventDefault: true,
615
- stopPropagation: true,
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 ref={parentRef} className="flex-1 overflow-y-auto">
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 handled = onRowSelect(messageId, {
138
+ const modifiers = {
123
139
  shiftKey: e.shiftKey,
124
140
  metaKey: e.metaKey,
125
141
  ctrlKey: e.ctrlKey,
126
- });
127
- if (handled) {
128
- e.preventDefault();
129
- e.stopPropagation();
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
- data-message-row
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
- data-message-row
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
- 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
@@ -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/10 dark:bg-danger/20",
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/10",
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
- "pointer-events-auto flex items-start gap-3 rounded-md border bg-canvas/80 px-3 py-2 shadow-md backdrop-blur",
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
  >