@remit/web-client 0.0.14 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
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": {
@@ -43,6 +43,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
43
43
  import {
44
44
  createContext,
45
45
  type ReactNode,
46
+ type RefObject,
46
47
  useCallback,
47
48
  useContext,
48
49
  useEffect,
@@ -57,7 +58,10 @@ import { Drawer } from "@/components/layout/Drawer";
57
58
  import { ConversationView } from "@/components/mail/ConversationView";
58
59
  import { DraftsView } from "@/components/mail/DraftsView";
59
60
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
60
- import { MessageList } from "@/components/mail/MessageList";
61
+ import {
62
+ MessageList,
63
+ type MessageListCommands,
64
+ } from "@/components/mail/MessageList";
61
65
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
62
66
  import { PullToRefresh } from "@/components/mail/PullToRefresh";
63
67
  import { SpamRescue } from "@/components/mail/SpamRescue";
@@ -189,7 +193,11 @@ interface MailboxPaneContextValue {
189
193
  onTriageContextChange: (ctx: {
190
194
  focusedMessageId: string | undefined;
191
195
  selectedIds: string[];
196
+ hasList: boolean;
197
+ blocksKeyboard: boolean;
192
198
  }) => void;
199
+ /** Where the list publishes the commands the keyboard layer drives. */
200
+ listCommandsRef: RefObject<MessageListCommands | null>;
193
201
  onRetry: () => void;
194
202
  // Toolbar / reading pane actions
195
203
  toolbarComposeRequest: ComposeMode | null;
@@ -418,13 +426,25 @@ function MailboxPaneProvider({
418
426
  undefined,
419
427
  );
420
428
  const [triageSelectedIds, setTriageSelectedIds] = useState<string[]>([]);
429
+ // The mounted message list publishes its navigation/selection commands here.
430
+ // Null whenever no list is mounted (reading-only phone view, drafts) — the
431
+ // keyboard layer then simply has nothing to drive.
432
+ const listCommandsRef = useRef<MessageListCommands | null>(null);
433
+ // Whether a message list is mounted and serving commands, and whether it has
434
+ // a modal that owns the keyboard. Both drive which keys this route claims.
435
+ const [hasList, setHasList] = useState(false);
436
+ const [listBlocksKeyboard, setListBlocksKeyboard] = useState(false);
421
437
  const handleTriageContextChange = useCallback(
422
438
  (context: {
423
439
  focusedMessageId: string | undefined;
424
440
  selectedIds: string[];
441
+ hasList: boolean;
442
+ blocksKeyboard: boolean;
425
443
  }) => {
426
444
  setTriageFocusedId(context.focusedMessageId);
427
445
  setTriageSelectedIds(context.selectedIds);
446
+ setHasList(context.hasList);
447
+ setListBlocksKeyboard(context.blocksKeyboard);
428
448
  },
429
449
  [],
430
450
  );
@@ -583,7 +603,10 @@ function MailboxPaneProvider({
583
603
  closeCompose,
584
604
  ]);
585
605
 
606
+ // Esc unwinds one step at a time: an active selection first, then the open
607
+ // thread.
586
608
  const goBack = useCallback(() => {
609
+ if (listCommandsRef.current?.clearSelection()) return;
587
610
  if (selectedMessageId) {
588
611
  navigate({
589
612
  to: "/mail/$mailboxId",
@@ -670,7 +693,11 @@ function MailboxPaneProvider({
670
693
  return messageIdsForFocusedThread(focusedThread);
671
694
  }, [triageSelectedIds, messageIdsForFocusedThread, focusedThread]);
672
695
 
696
+ // Prefer the list's delete: it confirms the move to Trash and then places the
697
+ // cursor on a surviving row. Falls back to the reading pane's message when no
698
+ // list is mounted or nothing in it is targeted.
673
699
  const triageDeleteAction = useCallback(() => {
700
+ if (listCommandsRef.current?.requestDelete()) return;
674
701
  const ids = triageTargetMessageIds();
675
702
  if (ids.length > 0) triageDelete(ids);
676
703
  }, [triageTargetMessageIds, triageDelete]);
@@ -764,8 +791,29 @@ function MailboxPaneProvider({
764
791
  const showIntelligence = isDesktop && intelligenceOpen && hasThread;
765
792
 
766
793
  useTriageKeyboard({
767
- enabled: !composeState.isOpen,
794
+ // A modal owns the keyboard outright. Suspending the layer is what keeps a
795
+ // second Delete press from reaching a delete while the confirmation for
796
+ // the first one is still on screen.
797
+ enabled: !composeState.isOpen && !listBlocksKeyboard,
768
798
  handlers: {
799
+ // List navigation and selection, registered only while a list is
800
+ // mounted to serve them. An unregistered action is never
801
+ // preventDefault-ed, so with no list the browser keeps Enter, Space and
802
+ // ⌘A — select-all-text in the reading pane still works.
803
+ ...(hasList
804
+ ? {
805
+ focusNext: () => listCommandsRef.current?.focusNext(),
806
+ focusPrevious: () => listCommandsRef.current?.focusPrevious(),
807
+ focusFirst: () => listCommandsRef.current?.focusFirst(),
808
+ focusLast: () => listCommandsRef.current?.focusLast(),
809
+ openFocused: () => listCommandsRef.current?.openFocused(),
810
+ toggleSelect: () => listCommandsRef.current?.toggleSelect(),
811
+ extendSelectDown: () => listCommandsRef.current?.extendSelectDown(),
812
+ extendSelectUp: () => listCommandsRef.current?.extendSelectUp(),
813
+ selectAll: () => listCommandsRef.current?.selectAll(),
814
+ toggleDensity: () => listCommandsRef.current?.toggleDensity(),
815
+ }
816
+ : {}),
769
817
  back: goBack,
770
818
  reply: triageReply,
771
819
  replyAll: triageReplyAll,
@@ -831,6 +879,7 @@ function MailboxPaneProvider({
831
879
  hasMore: hasNextPage,
832
880
  isLoadingMore: isFetchingNextPage,
833
881
  onTriageContextChange: handleTriageContextChange,
882
+ listCommandsRef,
834
883
  onRetry: () => refetch(),
835
884
  toolbarComposeRequest,
836
885
  onToolbarReply: handleToolbarReply,
@@ -884,6 +933,7 @@ function MailboxList() {
884
933
  isSpamFolder,
885
934
  rescueCandidates,
886
935
  onTriageContextChange,
936
+ listCommandsRef,
887
937
  onRetry,
888
938
  filterCategory,
889
939
  filterAttributes,
@@ -973,6 +1023,7 @@ function MailboxList() {
973
1023
  listTitle={listTitle}
974
1024
  hideHeader
975
1025
  onTriageContextChange={onTriageContextChange}
1026
+ commandsRef={listCommandsRef}
976
1027
  />
977
1028
  );
978
1029
 
@@ -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
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
2
2
  import type { TriageAction } from "@/lib/keymap";
3
3
  import {
4
4
  dispatchKey,
5
+ isControlTarget,
5
6
  isEditableTarget,
6
7
  type SequencePrefix,
7
8
  } from "@/lib/keymap-dispatch";
@@ -27,14 +28,17 @@ interface UseTriageKeyboardOptions {
27
28
  * even Esc is left to the focused field's own handler) and carrying the `g …`
28
29
  * go-to sequence prefix across keystrokes with a timeout.
29
30
  *
30
- * Routing is intentionally NOT fully unified yet: this hook owns the action
31
- * verbs + go-to keys, while the list-local navigation keys (j/k/x/Enter/d,
32
- * Shift+arrow, Delete/Backspace) are still routed by MessageList's legacy
33
- * `useKeyboardNavigation`, `/` by SearchBar, and `?` by the mail layout three
34
- * separate window listeners, each with its own (consistent) input-guard. The
35
- * `@/lib/keymap` module is the single source of truth for the DISPLAYED
36
- * bindings (the `?` overlay + tooltips), but not yet for routing. Consolidating
37
- * every binding through `dispatchKey` is a follow-up; see #429.
31
+ * List navigation and selection route through here and nowhere else: the
32
+ * message list publishes its commands upward (see `MessageListCommands`) and
33
+ * the route wires them into the handler table, so `@/lib/keymap` is the source
34
+ * of truth for both the displayed bindings and the routed ones. The list used
35
+ * to run a second window listener claiming the same keys, which is what made
36
+ * Enter unusable on every focused button in the app (#43).
37
+ *
38
+ * Other window-level keydown listeners still exist for keys this layer does not
39
+ * own — `?` at the mail layout, `/` in SearchBar, Esc in the compose and
40
+ * conversation views. They bind disjoint keys; only the list's competing
41
+ * listener was removed.
38
42
  *
39
43
  * Per-action targeting (focused row vs selection) and the actual mutations live
40
44
  * in the handlers the caller passes in — this hook only dispatches.
@@ -70,6 +74,7 @@ export function useTriageKeyboard({
70
74
  ctrlKey: event.ctrlKey,
71
75
  altKey: event.altKey,
72
76
  inEditable: isEditableTarget(event.target),
77
+ onControl: isControlTarget(event.target),
73
78
  },
74
79
  prefixRef.current,
75
80
  );
@@ -12,6 +12,7 @@ const stroke = (partial: Partial<KeyStroke> & { key: string }): KeyStroke => ({
12
12
  ctrlKey: false,
13
13
  altKey: false,
14
14
  inEditable: false,
15
+ onControl: false,
15
16
  ...partial,
16
17
  });
17
18
 
@@ -61,6 +62,82 @@ describe("dispatchKey — plain bindings", () => {
61
62
  });
62
63
  });
63
64
 
65
+ describe("dispatchKey — list navigation", () => {
66
+ const cases: Array<[string, string]> = [
67
+ ["ArrowDown", "focusNext"],
68
+ ["ArrowUp", "focusPrevious"],
69
+ ["Home", "focusFirst"],
70
+ ["End", "focusLast"],
71
+ [" ", "toggleSelect"],
72
+ ["Delete", "delete"],
73
+ ["Backspace", "delete"],
74
+ ];
75
+
76
+ for (const [key, action] of cases) {
77
+ test(`'${key}' → ${action}`, () => {
78
+ const result = run({ key });
79
+ assert.strictEqual(result.action, action);
80
+ assert.strictEqual(result.preventDefault, true);
81
+ });
82
+ }
83
+
84
+ test("Shift+Arrow extends the selection instead of moving the cursor", () => {
85
+ assert.strictEqual(
86
+ run({ key: "ArrowDown", shiftKey: true }).action,
87
+ "extendSelectDown",
88
+ );
89
+ assert.strictEqual(
90
+ run({ key: "ArrowUp", shiftKey: true }).action,
91
+ "extendSelectUp",
92
+ );
93
+ });
94
+
95
+ test("⌘A / Ctrl+A selects every loaded row", () => {
96
+ assert.strictEqual(run({ key: "a", metaKey: true }).action, "selectAll");
97
+ assert.strictEqual(run({ key: "a", ctrlKey: true }).action, "selectAll");
98
+ assert.strictEqual(
99
+ run({ key: "a", metaKey: true }).preventDefault,
100
+ true,
101
+ "the browser's select-all-text default must be replaced",
102
+ );
103
+ });
104
+ });
105
+
106
+ describe("dispatchKey — controls keep their activation keys", () => {
107
+ // The regression behind #43: a global Enter binding cancelled the default
108
+ // action of whatever the user had tabbed to, so no button in the app could
109
+ // be activated from the keyboard.
110
+ test("Enter on a focused control is left to the control", () => {
111
+ const result = run({ key: "Enter", onControl: true });
112
+ assert.strictEqual(result.action, null);
113
+ assert.strictEqual(result.preventDefault, false);
114
+ });
115
+
116
+ test("Space on a focused control is left to the control", () => {
117
+ const result = run({ key: " ", onControl: true });
118
+ assert.strictEqual(result.action, null);
119
+ assert.strictEqual(result.preventDefault, false);
120
+ });
121
+
122
+ test("Enter and Space still drive the list away from a control", () => {
123
+ assert.strictEqual(run({ key: "Enter" }).action, "openFocused");
124
+ assert.strictEqual(run({ key: " " }).action, "toggleSelect");
125
+ });
126
+
127
+ test("non-activation keys still fire from a focused control", () => {
128
+ // Tabbing to a toolbar button must not strand the rest of the keymap.
129
+ assert.strictEqual(run({ key: "j", onControl: true }).action, "focusNext");
130
+ assert.strictEqual(run({ key: "r", onControl: true }).action, "reply");
131
+ });
132
+
133
+ test("an Enter released to a control still cancels a pending g prefix", () => {
134
+ assert.strictEqual(
135
+ run({ key: "Enter", onControl: true }, "g").nextPrefix,
136
+ null,
137
+ );
138
+ });
139
+ });
140
+
64
141
  describe("dispatchKey — input suppression", () => {
65
142
  test("plain keys are inert in an editable surface", () => {
66
143
  assert.strictEqual(run({ key: "j", inEditable: true }).action, null);
@@ -98,8 +175,8 @@ describe("dispatchKey — modifiers", () => {
98
175
  });
99
176
 
100
177
  test("other meta combos are left to the browser", () => {
101
- assert.strictEqual(run({ key: "a", metaKey: true }).action, null);
102
178
  assert.strictEqual(run({ key: "c", metaKey: true }).action, null);
179
+ assert.strictEqual(run({ key: "f", metaKey: true }).action, null);
103
180
  });
104
181
 
105
182
  test("Shift+J / Shift+K extend the selection", () => {
@@ -19,6 +19,15 @@ export interface KeyStroke {
19
19
  altKey: boolean;
20
20
  /** Whether the event originated inside an editable surface. */
21
21
  inEditable: boolean;
22
+ /**
23
+ * Whether the event originated on an activatable control that is not a
24
+ * message-list row — a button, link, `role="button"`, `<summary>`. Enter and
25
+ * Space are that control's activation keys, so the layer releases them; every
26
+ * other binding still fires. Without this a global Enter binding cancels the
27
+ * default action of whatever the user tabbed to, and no button in the app can
28
+ * be activated from the keyboard.
29
+ */
30
+ onControl: boolean;
22
31
  }
23
32
 
24
33
  /** Pending sequence-prefix state. `"g"` means a `g` was pressed recently. */
@@ -69,7 +78,14 @@ interface PlainBinding {
69
78
  const PLAIN_BINDINGS: Record<string, PlainBinding> = {
70
79
  j: { action: "focusNext" },
71
80
  k: { action: "focusPrevious" },
81
+ arrowdown: { action: "focusNext" },
82
+ arrowup: { action: "focusPrevious" },
83
+ home: { action: "focusFirst" },
84
+ end: { action: "focusLast" },
72
85
  enter: { action: "openFocused" },
86
+ " ": { action: "toggleSelect" },
87
+ delete: { action: "delete" },
88
+ backspace: { action: "delete" },
73
89
  u: { action: "toggleRead" },
74
90
  x: { action: "toggleSelect" },
75
91
  r: { action: "reply" },
@@ -118,12 +134,26 @@ export function dispatchKey(
118
134
  return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
119
135
  }
120
136
 
137
+ // Enter / Space belong to whatever control has focus. Releasing them here is
138
+ // what keeps Tab-to-a-button-then-Enter working anywhere in the app; the
139
+ // list's own rows are excluded from `onControl`, so the roving cursor still
140
+ // opens and selects.
141
+ if (stroke.onControl && (lower === "enter" || lower === " ")) {
142
+ return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
143
+ }
144
+
121
145
  // ⌘N / Ctrl+N → compose. Checked before the prefix/plain tables so the
122
146
  // browser's "new window" is the only thing we intercept among meta combos.
123
147
  if (meta && lower === "n") {
124
148
  return { action: "compose", nextPrefix: null, preventDefault: true };
125
149
  }
126
150
 
151
+ // ⌘A / Ctrl+A → select every loaded row, replacing the browser's
152
+ // select-all-text default.
153
+ if (meta && lower === "a") {
154
+ return { action: "selectAll", nextPrefix: null, preventDefault: true };
155
+ }
156
+
127
157
  // Any other meta/ctrl combo is left to the browser/OS.
128
158
  if (meta) return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
129
159
 
@@ -145,13 +175,17 @@ export function dispatchKey(
145
175
  return { action: null, nextPrefix: "g", preventDefault: true };
146
176
  }
147
177
 
148
- // Shift+J / Shift+K extend the selection.
149
- if (stroke.shiftKey && (lower === "j" || lower === "k")) {
150
- return {
151
- action: lower === "j" ? "extendSelectDown" : "extendSelectUp",
152
- nextPrefix: null,
153
- preventDefault: true,
154
- };
178
+ // Shift+J/K and Shift+Arrow extend the selection.
179
+ if (stroke.shiftKey) {
180
+ const down = lower === "j" || lower === "arrowdown";
181
+ const up = lower === "k" || lower === "arrowup";
182
+ if (down || up) {
183
+ return {
184
+ action: down ? "extendSelectDown" : "extendSelectUp",
185
+ nextPrefix: null,
186
+ preventDefault: true,
187
+ };
188
+ }
155
189
  }
156
190
 
157
191
  // Plain single-key bindings.
@@ -185,3 +219,26 @@ export function isEditableTarget(target: EventTarget | null): boolean {
185
219
  target.isContentEditable
186
220
  );
187
221
  }
222
+
223
+ /**
224
+ * Marks an element as a message-list row. Rows are anchors, so they would
225
+ * otherwise read as ordinary controls; the list owns Enter/Space on them.
226
+ */
227
+ export const ROW_ATTRIBUTE = "data-message-row";
228
+
229
+ const CONTROL_SELECTOR =
230
+ 'button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"], [role="switch"], [role="checkbox"]';
231
+
232
+ /**
233
+ * Whether the event target sits inside an activatable control that is not a
234
+ * message-list row. See {@link KeyStroke.onControl}.
235
+ */
236
+ export function isControlTarget(target: EventTarget | null): boolean {
237
+ if (!(target instanceof HTMLElement)) return false;
238
+ const control = target.closest(CONTROL_SELECTOR);
239
+ if (!control) return false;
240
+ // The nearest control wins. A row is an anchor and so matches the selector,
241
+ // but the list owns Enter/Space on its rows; a control nested *inside* a row
242
+ // (the select checkbox) is a control like any other and keeps its own keys.
243
+ return !control.hasAttribute(ROW_ATTRIBUTE);
244
+ }
package/src/lib/keymap.ts CHANGED
@@ -13,12 +13,15 @@ export type TriageAction =
13
13
  // list navigation / focus model
14
14
  | "focusNext"
15
15
  | "focusPrevious"
16
+ | "focusFirst"
17
+ | "focusLast"
16
18
  | "openFocused"
17
19
  | "back"
18
20
  // selection
19
21
  | "toggleSelect"
20
22
  | "extendSelectDown"
21
23
  | "extendSelectUp"
24
+ | "selectAll"
22
25
  // message verbs (focused row / selection)
23
26
  | "reply"
24
27
  | "replyAll"
@@ -76,6 +79,14 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
76
79
  keys: ["k"],
77
80
  description: "Focus previous message",
78
81
  },
82
+ { action: "focusNext", keys: ["↓"], description: "Focus next message" },
83
+ {
84
+ action: "focusPrevious",
85
+ keys: ["↑"],
86
+ description: "Focus previous message",
87
+ },
88
+ { action: "focusFirst", keys: ["Home"], description: "Focus first" },
89
+ { action: "focusLast", keys: ["End"], description: "Focus last" },
79
90
  {
80
91
  action: "openFocused",
81
92
  keys: ["Enter"],
@@ -88,6 +99,7 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
88
99
  title: "Selection",
89
100
  hints: [
90
101
  { action: "toggleSelect", keys: ["x"], description: "Toggle select" },
102
+ { action: "toggleSelect", keys: ["Space"], description: "Toggle select" },
91
103
  {
92
104
  action: "extendSelectDown",
93
105
  keys: ["Shift", "j"],
@@ -98,6 +110,17 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
98
110
  keys: ["Shift", "k"],
99
111
  description: "Extend selection up",
100
112
  },
113
+ {
114
+ action: "extendSelectDown",
115
+ keys: ["Shift", "↓"],
116
+ description: "Extend selection down",
117
+ },
118
+ {
119
+ action: "extendSelectUp",
120
+ keys: ["Shift", "↑"],
121
+ description: "Extend selection up",
122
+ },
123
+ { action: "selectAll", keys: ["⌘", "A"], description: "Select all" },
101
124
  ],
102
125
  },
103
126
  {
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { tabStopId } from "./list-focus.ts";
4
+
5
+ const ids = ["a", "b", "c"];
6
+
7
+ describe("tabStopId", () => {
8
+ test("the cursor row holds the tab stop", () => {
9
+ assert.strictEqual(tabStopId(ids, "b"), "b");
10
+ });
11
+
12
+ test("an untouched list puts the tab stop on the first row", () => {
13
+ assert.strictEqual(tabStopId(ids, undefined), "a");
14
+ });
15
+
16
+ test("a cursor that no longer exists falls back to the first row", () => {
17
+ // After a delete or a refetch the cursor can name a row that is gone; the
18
+ // list must keep a tab stop or Tab skips over it entirely.
19
+ assert.strictEqual(tabStopId(ids, "zzz"), "a");
20
+ });
21
+
22
+ test("an empty list has no tab stop", () => {
23
+ assert.strictEqual(tabStopId([], "a"), undefined);
24
+ });
25
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Roving-tabindex math for the message list.
3
+ *
4
+ * A virtualized list of hundreds of rows must expose exactly one tab stop:
5
+ * Tab then moves focus into the list at the keyboard cursor and Shift+Tab moves
6
+ * back out to the side panel, instead of walking every row. Pure so the rule is
7
+ * testable without a DOM.
8
+ */
9
+
10
+ /**
11
+ * The id of the row that holds the list's tab stop. The keyboard cursor owns it
12
+ * while it points at a loaded row; otherwise the first row does, so an untouched
13
+ * list is still reachable with one Tab.
14
+ */
15
+ export function tabStopId(
16
+ orderedIds: string[],
17
+ focusedId: string | undefined,
18
+ ): string | undefined {
19
+ if (orderedIds.length === 0) return undefined;
20
+ if (focusedId !== undefined && orderedIds.includes(focusedId)) {
21
+ return focusedId;
22
+ }
23
+ return orderedIds[0];
24
+ }