@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 +1 -1
- package/src/components/mail/MailboxPane.tsx +53 -2
- 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/hooks/useTriageKeyboard.ts +13 -8
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
149
|
-
if (stroke.shiftKey
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
}
|