@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.41",
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
- clearSelection();
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
- [clearSelection, selectAll],
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
- clearSelection();
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
- clearSelection,
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
- clearSelection();
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
- clearSelection,
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
- clearSelection();
680
+ exitSelection();
668
681
  },
669
- [onMoveMessages, selectedCount, selectedIds, clearSelection],
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: Enter multi-select mode on long press
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
- if (escalation.phase.kind === "escalated") {
749
- escalation.clear();
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
- clearSelection();
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
- clearSelection,
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={clearSelection}
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 { OutboxRow, ReadingPaneEmpty } from "@remit/ui";
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;