@remit/web-client 0.0.41 → 0.0.43
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/DailyBrief.tsx +2 -0
- package/src/components/mail/FlaggedList.tsx +7 -2
- package/src/components/mail/MessageList.selection.test.ts +52 -0
- package/src/components/mail/MessageList.tsx +61 -33
- package/src/components/mail/OutboxPane.tsx +13 -3
- package/src/lib/selection-mode.test.ts +44 -0
- package/src/lib/selection-mode.ts +29 -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.43",
|
|
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": {
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type FilterSheetProps,
|
|
32
32
|
type FilterSheetSource,
|
|
33
33
|
KeyboardHintBar,
|
|
34
|
+
LIST_ROW_ATTRIBUTE,
|
|
34
35
|
type SearchResult,
|
|
35
36
|
type ThreadRowData,
|
|
36
37
|
type ThreadSection,
|
|
@@ -140,6 +141,7 @@ const BriefRow = ({
|
|
|
140
141
|
return (
|
|
141
142
|
<button
|
|
142
143
|
type="button"
|
|
144
|
+
{...LIST_ROW_ATTRIBUTE}
|
|
143
145
|
onClick={onClick}
|
|
144
146
|
className={cn("group w-full", comfortableRowClass({ active }))}
|
|
145
147
|
>
|
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
import {
|
|
17
17
|
ComfortableRow,
|
|
18
18
|
flaggedFilterConfig,
|
|
19
|
+
LIST_ROW_SELECTOR,
|
|
19
20
|
MessageListPane,
|
|
20
21
|
type ThreadRowData,
|
|
22
|
+
useRovingFocus,
|
|
21
23
|
} from "@remit/ui";
|
|
22
|
-
import { useCallback, useMemo, useState } from "react";
|
|
24
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
23
25
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
24
26
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
25
27
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
@@ -54,6 +56,9 @@ export function FlaggedList({
|
|
|
54
56
|
const tokenContext = useSearchTokenContext();
|
|
55
57
|
const isDesktop = useIsDesktop();
|
|
56
58
|
|
|
59
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
60
|
+
useRovingFocus({ containerRef: listRef, itemSelector: LIST_ROW_SELECTOR });
|
|
61
|
+
|
|
57
62
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
|
58
63
|
const [activeFilters, setActiveFilters] = useState<ReadonlySet<string>>(
|
|
59
64
|
new Set(),
|
|
@@ -130,7 +135,7 @@ export function FlaggedList({
|
|
|
130
135
|
}, []);
|
|
131
136
|
|
|
132
137
|
const listBody = (
|
|
133
|
-
<div className="flex-1 overflow-y-auto">
|
|
138
|
+
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
134
139
|
<div className="divide-y divide-line">
|
|
135
140
|
{rows.map((thread) => (
|
|
136
141
|
<ComfortableRow
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Selection mode has one source of truth and one exit (#115). `MessageList`
|
|
9
|
+
* wires the DOM, the virtualizer, routing and several data hooks together, so
|
|
10
|
+
* — as with this package's other component-level rules (see
|
|
11
|
+
* `../../index.css.test.ts`) — the rule is enforced by reading the source
|
|
12
|
+
* rather than rendering the tree. The decisions themselves are unit-tested in
|
|
13
|
+
* `../../lib/selection-mode.test.ts`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
|
|
18
|
+
|
|
19
|
+
describe("MessageList selection mode", () => {
|
|
20
|
+
it("keeps no selection-mode flag of its own", () => {
|
|
21
|
+
assert.doesNotMatch(source, /useState[^\n]*[iI]sMultiSelectMode/);
|
|
22
|
+
assert.doesNotMatch(source, /setIsMultiSelectMode/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("derives the mode from the selection count", () => {
|
|
26
|
+
assert.match(
|
|
27
|
+
source,
|
|
28
|
+
/const isMultiSelectMode = deriveIsMultiSelectMode\(\s*selectedCount,/,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("calls the selection hook's clearSelection from exactly one place", () => {
|
|
33
|
+
const calls = source.match(/\bclearSelection\(\)/g) ?? [];
|
|
34
|
+
assert.equal(calls.length, 1);
|
|
35
|
+
assert.match(
|
|
36
|
+
source,
|
|
37
|
+
/const exitSelection = useCallback\(\(\) => \{[^}]*clearSelection\(\);/,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("exits selection when the mailbox changes", () => {
|
|
42
|
+
assert.match(source, /previousMailboxIdRef\.current === mailboxId/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("turns the back gesture into an exit, and only while selecting", () => {
|
|
46
|
+
assert.match(
|
|
47
|
+
source,
|
|
48
|
+
/shouldExitSelectionOnNavigate\(action, hasSelection\)/,
|
|
49
|
+
);
|
|
50
|
+
assert.match(source, /disabled: !hasSelection/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
MessageListPane,
|
|
6
6
|
SelectionTopBar,
|
|
7
7
|
} from "@remit/ui";
|
|
8
|
-
import { useNavigate } from "@tanstack/react-router";
|
|
8
|
+
import { useBlocker, useNavigate } from "@tanstack/react-router";
|
|
9
9
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
10
10
|
import { Search, Sparkles } from "lucide-react";
|
|
11
11
|
import type { RefObject } from "react";
|
|
@@ -34,6 +34,10 @@ import {
|
|
|
34
34
|
} from "@/lib/escalation-label";
|
|
35
35
|
import { formatDeleteToTrashTitle, formatNumber } from "@/lib/format";
|
|
36
36
|
import { tabStopId } from "@/lib/list-focus";
|
|
37
|
+
import {
|
|
38
|
+
deriveIsMultiSelectMode,
|
|
39
|
+
shouldExitSelectionOnNavigate,
|
|
40
|
+
} from "@/lib/selection-mode";
|
|
37
41
|
import { cn } from "@/lib/utils";
|
|
38
42
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
39
43
|
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
@@ -200,7 +204,6 @@ export const MessageList = ({
|
|
|
200
204
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
201
205
|
const navigate = useNavigate();
|
|
202
206
|
const isDesktop = useIsDesktop();
|
|
203
|
-
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
|
204
207
|
const [organizeOpen, setOrganizeOpen] = useState(false);
|
|
205
208
|
const isSearching = !!searchQuery?.trim();
|
|
206
209
|
|
|
@@ -247,6 +250,11 @@ export const MessageList = ({
|
|
|
247
250
|
intersectWith,
|
|
248
251
|
} = useSelection();
|
|
249
252
|
|
|
253
|
+
// The selection count is the only source of truth for whether the list is
|
|
254
|
+
// in multi-select mode (#115). A separate flag needs an effect to reconcile
|
|
255
|
+
// it back to the count, and across that render the two disagree.
|
|
256
|
+
const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
|
|
257
|
+
|
|
250
258
|
// Pending delete, awaiting confirmation. `null` means the dialog is closed.
|
|
251
259
|
// `source: "ids"` snapshots the concrete ids at request time so a selection
|
|
252
260
|
// change behind the dialog can't retarget the delete — every keyboard/desktop
|
|
@@ -274,6 +282,18 @@ export const MessageList = ({
|
|
|
274
282
|
predicateKey,
|
|
275
283
|
searchQuery: searchPredicate ?? {},
|
|
276
284
|
});
|
|
285
|
+
|
|
286
|
+
// The one way selection mode ends (#115): cancel, a completed delete or
|
|
287
|
+
// move, a plain click that collapses a range, switching mailboxes, the back
|
|
288
|
+
// gesture. Nothing calls the selection hook's `clearSelection` directly, so
|
|
289
|
+
// an escalated selection can never survive the exit as a stale phase.
|
|
290
|
+
const escalationPhaseKind = escalation.phase.kind;
|
|
291
|
+
const clearEscalation = escalation.clear;
|
|
292
|
+
const exitSelection = useCallback(() => {
|
|
293
|
+
if (escalationPhaseKind === "escalated") clearEscalation();
|
|
294
|
+
clearSelection();
|
|
295
|
+
}, [clearSelection, clearEscalation, escalationPhaseKind]);
|
|
296
|
+
|
|
277
297
|
// Non-null exactly once a bounded/escalated run just ended with some ids
|
|
278
298
|
// still not confirmed deleted: the count that DID succeed in that run, so
|
|
279
299
|
// the partial-failure notice can say "N moved to Trash" alongside "Retry".
|
|
@@ -316,13 +336,6 @@ export const MessageList = ({
|
|
|
316
336
|
const commandsAvailable = !isLoading && threads.length > 0;
|
|
317
337
|
const confirmOpen = pendingDelete !== null;
|
|
318
338
|
|
|
319
|
-
// Auto-exit multi-select when selection becomes empty
|
|
320
|
-
useEffect(() => {
|
|
321
|
-
if (isMultiSelectMode && selectedCount === 0) {
|
|
322
|
-
setIsMultiSelectMode(false);
|
|
323
|
-
}
|
|
324
|
-
}, [isMultiSelectMode, selectedCount]);
|
|
325
|
-
|
|
326
339
|
// Single choke point for what selection looks like once a chunked/escalated
|
|
327
340
|
// run ends, for any reason (issue #92 requirement 10). A clean run with
|
|
328
341
|
// nothing left over exits selection mode and hands off to a transient
|
|
@@ -338,7 +351,7 @@ export const MessageList = ({
|
|
|
338
351
|
`${formatNumber(outcome.done)} moved to Trash. Your mail server is still catching up.`,
|
|
339
352
|
);
|
|
340
353
|
setLastRunSucceeded(null);
|
|
341
|
-
|
|
354
|
+
exitSelection();
|
|
342
355
|
return;
|
|
343
356
|
}
|
|
344
357
|
if (retryIds.length > 0) {
|
|
@@ -348,7 +361,7 @@ export const MessageList = ({
|
|
|
348
361
|
}
|
|
349
362
|
setLastRunSucceeded(null);
|
|
350
363
|
},
|
|
351
|
-
[
|
|
364
|
+
[exitSelection, selectAll],
|
|
352
365
|
);
|
|
353
366
|
|
|
354
367
|
const virtualizer = useVirtualizer({
|
|
@@ -437,7 +450,7 @@ export const MessageList = ({
|
|
|
437
450
|
// Plain click: collapse any multi-selection and let navigation proceed.
|
|
438
451
|
// The clicked row becomes the next anchor for a subsequent shift-click,
|
|
439
452
|
// but is NOT added to the checkbox set (no toolbar on a plain open).
|
|
440
|
-
|
|
453
|
+
exitSelection();
|
|
441
454
|
setAnchor(messageId);
|
|
442
455
|
return false;
|
|
443
456
|
},
|
|
@@ -446,7 +459,7 @@ export const MessageList = ({
|
|
|
446
459
|
focusedMessageId,
|
|
447
460
|
selectRange,
|
|
448
461
|
toggleCheck,
|
|
449
|
-
|
|
462
|
+
exitSelection,
|
|
450
463
|
setAnchor,
|
|
451
464
|
],
|
|
452
465
|
);
|
|
@@ -611,7 +624,7 @@ export const MessageList = ({
|
|
|
611
624
|
}
|
|
612
625
|
|
|
613
626
|
onDeleteMessages?.(ids);
|
|
614
|
-
|
|
627
|
+
exitSelection();
|
|
615
628
|
focusBeforeConfirmRef.current = null;
|
|
616
629
|
setPendingDelete(null);
|
|
617
630
|
|
|
@@ -632,7 +645,7 @@ export const MessageList = ({
|
|
|
632
645
|
pendingDelete,
|
|
633
646
|
threads,
|
|
634
647
|
onDeleteMessages,
|
|
635
|
-
|
|
648
|
+
exitSelection,
|
|
636
649
|
navigate,
|
|
637
650
|
mailboxId,
|
|
638
651
|
runChunkedConfirmDelete,
|
|
@@ -664,9 +677,9 @@ export const MessageList = ({
|
|
|
664
677
|
(destinationMailboxId: string) => {
|
|
665
678
|
if (!onMoveMessages || selectedCount === 0) return;
|
|
666
679
|
onMoveMessages(Array.from(selectedIds), destinationMailboxId);
|
|
667
|
-
|
|
680
|
+
exitSelection();
|
|
668
681
|
},
|
|
669
|
-
[onMoveMessages, selectedCount, selectedIds,
|
|
682
|
+
[onMoveMessages, selectedCount, selectedIds, exitSelection],
|
|
670
683
|
);
|
|
671
684
|
|
|
672
685
|
// Cross-account guard: every selected thread row must belong to the
|
|
@@ -708,23 +721,17 @@ export const MessageList = ({
|
|
|
708
721
|
[toggleReadFor],
|
|
709
722
|
);
|
|
710
723
|
|
|
711
|
-
// Mobile:
|
|
724
|
+
// Mobile: a long press enters multi-select mode by starting a selection —
|
|
725
|
+
// the mode follows from the count, there is no flag to set.
|
|
712
726
|
const handleLongPress = useCallback(
|
|
713
727
|
(messageId: string) => {
|
|
714
728
|
if (!isDesktop) {
|
|
715
|
-
setIsMultiSelectMode(true);
|
|
716
729
|
select(messageId);
|
|
717
730
|
}
|
|
718
731
|
},
|
|
719
732
|
[isDesktop, select],
|
|
720
733
|
);
|
|
721
734
|
|
|
722
|
-
// Cancel multi-select mode
|
|
723
|
-
const handleCancelMultiSelect = useCallback(() => {
|
|
724
|
-
setIsMultiSelectMode(false);
|
|
725
|
-
clearSelection();
|
|
726
|
-
}, [clearSelection]);
|
|
727
|
-
|
|
728
735
|
// Mobile bar's Trash tap: an escalated selection has no materialized ids to
|
|
729
736
|
// hand `requestDelete`, so it opens the predicate confirmation instead.
|
|
730
737
|
const handleMobileDelete = useCallback(() => {
|
|
@@ -745,11 +752,8 @@ export const MessageList = ({
|
|
|
745
752
|
escalation.stop();
|
|
746
753
|
return;
|
|
747
754
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
}
|
|
751
|
-
handleCancelMultiSelect();
|
|
752
|
-
}, [escalation, handleCancelMultiSelect]);
|
|
755
|
+
exitSelection();
|
|
756
|
+
}, [escalation, exitSelection]);
|
|
753
757
|
|
|
754
758
|
// The escalation notice's "Clear selection" action: drop back to the
|
|
755
759
|
// bounded (all-loaded) selection without touching selection mode itself.
|
|
@@ -861,6 +865,30 @@ export const MessageList = ({
|
|
|
861
865
|
intersectWith(threads.map((t) => t.messageId));
|
|
862
866
|
}, [threads, intersectWith, escalation.phase, escalation.isDeleting]);
|
|
863
867
|
|
|
868
|
+
// Switching mailboxes exits selection outright, instead of leaving it to the
|
|
869
|
+
// intersect effect above to empty the set by coincidence — a different
|
|
870
|
+
// mailbox's threads happen to share none of the old ids.
|
|
871
|
+
const previousMailboxIdRef = useRef(mailboxId);
|
|
872
|
+
useEffect(() => {
|
|
873
|
+
if (previousMailboxIdRef.current === mailboxId) return;
|
|
874
|
+
previousMailboxIdRef.current = mailboxId;
|
|
875
|
+
exitSelection();
|
|
876
|
+
}, [mailboxId, exitSelection]);
|
|
877
|
+
|
|
878
|
+
// The back gesture (Android's back button, the browser's back) leaves
|
|
879
|
+
// selection mode rather than the route. Only `BACK` is intercepted, so a
|
|
880
|
+
// navigation the app itself starts — opening a message, switching mailboxes
|
|
881
|
+
// — is never blocked, and the blocker is off entirely with nothing selected.
|
|
882
|
+
useBlocker({
|
|
883
|
+
shouldBlockFn: ({ action }) => {
|
|
884
|
+
if (!shouldExitSelectionOnNavigate(action, hasSelection)) return false;
|
|
885
|
+
exitSelection();
|
|
886
|
+
return true;
|
|
887
|
+
},
|
|
888
|
+
enableBeforeUnload: false,
|
|
889
|
+
disabled: !hasSelection,
|
|
890
|
+
});
|
|
891
|
+
|
|
864
892
|
// Load more when scrolling near the bottom
|
|
865
893
|
useEffect(() => {
|
|
866
894
|
const scrollElement = parentRef.current;
|
|
@@ -918,7 +946,7 @@ export const MessageList = ({
|
|
|
918
946
|
selectAll: handleSelectAll,
|
|
919
947
|
clearSelection: () => {
|
|
920
948
|
if (!hasSelection) return false;
|
|
921
|
-
|
|
949
|
+
exitSelection();
|
|
922
950
|
return true;
|
|
923
951
|
},
|
|
924
952
|
requestDelete: handleDeleteKey,
|
|
@@ -940,7 +968,7 @@ export const MessageList = ({
|
|
|
940
968
|
extendRangeUp,
|
|
941
969
|
handleSelectAll,
|
|
942
970
|
hasSelection,
|
|
943
|
-
|
|
971
|
+
exitSelection,
|
|
944
972
|
handleDeleteKey,
|
|
945
973
|
toggleDensity,
|
|
946
974
|
]);
|
|
@@ -972,7 +1000,7 @@ export const MessageList = ({
|
|
|
972
1000
|
<SelectionToolbar
|
|
973
1001
|
selectedCount={selectedCount}
|
|
974
1002
|
onDelete={handleDelete}
|
|
975
|
-
onClearSelection={
|
|
1003
|
+
onClearSelection={exitSelection}
|
|
976
1004
|
onMarkAsRead={onMarkAsRead ? handleMarkAsRead : undefined}
|
|
977
1005
|
onMove={onMoveMessages ? handleMoveSelected : undefined}
|
|
978
1006
|
onOrganize={() => setOrganizeOpen(true)}
|
|
@@ -22,7 +22,12 @@ import type {
|
|
|
22
22
|
RemitImapOutboxMessageResponse,
|
|
23
23
|
RemitImapOutboxMessageStatus,
|
|
24
24
|
} from "@remit/api-http-client/types.gen.ts";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
LIST_ROW_SELECTOR,
|
|
27
|
+
OutboxRow,
|
|
28
|
+
ReadingPaneEmpty,
|
|
29
|
+
useRovingFocus,
|
|
30
|
+
} from "@remit/ui";
|
|
26
31
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
27
32
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
|
28
33
|
import {
|
|
@@ -40,6 +45,7 @@ import {
|
|
|
40
45
|
useCallback,
|
|
41
46
|
useContext,
|
|
42
47
|
useMemo,
|
|
48
|
+
useRef,
|
|
43
49
|
} from "react";
|
|
44
50
|
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
45
51
|
import { MessageBody } from "@/components/mail/MessageBody";
|
|
@@ -434,6 +440,8 @@ function OutboxList() {
|
|
|
434
440
|
selectedMessageId,
|
|
435
441
|
onSelectMessage,
|
|
436
442
|
} = useOutboxPane();
|
|
443
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
444
|
+
useRovingFocus({ containerRef: listRef, itemSelector: LIST_ROW_SELECTOR });
|
|
437
445
|
|
|
438
446
|
if (isLoading) {
|
|
439
447
|
return (
|
|
@@ -468,7 +476,7 @@ function OutboxList() {
|
|
|
468
476
|
{messages.length} {messages.length === 1 ? "message" : "messages"}
|
|
469
477
|
</span>
|
|
470
478
|
</header>
|
|
471
|
-
<div className="flex-1 overflow-y-auto">
|
|
479
|
+
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
472
480
|
{messages.map((message) => (
|
|
473
481
|
<OutboxMessageRow
|
|
474
482
|
key={message.outboxMessageId}
|
|
@@ -523,6 +531,8 @@ function OutboxPhone() {
|
|
|
523
531
|
selectedMessage,
|
|
524
532
|
onSelectMessage,
|
|
525
533
|
} = useOutboxPane();
|
|
534
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
535
|
+
useRovingFocus({ containerRef: listRef, itemSelector: LIST_ROW_SELECTOR });
|
|
526
536
|
|
|
527
537
|
if (isLoading) {
|
|
528
538
|
return (
|
|
@@ -563,7 +573,7 @@ function OutboxPhone() {
|
|
|
563
573
|
{messages.length} {messages.length === 1 ? "message" : "messages"}
|
|
564
574
|
</span>
|
|
565
575
|
</header>
|
|
566
|
-
<div className="flex-1 overflow-y-auto">
|
|
576
|
+
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
567
577
|
{messages.map((message) => (
|
|
568
578
|
<OutboxMessageRow
|
|
569
579
|
key={message.outboxMessageId}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
deriveIsMultiSelectMode,
|
|
5
|
+
shouldExitSelectionOnNavigate,
|
|
6
|
+
} from "./selection-mode.js";
|
|
7
|
+
|
|
8
|
+
describe("deriveIsMultiSelectMode", () => {
|
|
9
|
+
test("no selection is not multi-select mode", () => {
|
|
10
|
+
assert.equal(deriveIsMultiSelectMode(0, false), false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("any selection on touch is multi-select mode", () => {
|
|
14
|
+
assert.equal(deriveIsMultiSelectMode(1, false), true);
|
|
15
|
+
assert.equal(deriveIsMultiSelectMode(42, false), true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("desktop selection drives the desktop toolbar, not multi-select mode", () => {
|
|
19
|
+
assert.equal(deriveIsMultiSelectMode(1, true), false);
|
|
20
|
+
assert.equal(deriveIsMultiSelectMode(0, true), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("dropping the last selected id leaves the mode in the same call", () => {
|
|
24
|
+
assert.equal(deriveIsMultiSelectMode(1, false), true);
|
|
25
|
+
assert.equal(deriveIsMultiSelectMode(0, false), false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("shouldExitSelectionOnNavigate", () => {
|
|
30
|
+
test("back while selecting exits selection instead of navigating", () => {
|
|
31
|
+
assert.equal(shouldExitSelectionOnNavigate("BACK", true), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("back with nothing selected is left alone", () => {
|
|
35
|
+
assert.equal(shouldExitSelectionOnNavigate("BACK", false), false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("forward, push, replace and go are never blocked", () => {
|
|
39
|
+
assert.equal(shouldExitSelectionOnNavigate("FORWARD", true), false);
|
|
40
|
+
assert.equal(shouldExitSelectionOnNavigate("PUSH", true), false);
|
|
41
|
+
assert.equal(shouldExitSelectionOnNavigate("REPLACE", true), false);
|
|
42
|
+
assert.equal(shouldExitSelectionOnNavigate("GO", true), false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection-mode rules for the message list (#115, refs #92 requirements 9
|
|
3
|
+
* and 10). Pure so "one source of truth, one exit" is testable without a DOM.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The subset of `@tanstack/history`'s `HistoryAction` a blocker can see. */
|
|
7
|
+
export type NavigationAction = "PUSH" | "REPLACE" | "FORWARD" | "BACK" | "GO";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether the list is in multi-select mode: a function of the selection count,
|
|
11
|
+
* never a stored flag, so the two can never disagree. Multi-select is the
|
|
12
|
+
* touch affordance (long press, always-visible checkboxes, the selection top
|
|
13
|
+
* bar); on desktop a selection drives the desktop toolbar instead and rows
|
|
14
|
+
* keep their ordinary hover behaviour.
|
|
15
|
+
*/
|
|
16
|
+
export const deriveIsMultiSelectMode = (
|
|
17
|
+
selectedCount: number,
|
|
18
|
+
isDesktop: boolean,
|
|
19
|
+
): boolean => !isDesktop && selectedCount > 0;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a history navigation should exit selection mode instead of leaving
|
|
23
|
+
* the route. Only the back gesture is intercepted, so a navigation the app
|
|
24
|
+
* itself starts (opening a message, switching mailboxes) is never blocked.
|
|
25
|
+
*/
|
|
26
|
+
export const shouldExitSelectionOnNavigate = (
|
|
27
|
+
action: NavigationAction,
|
|
28
|
+
hasSelection: boolean,
|
|
29
|
+
): boolean => action === "BACK" && hasSelection;
|