@remit/web-client 0.0.110 → 0.0.112

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.110",
3
+ "version": "0.0.112",
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": {
@@ -440,7 +440,7 @@ export function MailListHeader({
440
440
  <span className="shrink-0 text-2xs text-fg-subtle">
441
441
  {unreadCount.toLocaleString()} unread
442
442
  </span>
443
- {!hasQuery && <FilterToggle />}
443
+ <FilterToggle />
444
444
  </>
445
445
  ),
446
446
  searchSlot: ownsSearch && !searchExpanded && (
@@ -492,7 +492,6 @@ export function MailListHeader({
492
492
  chromeResults,
493
493
  makeFilterAction,
494
494
  searchConversion,
495
- hasQuery,
496
495
  ],
497
496
  );
498
497
 
@@ -102,7 +102,7 @@ export function MailViewChrome({
102
102
  };
103
103
 
104
104
  return (
105
- <FilterPanelProvider>
105
+ <FilterPanelProvider hasSheet={!searching}>
106
106
  <MailListHeader
107
107
  title={title}
108
108
  unreadCount={unreadCount}
@@ -2,10 +2,14 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
2
2
  import {
3
3
  Banner,
4
4
  type Density,
5
+ deriveIsMultiSelectMode,
5
6
  type MessageListFilter,
6
7
  MessageListLoadingMore,
7
8
  MessageListPane,
9
+ nextFocusId,
10
+ type SelectionModifiers,
8
11
  SelectionTopBar,
12
+ useSelection,
9
13
  type Verb,
10
14
  } from "@remit/ui";
11
15
  import { useBlocker, useNavigate } from "@tanstack/react-router";
@@ -26,11 +30,6 @@ import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
26
30
  import { useLabelList } from "@/hooks/useLabels";
27
31
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
28
32
  import { useIsDesktop } from "@/hooks/useMediaQuery";
29
- import {
30
- nextFocusId,
31
- type SelectionModifiers,
32
- useSelection,
33
- } from "@/hooks/useSelection";
34
33
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
35
34
  import {
36
35
  type BulkActionKind,
@@ -46,17 +45,10 @@ import {
46
45
  escalatedStatusLabel,
47
46
  escalationActionLabel,
48
47
  } from "@/lib/escalation-label";
49
- import {
50
- formatDeleteToTrashTitle,
51
- formatEmailDate,
52
- formatNumber,
53
- } from "@/lib/format";
48
+ import { formatDeleteToTrashTitle, formatEmailDate } from "@/lib/format";
54
49
  import { tabStopId } from "@/lib/list-focus";
55
50
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
56
- import {
57
- deriveIsMultiSelectMode,
58
- shouldExitSelectionOnNavigate,
59
- } from "@/lib/selection-mode";
51
+ import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
60
52
  import { cn } from "@/lib/utils";
61
53
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
62
54
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
@@ -1103,9 +1095,7 @@ export const MessageList = ({
1103
1095
  escalation.progress?.total ?? selectionCount,
1104
1096
  )
1105
1097
  : escalation.phase.kind === "counting"
1106
- ? escalation.phase.countSoFar >= 5000
1107
- ? `Counting… ${formatNumber(escalation.phase.countSoFar)} so far. This is a big result set.`
1108
- : `Counting… ${formatNumber(escalation.phase.countSoFar)} so far`
1098
+ ? "Counting matches…"
1109
1099
  : escalation.phase.kind === "escalated"
1110
1100
  ? escalatedStatusLabel(searchPredicate ?? {}, escalation.phase.total)
1111
1101
  : undefined;
@@ -6,9 +6,13 @@
6
6
  * interactive lives in `MessageRow`, which the brief and Flagged render too.
7
7
  */
8
8
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
- import type { Density, SenderTrustLevel, ThreadRowData } from "@remit/ui";
9
+ import type {
10
+ Density,
11
+ SelectionModifiers,
12
+ SenderTrustLevel,
13
+ ThreadRowData,
14
+ } from "@remit/ui";
10
15
  import { memo } from "react";
11
- import type { SelectionModifiers } from "@/hooks/useSelection";
12
16
  import { toDisplayCategory } from "@/lib/display-category";
13
17
  import { formatEmailDate } from "@/lib/format";
14
18
  import { MessageRow } from "./MessageRow";
@@ -18,17 +18,18 @@ import {
18
18
  compactRowClass,
19
19
  type Density,
20
20
  mergeProps,
21
+ modifiersOf,
21
22
  type RowToggleEvent,
23
+ type SelectionModifiers,
22
24
  type ThreadRowData,
23
25
  useLongPress,
24
26
  } from "@remit/ui";
25
27
  import { useQueryClient } from "@tanstack/react-query";
26
28
  import { Link } from "@tanstack/react-router";
27
29
  import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
28
- import type { SelectionModifiers } from "@/hooks/useSelection";
29
30
  import { cn } from "@/lib/utils";
30
31
  import { useThreadRowInteraction } from "./ThreadListInteraction";
31
- import { modifiersOf, useModifierSelect } from "./useModifierSelect";
32
+ import { useModifierSelect } from "./useModifierSelect";
32
33
 
33
34
  interface MailboxLinkSearch {
34
35
  selectedMessageId?: string;
@@ -1,13 +1,13 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
2
  import {
3
3
  type Density,
4
+ type SelectionModifiers,
4
5
  SwipeableRow,
5
6
  type SwipePeek,
6
7
  type ThreadRowData,
7
8
  } from "@remit/ui";
8
9
  import { Link } from "@tanstack/react-router";
9
10
  import { useCallback, useState } from "react";
10
- import type { SelectionModifiers } from "@/hooks/useSelection";
11
11
  import { toDisplayCategory } from "@/lib/display-category";
12
12
  import { formatEmailDate } from "@/lib/format";
13
13
  import { MessageListItem } from "./MessageListItem";
@@ -14,10 +14,10 @@
14
14
 
15
15
  import assert from "node:assert/strict";
16
16
  import { afterEach, describe, it } from "node:test";
17
+ import type { SelectionModifiers } from "@remit/ui";
17
18
  import { createElement, type MouseEvent } from "react";
18
- import type { SelectionModifiers } from "@/hooks/useSelection";
19
19
  import { createDomHarness, type DomHarness } from "../../test-support/dom";
20
- import { isModified, useModifierSelect } from "./useModifierSelect";
20
+ import { useModifierSelect } from "./useModifierSelect";
21
21
 
22
22
  let harness: DomHarness | undefined;
23
23
 
@@ -221,27 +221,3 @@ describe("useModifierSelect", () => {
221
221
  assert.equal(press(dom, "contextmenu", { ctrlKey: true }), false);
222
222
  });
223
223
  });
224
-
225
- describe("isModified", () => {
226
- it("is true for any of the three keys a keyboard can add", () => {
227
- assert.equal(
228
- isModified({ shiftKey: true, metaKey: false, ctrlKey: false }),
229
- true,
230
- );
231
- assert.equal(
232
- isModified({ shiftKey: false, metaKey: true, ctrlKey: false }),
233
- true,
234
- );
235
- assert.equal(
236
- isModified({ shiftKey: false, metaKey: false, ctrlKey: true }),
237
- true,
238
- );
239
- });
240
-
241
- it("is false for the bare press a tap delivers", () => {
242
- assert.equal(
243
- isModified({ shiftKey: false, metaKey: false, ctrlKey: false }),
244
- false,
245
- );
246
- });
247
- });
@@ -16,17 +16,8 @@
16
16
  * `preventDefault` is already too late there. `claimClick` still handles the
17
17
  * click for engines that deliver it that way.
18
18
  */
19
+ import { isModified, modifiersOf, type SelectionModifiers } from "@remit/ui";
19
20
  import { type MouseEvent, useCallback, useRef } from "react";
20
- import type { SelectionModifiers } from "@/hooks/useSelection";
21
-
22
- export const modifiersOf = (e: MouseEvent): SelectionModifiers => ({
23
- shiftKey: e.shiftKey,
24
- metaKey: e.metaKey,
25
- ctrlKey: e.ctrlKey,
26
- });
27
-
28
- export const isModified = (m: SelectionModifiers): boolean =>
29
- m.shiftKey || m.metaKey || m.ctrlKey;
30
21
 
31
22
  export interface ModifierSelect {
32
23
  /** Takes a modified press for selection before the browser acts on it. */
@@ -42,10 +42,12 @@ interface MailServer {
42
42
  /**
43
43
  * A search that pages `TOTAL` ids at the hook's own page size, keyed by the
44
44
  * query, so a run started against one predicate is distinguishable from a run
45
- * that followed the list onto a later one.
45
+ * that followed the list onto a later one. A count-only request is answered
46
+ * with the whole match set in one response, as the server answers it.
46
47
  */
47
48
  const searchPage = (url: URL): unknown => {
48
49
  const query = url.searchParams.get("query") ?? "";
50
+ if (url.searchParams.get("results") === "false") return { count: TOTAL };
49
51
  const served = Number(url.searchParams.get("continuationToken") ?? "0");
50
52
  const size = Math.min(PAGE_SIZE, Math.max(TOTAL - served, 0));
51
53
  return {
@@ -18,7 +18,6 @@ import {
18
18
  type ApplyBatch,
19
19
  type BulkActionProgress,
20
20
  type BulkRunOutcome,
21
- countMatches,
22
21
  type FetchIdsPage,
23
22
  honestProgress,
24
23
  runChunkedAction,
@@ -53,16 +52,14 @@ export type EscalatedAction =
53
52
  | { kind: "move"; destinationMailboxId: string }
54
53
  | { kind: "markRead" };
55
54
 
56
- /** Page size for both the counting and the execution loop. Set to the write
57
- * side's own 100-id cap so an execution page IS a write chunk — no
58
- * in-memory accumulation step between reading ids and sending them. Counting
59
- * doesn't have that constraint but reuses the same page size rather than
60
- * adding a second one to reason about. */
55
+ /** Page size for the execution loop. Set to the write side's own 100-id cap so
56
+ * an execution page IS a write chunk — no in-memory accumulation step between
57
+ * reading ids and sending them. */
61
58
  const PAGE_SIZE = 100;
62
59
 
63
60
  export type EscalationPhase =
64
61
  | { kind: "idle" }
65
- | { kind: "counting"; countSoFar: number }
62
+ | { kind: "counting" }
66
63
  | { kind: "escalated"; total: number };
67
64
 
68
65
  interface UseEscalatedActionsOptions {
@@ -80,12 +77,13 @@ interface UseEscalatedActionsOptions {
80
77
 
81
78
  export interface UseEscalatedActionsResult {
82
79
  phase: EscalationPhase;
83
- /** Begin paging the predicate's full match set to find its total. */
80
+ /** Ask the server how many messages the predicate matches, and switch the
81
+ * selection to that predicate once it answers. */
84
82
  escalate: () => void;
85
83
  /**
86
- * Stop whatever's running — counting or an action — at the next page
87
- * boundary. A no-op when nothing is running. The only thing that ends a run
88
- * in flight: leaving the selection, the wizard or the search does not.
84
+ * Stop whatever's running — the count or an action — at the next boundary.
85
+ * A no-op when nothing is running. The only thing that ends a run in
86
+ * flight: leaving the selection, the wizard or the search does not.
89
87
  */
90
88
  stop: () => void;
91
89
  /**
@@ -181,11 +179,22 @@ export const useEscalatedActions = ({
181
179
  [mailboxId],
182
180
  );
183
181
 
184
- const fetchIdsPage = useCallback<FetchIdsPage>(
185
- (continuationToken) =>
186
- fetchPagesOf(searchQueryRef.current)(continuationToken),
187
- [fetchPagesOf],
188
- );
182
+ /**
183
+ * How many messages the predicate matches, straight from the server that
184
+ * resolves it (#509). One count-only request: `limit` is a page size and has
185
+ * no bearing on the answer, so nothing is paged to arrive at it.
186
+ */
187
+ const fetchMatchCount = useCallback(async (): Promise<number> => {
188
+ const { data } = await threadOperationsSearchThreads({
189
+ path: { mailboxId },
190
+ query: { ...searchQueryRef.current, count: true, results: false },
191
+ throwOnError: true,
192
+ });
193
+ if (data.count === undefined) {
194
+ throw new Error("the search returned no count for the selection");
195
+ }
196
+ return data.count;
197
+ }, [mailboxId]);
189
198
 
190
199
  const applyBatchFor = useCallback(
191
200
  (action: EscalatedAction): ApplyBatch =>
@@ -235,30 +244,27 @@ export const useEscalatedActions = ({
235
244
 
236
245
  const escalate = useCallback(() => {
237
246
  cancelRef.current = false;
238
- setPhase({ kind: "counting", countSoFar: 0 });
239
- countMatches(
240
- fetchIdsPage,
241
- (countSoFar) => setPhase({ kind: "counting", countSoFar }),
242
- () => cancelRef.current,
243
- ).then((result) => {
244
- if (result.error) {
247
+ setPhase({ kind: "counting" });
248
+ fetchMatchCount().then(
249
+ (total) => {
250
+ if (cancelRef.current) {
251
+ setPhase({ kind: "idle" });
252
+ return;
253
+ }
254
+ setPhase({ kind: "escalated", total });
255
+ },
256
+ (error: unknown) => {
245
257
  pushError(
246
258
  buildMutationErrorBanner(
247
259
  "Couldn't count matching messages",
248
260
  "The count didn't finish.",
249
- result.error,
261
+ error,
250
262
  ),
251
263
  );
252
264
  setPhase({ kind: "idle" });
253
- return;
254
- }
255
- if (result.cancelled) {
256
- setPhase({ kind: "idle" });
257
- return;
258
- }
259
- setPhase({ kind: "escalated", total: result.total });
260
- });
261
- }, [fetchIdsPage, pushError]);
265
+ },
266
+ );
267
+ }, [fetchMatchCount, pushError]);
262
268
 
263
269
  const stop = useCallback(() => {
264
270
  cancelRef.current = true;
@@ -279,8 +285,8 @@ export const useEscalatedActions = ({
279
285
  runningRef.current = true;
280
286
  setRunningAction(action);
281
287
  // `honestProgress` widens `total` if `done` overtakes it (#109) — the
282
- // predicate can match more by the time the run re-pages it than
283
- // `countMatches` saw, and the bar must never show more done than out of.
288
+ // predicate can match more by the time the run pages it than the count
289
+ // saw, and the bar must never show more done than out of.
284
290
  const onProgress = (next: BulkActionProgress) =>
285
291
  setProgress(honestProgress(next));
286
292
  const applyBatch = applyBatchFor(action);
@@ -13,13 +13,14 @@
13
13
  * `cursorMovedByPointerRef` records whether the last move came from a click, so
14
14
  * a list that scrolls its cursor into view can skip doing so for pointer moves.
15
15
  */
16
- import { useCallback, useMemo, useRef, useState } from "react";
17
16
  import {
17
+ deriveIsMultiSelectMode,
18
18
  nextFocusId,
19
+ rowSelectIntent,
19
20
  type SelectionModifiers,
20
21
  useSelection,
21
- } from "@/hooks/useSelection";
22
- import { deriveIsMultiSelectMode } from "@/lib/selection-mode";
22
+ } from "@remit/ui";
23
+ import { useCallback, useMemo, useRef, useState } from "react";
23
24
 
24
25
  interface UseListCursorOptions {
25
26
  /** Message ids in display order. */
@@ -178,14 +179,15 @@ export const useListCursor = ({
178
179
 
179
180
  const handleRowSelect = useCallback(
180
181
  (messageId: string, modifiers: SelectionModifiers): boolean => {
181
- if (modifiers.shiftKey) {
182
+ const intent = rowSelectIntent(modifiers);
183
+ if (intent === "range") {
182
184
  // The open/focused row is the fallback origin when the stored anchor
183
185
  // has been filtered or searched out of the visible list, so the first
184
186
  // shift-click still ranges from where the user is (#142, #144).
185
187
  selectRange(orderedIds, messageId, focusedMessageId);
186
188
  return true;
187
189
  }
188
- if (modifiers.metaKey || modifiers.ctrlKey) {
190
+ if (intent === "toggle") {
189
191
  toggleCheck(messageId);
190
192
  return true;
191
193
  }
@@ -3,7 +3,6 @@ import { describe, test } from "node:test";
3
3
  import {
4
4
  BULK_ACTION_CHUNK_SIZE,
5
5
  chunkIds,
6
- countMatches,
7
6
  type FetchIdsPageResult,
8
7
  honestProgress,
9
8
  runChunkedAction,
@@ -297,67 +296,10 @@ describe("runPredicateAction", () => {
297
296
  });
298
297
  });
299
298
 
300
- describe("countMatches", () => {
301
- const neverCancelled = () => false;
302
-
303
- test("counts across every page until the token is exhausted", async () => {
304
- let call = 0;
305
- const pages: FetchIdsPageResult[] = [
306
- { ids: ids(500, "a"), continuationToken: "t1" },
307
- { ids: ids(500, "b"), continuationToken: "t2" },
308
- { ids: ids(412, "c") },
309
- ];
310
- const fetch = async () => pages[call++];
311
- const outcome = await countMatches(fetch, () => undefined, neverCancelled);
312
- assert.deepEqual(outcome, { total: 1412, cancelled: false });
313
- });
314
-
315
- test("reports a running total after each page", async () => {
316
- let call = 0;
317
- const pages: FetchIdsPageResult[] = [
318
- { ids: ids(500), continuationToken: "t1" },
319
- { ids: ids(300) },
320
- ];
321
- const fetch = async () => pages[call++];
322
- const progressCalls: number[] = [];
323
- await countMatches(fetch, (n) => progressCalls.push(n), neverCancelled);
324
- assert.deepEqual(progressCalls, [500, 800]);
325
- });
326
-
327
- test("cancelling mid-count stops paging and reports what it had so far", async () => {
328
- let call = 0;
329
- let cancelled = false;
330
- const fetch = async (): Promise<FetchIdsPageResult> => {
331
- call++;
332
- return { ids: ids(500), continuationToken: "more" };
333
- };
334
- const outcome = await countMatches(
335
- fetch,
336
- () => {
337
- cancelled = true;
338
- },
339
- () => cancelled,
340
- );
341
- assert.equal(outcome.cancelled, true);
342
- assert.equal(call, 1);
343
- assert.equal(outcome.total, 500);
344
- });
345
-
346
- test("an infra failure while paging stops the count and reports the error", async () => {
347
- const boom = new Error("network blip");
348
- const fetch = async (): Promise<FetchIdsPageResult> => {
349
- throw boom;
350
- };
351
- const outcome = await countMatches(fetch, () => undefined, neverCancelled);
352
- assert.equal(outcome.error, boom);
353
- assert.equal(outcome.total, 0);
354
- });
355
- });
356
-
357
299
  describe("honestProgress", () => {
358
- // Regression for #109: `countMatches` and `runPredicateAction` page the
359
- // same predicate independently, so the delete can outrun the frozen
360
- // `total` it started with when the result set grows in between.
300
+ // Regression for #109: the count and `runPredicateAction` resolve the same
301
+ // predicate independently, so the delete can outrun the frozen `total` it
302
+ // started with when the result set grows in between.
361
303
  test("leaves an on-track progress reading untouched", () => {
362
304
  assert.deepEqual(honestProgress({ done: 50, total: 100 }), {
363
305
  done: 50,
@@ -214,14 +214,14 @@ export const runPredicateAction = async (
214
214
 
215
215
  /**
216
216
  * Corrects a progress reading so its `total` can never read as less than
217
- * `done` (#109). `runPredicateAction`'s `total` is `countMatches`'s frozen
218
- * page-through, taken before the run re-pages the same predicate a second,
219
- * independent time; if more matches arrived in between, `done` can overtake
220
- * it mid-run and a raw "Deleting 1,340 of 1,284" would follow. Widening the
221
- * denominator to match keeps the bar's ratio sane (never past 100%) without
222
- * claiming the original count was exact — the honest fix is admitting the
223
- * estimate grew, not re-paging to reconcile it (the result set is live; a
224
- * second count taken any later is no less stale than the first).
217
+ * `done` (#109). `runPredicateAction`'s `total` is the count the server gave
218
+ * before the run resolved the same predicate a second, independent time; if
219
+ * more matches arrived in between, `done` can overtake it mid-run and a raw
220
+ * "Deleting 1,340 of 1,284" would follow. Widening the denominator to match
221
+ * keeps the bar's ratio sane (never past 100%) without claiming the original
222
+ * count was exact — the honest fix is admitting the reading grew, not taking a
223
+ * second count to reconcile it (the result set is live; a count taken any
224
+ * later is no less stale than the first).
225
225
  */
226
226
  export const honestProgress = (
227
227
  progress: BulkActionProgress,
@@ -230,45 +230,6 @@ export const honestProgress = (
230
230
  total: Math.max(progress.total, progress.done),
231
231
  });
232
232
 
233
- export interface CountMatchesResult {
234
- total: number;
235
- cancelled: boolean;
236
- error?: unknown;
237
- }
238
-
239
- /**
240
- * Pages the full predicate result set to find its exact total — the only way,
241
- * short of a server-side total (out of scope, see issue #92), since search has
242
- * no total-count field beyond a small capped-window estimate. Reports a
243
- * running count via `onProgress` so a long count reads as progressing rather
244
- * than hung, and checks `isCancelled` between pages so Stop takes effect
245
- * within one page's latency.
246
- */
247
- export const countMatches = async (
248
- fetchIdsPage: FetchIdsPage,
249
- onProgress: (countSoFar: number) => void,
250
- isCancelled: () => boolean,
251
- ): Promise<CountMatchesResult> => {
252
- let total = 0;
253
- let token: string | undefined;
254
-
255
- do {
256
- if (isCancelled()) {
257
- return { total, cancelled: true };
258
- }
259
- const fetched = await attempt(fetchIdsPage(token));
260
- if (!fetched.ok) {
261
- return { total, cancelled: false, error: fetched.error };
262
- }
263
- const page = fetched.value;
264
- total += page.ids.length;
265
- onProgress(total);
266
- token = page.continuationToken;
267
- } while (token);
268
-
269
- return { total, cancelled: false };
270
- };
271
-
272
233
  export interface BulkRunOutcome {
273
234
  done: number;
274
235
  failedIds: string[];
@@ -1,30 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
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
- });
3
+ import { shouldExitSelectionOnNavigate } from "./selection-mode.js";
28
4
 
29
5
  describe("shouldExitSelectionOnNavigate", () => {
30
6
  test("back while selecting exits selection instead of navigating", () => {
@@ -8,18 +8,6 @@ import type { StepId } from "@remit/ui";
8
8
  /** The subset of `@tanstack/history`'s `HistoryAction` a blocker can see. */
9
9
  export type NavigationAction = "PUSH" | "REPLACE" | "FORWARD" | "BACK" | "GO";
10
10
 
11
- /**
12
- * Whether the list is in multi-select mode: a function of the selection count,
13
- * never a stored flag, so the two can never disagree. Multi-select is the
14
- * touch affordance (long press, always-visible checkboxes, the selection top
15
- * bar); on desktop a selection drives the desktop toolbar instead and rows
16
- * keep their ordinary hover behaviour.
17
- */
18
- export const deriveIsMultiSelectMode = (
19
- selectedCount: number,
20
- isDesktop: boolean,
21
- ): boolean => !isDesktop && selectedCount > 0;
22
-
23
11
  /**
24
12
  * Whether a history navigation should exit selection mode instead of leaving
25
13
  * the route. Only the back gesture is intercepted, so a navigation the app
@@ -1,244 +0,0 @@
1
- import assert from "node:assert";
2
- import { describe, test } from "node:test";
3
- import {
4
- computeRange,
5
- intersectSelectedIds,
6
- nextFocusId,
7
- resolveRangeAnchor,
8
- } from "./useSelection.js";
9
-
10
- const ids = ["a", "b", "c", "d", "e"];
11
-
12
- // Mirrors the hook's `selectRange` state transition (anchor + selection) using
13
- // the same pure helpers it runs, so the mouse/keyboard sequences below exercise
14
- // the real logic without an interactive DOM. Returns the next {selected, anchor}.
15
- const applyRange = (
16
- state: { selected: Set<string>; anchor: string | undefined },
17
- orderedIds: string[],
18
- targetId: string,
19
- fallbackAnchor?: string,
20
- ): { selected: Set<string>; anchor: string | undefined } => {
21
- const anchor = resolveRangeAnchor(
22
- orderedIds,
23
- state.anchor,
24
- fallbackAnchor,
25
- targetId,
26
- );
27
- const range = computeRange(orderedIds, anchor, targetId);
28
- const selected = new Set(state.selected);
29
- for (const id of range) selected.add(id);
30
- return { selected, anchor };
31
- };
32
-
33
- // Mirrors a cmd/ctrl-click: toggle membership and re-anchor on the clicked row.
34
- const applyToggle = (
35
- state: { selected: Set<string>; anchor: string | undefined },
36
- targetId: string,
37
- ): { selected: Set<string>; anchor: string | undefined } => {
38
- const selected = new Set(state.selected);
39
- if (selected.has(targetId)) selected.delete(targetId);
40
- else selected.add(targetId);
41
- return { selected, anchor: targetId };
42
- };
43
-
44
- describe("computeRange", () => {
45
- test("forward range: anchor above target selects the inclusive slice", () => {
46
- assert.deepStrictEqual(computeRange(ids, "b", "d"), ["b", "c", "d"]);
47
- });
48
-
49
- test("backward range: target above anchor selects the same inclusive slice", () => {
50
- assert.deepStrictEqual(computeRange(ids, "d", "b"), ["b", "c", "d"]);
51
- });
52
-
53
- test("anchor equals target selects just that one id", () => {
54
- assert.deepStrictEqual(computeRange(ids, "c", "c"), ["c"]);
55
- });
56
-
57
- test("no anchor selects just the target", () => {
58
- assert.deepStrictEqual(computeRange(ids, undefined, "c"), ["c"]);
59
- });
60
-
61
- test("anchor not present in the list selects just the target", () => {
62
- assert.deepStrictEqual(computeRange(ids, "zzz", "c"), ["c"]);
63
- });
64
-
65
- test("full span from first to last includes every id in order", () => {
66
- assert.deepStrictEqual(computeRange(ids, "a", "e"), [
67
- "a",
68
- "b",
69
- "c",
70
- "d",
71
- "e",
72
- ]);
73
- });
74
-
75
- test("target not present in the list selects nothing", () => {
76
- assert.deepStrictEqual(computeRange(ids, "b", "zzz"), []);
77
- });
78
- });
79
-
80
- describe("resolveRangeAnchor", () => {
81
- test("keeps a still-visible stored anchor so consecutive shift-clicks extend from it", () => {
82
- assert.strictEqual(resolveRangeAnchor(ids, "b", undefined, "d"), "b");
83
- });
84
-
85
- test("a stored anchor no longer visible falls back to the open/focused row", () => {
86
- // The stored anchor "z" was filtered/searched out of the visible list; the
87
- // open row "b" is still visible, so the range anchors there.
88
- assert.strictEqual(resolveRangeAnchor(ids, "z", "b", "d"), "b");
89
- });
90
-
91
- test("with neither a visible stored anchor nor a visible fallback, the target anchors itself", () => {
92
- assert.strictEqual(resolveRangeAnchor(ids, "z", "y", "d"), "d");
93
- });
94
-
95
- test("no stored anchor and no fallback selects just the target", () => {
96
- assert.strictEqual(resolveRangeAnchor(ids, undefined, undefined, "c"), "c");
97
- });
98
-
99
- test("a stored anchor beats the fallback while it stays visible", () => {
100
- assert.strictEqual(resolveRangeAnchor(ids, "a", "c", "e"), "a");
101
- });
102
- });
103
-
104
- describe("shift-click range over a filtered/search-narrowed list (#142, #144)", () => {
105
- test("shift-click range with an active filter builds the range within the visible rows", () => {
106
- // Full inbox is a..h; the "Automated" filter leaves only these rows.
107
- const filtered = ["b", "d", "f", "g"];
108
- // The open row "d" is visible; nothing selected yet, no stored anchor.
109
- let state = {
110
- selected: new Set<string>(),
111
- anchor: undefined as string | undefined,
112
- };
113
- // Shift-click "g": ranges from the open/focused row "d" to "g".
114
- state = applyRange(state, filtered, "g", "d");
115
- assert.deepStrictEqual([...state.selected], ["d", "f", "g"]);
116
- assert.strictEqual(state.anchor, "d");
117
- });
118
-
119
- test("range where the anchor left the visible set re-anchors instead of no-oping", () => {
120
- const filtered = ["b", "d", "f", "g"];
121
- // Stored anchor "a" was selected before the filter narrowed the list and is
122
- // no longer visible; there is no open/focused fallback row.
123
- let state = {
124
- selected: new Set<string>(),
125
- anchor: "a" as string | undefined,
126
- };
127
- // First shift-click adopts the clicked row as the new visible anchor.
128
- state = applyRange(state, filtered, "f");
129
- assert.deepStrictEqual([...state.selected], ["f"]);
130
- assert.strictEqual(state.anchor, "f");
131
- // Second shift-click now builds a real range from that adopted anchor.
132
- state = applyRange(state, filtered, "b");
133
- assert.deepStrictEqual([...state.selected].sort(), ["b", "d", "f"]);
134
- assert.strictEqual(state.anchor, "f");
135
- });
136
-
137
- test("multi-select across a search-results list: cmd-click then shift-click", () => {
138
- // Search "npm" yields these results; the inbox anchor is gone from this set.
139
- const results = ["m1", "m2", "m3", "m4", "m5"];
140
- let state = {
141
- selected: new Set<string>(),
142
- anchor: "inbox-row" as string | undefined,
143
- };
144
- // Cmd-click "m2": toggles it in and re-anchors on it (a visible row).
145
- state = applyToggle(state, "m2");
146
- assert.deepStrictEqual([...state.selected], ["m2"]);
147
- assert.strictEqual(state.anchor, "m2");
148
- // Shift-click "m4": ranges from the cmd-clicked anchor across the results.
149
- state = applyRange(state, results, "m4");
150
- assert.deepStrictEqual([...state.selected].sort(), ["m2", "m3", "m4"]);
151
- assert.strictEqual(state.anchor, "m2");
152
- });
153
- });
154
-
155
- describe("nextFocusId", () => {
156
- test("moves down one row", () => {
157
- assert.strictEqual(nextFocusId(ids, "b", 1), "c");
158
- });
159
-
160
- test("moves up one row", () => {
161
- assert.strictEqual(nextFocusId(ids, "c", -1), "b");
162
- });
163
-
164
- test("clamps at the bottom (no wrap)", () => {
165
- assert.strictEqual(nextFocusId(ids, "e", 1), "e");
166
- });
167
-
168
- test("clamps at the top (no wrap)", () => {
169
- assert.strictEqual(nextFocusId(ids, "a", -1), "a");
170
- });
171
-
172
- test("no focus + down starts at the first row", () => {
173
- assert.strictEqual(nextFocusId(ids, undefined, 1), "a");
174
- });
175
-
176
- test("no focus + up starts at the last row", () => {
177
- assert.strictEqual(nextFocusId(ids, undefined, -1), "e");
178
- });
179
-
180
- test("focus not in the list + down starts at the first row", () => {
181
- assert.strictEqual(nextFocusId(ids, "zzz", 1), "a");
182
- });
183
-
184
- test("empty list returns undefined", () => {
185
- assert.strictEqual(nextFocusId([], "a", 1), undefined);
186
- });
187
- });
188
-
189
- describe("intersectSelectedIds", () => {
190
- test("a refresh that drops some selected ids and adds new rows keeps only the survivors (#111)", () => {
191
- // Regression for #111: the effect used to clear the WHOLE selection the
192
- // moment any single selected id left the list. Here "b" leaves (deleted
193
- // elsewhere) while "f" arrives (new mail) — "a" and "c" must survive.
194
- const selected = new Set(["a", "b", "c"]);
195
- const refreshedThreadIds = ["a", "c", "d", "f"];
196
- assert.deepStrictEqual(
197
- intersectSelectedIds(selected, refreshedThreadIds),
198
- new Set(["a", "c"]),
199
- );
200
- });
201
-
202
- test("never adds an id that wasn't already selected", () => {
203
- const selected = new Set(["a"]);
204
- assert.deepStrictEqual(
205
- intersectSelectedIds(selected, ["a", "b", "c"]),
206
- new Set(["a"]),
207
- );
208
- });
209
-
210
- test("every selected id surviving is a no-op (same members, not just same size)", () => {
211
- const selected = new Set(["a", "b"]);
212
- assert.deepStrictEqual(
213
- intersectSelectedIds(selected, ["a", "b", "z"]),
214
- new Set(["a", "b"]),
215
- );
216
- });
217
-
218
- test("a post-delete retry selection survives a refetch that still contains it, minus what actually left", () => {
219
- // Mirrors processRunOutcome materializing the failed ids as the new
220
- // selection, then the cache-invalidation refetch running this same
221
- // intersection against the freshly reloaded `threads`. One retry id
222
- // ("fail-2") is momentarily missing from the refreshed page; the other
223
- // two must stay selected so the Retry notice (gated on
224
- // `selectedCount > 0`) doesn't disappear with it.
225
- const retrySelection = new Set(["fail-1", "fail-2", "fail-3"]);
226
- const refetchedThreadIds = ["fail-1", "fail-3", "unrelated-1"];
227
- assert.deepStrictEqual(
228
- intersectSelectedIds(retrySelection, refetchedThreadIds),
229
- new Set(["fail-1", "fail-3"]),
230
- );
231
- });
232
-
233
- test("everything in the selection leaving empties it, rather than leaving stale ids behind", () => {
234
- const selected = new Set(["a", "b"]);
235
- assert.deepStrictEqual(intersectSelectedIds(selected, ["z"]), new Set());
236
- });
237
-
238
- test("an empty selection stays empty", () => {
239
- assert.deepStrictEqual(
240
- intersectSelectedIds(new Set(), ["a", "b"]),
241
- new Set(),
242
- );
243
- });
244
- });
@@ -1,292 +0,0 @@
1
- import { useCallback, useState } from "react";
2
-
3
- /**
4
- * Mouse/keyboard modifier flags read off a row click, used to drive desktop
5
- * multi-select semantics (shift = range, cmd/ctrl = toggle, plain = open).
6
- */
7
- export interface SelectionModifiers {
8
- shiftKey: boolean;
9
- metaKey: boolean;
10
- ctrlKey: boolean;
11
- }
12
-
13
- interface UseSelectionOptions<T> {
14
- /** Function to extract ID from an item */
15
- getId: (item: T) => string;
16
- }
17
-
18
- interface UseSelectionReturn {
19
- /** Set of currently selected item IDs */
20
- selectedIds: Set<string>;
21
- /** Number of selected items */
22
- selectedCount: number;
23
- /** Whether any items are selected */
24
- hasSelection: boolean;
25
- /** Check if a specific item is selected */
26
- isSelected: (id: string) => boolean;
27
- /** Toggle selection for a single item (updates the range anchor) */
28
- toggle: (id: string) => void;
29
- /** Select a single item (adds to selection, updates the range anchor) */
30
- select: (id: string) => void;
31
- /** Deselect a single item */
32
- deselect: (id: string) => void;
33
- /** Select all items */
34
- selectAll: (ids: string[]) => void;
35
- /** Clear all selections (also clears the range anchor) */
36
- clearSelection: () => void;
37
- /** Toggle selection for all items */
38
- toggleAll: (ids: string[]) => void;
39
- /**
40
- * Add the contiguous range of ids from the anchor to `targetId` (inclusive)
41
- * to the selection, using `orderedIds` for display order. The anchor is the
42
- * stored one when it is still visible in `orderedIds`; otherwise
43
- * `fallbackAnchor` when that is visible (the open/focused row); otherwise
44
- * `targetId`. Whatever anchors the range becomes the new stored anchor, so a
45
- * filtered or search-narrowed list can still build a range within what's
46
- * visible (#142, #144).
47
- */
48
- selectRange: (
49
- orderedIds: string[],
50
- targetId: string,
51
- fallbackAnchor?: string,
52
- ) => void;
53
- /**
54
- * Set the range anchor without changing the selection set. Used by a plain
55
- * click that navigates but should seed the anchor for a later shift-click.
56
- */
57
- setAnchor: (id: string) => void;
58
- /**
59
- * The id of the row that anchors shift-range selection. `undefined` when
60
- * nothing has been selected yet.
61
- */
62
- anchorId: string | undefined;
63
- /**
64
- * Narrows the selection to whatever in it is still present in `currentIds`
65
- * — drops ids that left, keeps every survivor. Never adds anything, and
66
- * never clears the selection just because one id is gone (#111).
67
- */
68
- intersectWith: (currentIds: readonly string[]) => void;
69
- }
70
-
71
- /**
72
- * Compute the inclusive slice of ids spanning from `anchorId` to `targetId`
73
- * in `orderedIds`. Pure so it can be unit-tested without a DOM.
74
- *
75
- * - Direction-agnostic: works whether the target sits above or below the anchor.
76
- * - Missing anchor (or anchor not in the list): returns just `[targetId]`.
77
- * - Target not in the list: returns `[]` (nothing to select).
78
- */
79
- export const computeRange = (
80
- orderedIds: string[],
81
- anchorId: string | undefined,
82
- targetId: string,
83
- ): string[] => {
84
- const targetIndex = orderedIds.indexOf(targetId);
85
- if (targetIndex === -1) return [];
86
-
87
- const anchorIndex =
88
- anchorId === undefined ? -1 : orderedIds.indexOf(anchorId);
89
- if (anchorIndex === -1) return [targetId];
90
-
91
- const start = Math.min(anchorIndex, targetIndex);
92
- const end = Math.max(anchorIndex, targetIndex);
93
- return orderedIds.slice(start, end + 1);
94
- };
95
-
96
- /**
97
- * Resolve which id a shift-range selection anchors from, given the stored
98
- * anchor and the ids currently visible (`orderedIds`). Pure so the
99
- * filtered/search anchor behavior can be unit-tested without a DOM.
100
- *
101
- * - The stored anchor wins while it is still visible — consecutive shift-clicks
102
- * keep extending from the same origin (Apple Mail / Gmail).
103
- * - Once the stored anchor leaves the visible set (filtered out, or a search
104
- * changed the list), it can't anchor a range in that set, so fall back to
105
- * `fallbackAnchor` (the open/focused row) when it is visible.
106
- * - With neither available, the target anchors itself: the clicked row is
107
- * selected alone and becomes the origin for the next shift-click.
108
- */
109
- export const resolveRangeAnchor = (
110
- orderedIds: string[],
111
- storedAnchor: string | undefined,
112
- fallbackAnchor: string | undefined,
113
- targetId: string,
114
- ): string => {
115
- if (storedAnchor !== undefined && orderedIds.includes(storedAnchor)) {
116
- return storedAnchor;
117
- }
118
- if (fallbackAnchor !== undefined && orderedIds.includes(fallbackAnchor)) {
119
- return fallbackAnchor;
120
- }
121
- return targetId;
122
- };
123
-
124
- /**
125
- * The ids from `selectedIds` that are still present in `currentIds` — the
126
- * survivor set after a list refresh. Only ever narrows: an id absent from
127
- * `selectedIds` is never added just because it's in `currentIds`. Pure so the
128
- * "drop what left, keep the rest" behavior (K-9's `selected.intersect
129
- * (uniqueIds)`, cited by #92 D2) can be unit-tested without a DOM.
130
- */
131
- export const intersectSelectedIds = (
132
- selectedIds: ReadonlySet<string>,
133
- currentIds: readonly string[],
134
- ): Set<string> => {
135
- const present = new Set(currentIds);
136
- const next = new Set<string>();
137
- for (const id of selectedIds) {
138
- if (present.has(id)) next.add(id);
139
- }
140
- return next;
141
- };
142
-
143
- /**
144
- * Compute the id one step from `focusId` in `orderedIds`, clamped at the ends.
145
- * Pure so the shift-arrow range-extend math can be unit-tested without a DOM.
146
- *
147
- * - `direction` is -1 for up (previous) or +1 for down (next).
148
- * - Missing focus (or focus not in the list): returns the first id for down,
149
- * the last id for up, or `undefined` when the list is empty.
150
- * - At a boundary: returns the same `focusId` (no wrap).
151
- */
152
- export const nextFocusId = (
153
- orderedIds: string[],
154
- focusId: string | undefined,
155
- direction: -1 | 1,
156
- ): string | undefined => {
157
- if (orderedIds.length === 0) return undefined;
158
-
159
- const currentIndex = focusId === undefined ? -1 : orderedIds.indexOf(focusId);
160
- if (currentIndex === -1) {
161
- return direction > 0 ? orderedIds[0] : orderedIds[orderedIds.length - 1];
162
- }
163
-
164
- const nextIndex = Math.min(
165
- Math.max(currentIndex + direction, 0),
166
- orderedIds.length - 1,
167
- );
168
- return orderedIds[nextIndex];
169
- };
170
-
171
- /**
172
- * Hook for managing selection state in lists.
173
- * Supports single and multi-select operations.
174
- */
175
- export const useSelection = <T>(
176
- _options?: UseSelectionOptions<T>,
177
- ): UseSelectionReturn => {
178
- const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
179
- const [anchorId, setAnchorId] = useState<string | undefined>(undefined);
180
-
181
- const isSelected = useCallback(
182
- (id: string) => selectedIds.has(id),
183
- [selectedIds],
184
- );
185
-
186
- const toggle = useCallback((id: string) => {
187
- setAnchorId(id);
188
- setSelectedIds((prev) => {
189
- const next = new Set(prev);
190
- if (next.has(id)) {
191
- next.delete(id);
192
- } else {
193
- next.add(id);
194
- }
195
- return next;
196
- });
197
- }, []);
198
-
199
- const select = useCallback((id: string) => {
200
- setAnchorId(id);
201
- setSelectedIds((prev) => {
202
- if (prev.has(id)) return prev;
203
- const next = new Set(prev);
204
- next.add(id);
205
- return next;
206
- });
207
- }, []);
208
-
209
- const deselect = useCallback((id: string) => {
210
- setSelectedIds((prev) => {
211
- if (!prev.has(id)) return prev;
212
- const next = new Set(prev);
213
- next.delete(id);
214
- return next;
215
- });
216
- }, []);
217
-
218
- const selectAll = useCallback((ids: string[]) => {
219
- setSelectedIds(new Set(ids));
220
- }, []);
221
-
222
- const clearSelection = useCallback(() => {
223
- setAnchorId(undefined);
224
- setSelectedIds(new Set());
225
- }, []);
226
-
227
- const toggleAll = useCallback((ids: string[]) => {
228
- setSelectedIds((prev) => {
229
- const allSelected = ids.every((id) => prev.has(id));
230
- return allSelected ? new Set() : new Set(ids);
231
- });
232
- }, []);
233
-
234
- const setAnchor = useCallback((id: string) => {
235
- setAnchorId(id);
236
- }, []);
237
-
238
- // Bails out to the same `prev` reference when nothing was dropped, so a
239
- // caller can run this on every list refresh (e.g. an effect keyed on
240
- // `threads`) without forcing a render each time.
241
- const intersectWith = useCallback((currentIds: readonly string[]) => {
242
- setSelectedIds((prev) => {
243
- if (prev.size === 0) return prev;
244
- const next = intersectSelectedIds(prev, currentIds);
245
- return next.size === prev.size ? prev : next;
246
- });
247
- }, []);
248
-
249
- const selectRange = useCallback(
250
- (orderedIds: string[], targetId: string, fallbackAnchor?: string) => {
251
- const effectiveAnchor = resolveRangeAnchor(
252
- orderedIds,
253
- anchorId,
254
- fallbackAnchor,
255
- targetId,
256
- );
257
- setSelectedIds((prev) => {
258
- const range = computeRange(orderedIds, effectiveAnchor, targetId);
259
- if (range.length === 0) return prev;
260
- const next = new Set(prev);
261
- for (const id of range) {
262
- next.add(id);
263
- }
264
- return next;
265
- });
266
- // Whatever anchored the range becomes the stored anchor. A still-visible
267
- // stored anchor resolves to itself (unchanged), so consecutive
268
- // shift-clicks keep extending from the same origin; a stored anchor that
269
- // left the visible set is replaced by the row the range actually used, so
270
- // a filtered/search-narrowed list can build a range within what's visible.
271
- setAnchorId(effectiveAnchor);
272
- },
273
- [anchorId],
274
- );
275
-
276
- return {
277
- selectedIds,
278
- selectedCount: selectedIds.size,
279
- hasSelection: selectedIds.size > 0,
280
- isSelected,
281
- toggle,
282
- select,
283
- deselect,
284
- selectAll,
285
- clearSelection,
286
- toggleAll,
287
- selectRange,
288
- setAnchor,
289
- anchorId,
290
- intersectWith,
291
- };
292
- };