@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 +1 -1
- package/src/components/mail/MailListHeader.tsx +1 -2
- package/src/components/mail/MailViewChrome.tsx +1 -1
- package/src/components/mail/MessageList.tsx +7 -17
- package/src/components/mail/MessageListItem.tsx +6 -2
- package/src/components/mail/MessageRow.tsx +3 -2
- package/src/components/mail/SwipeableMessageRow.tsx +1 -1
- package/src/components/mail/useModifierSelect.render.test.ts +2 -26
- package/src/components/mail/useModifierSelect.ts +1 -10
- package/src/hooks/escalated-run-lifetime.render.test.ts +3 -1
- package/src/hooks/useEscalatedActions.ts +41 -35
- package/src/hooks/useListCursor.ts +7 -5
- package/src/lib/bulk-actions.test.ts +3 -61
- package/src/lib/bulk-actions.ts +8 -47
- package/src/lib/selection-mode.test.ts +1 -25
- package/src/lib/selection-mode.ts +0 -12
- package/src/hooks/useSelection.test.ts +0 -244
- package/src/hooks/useSelection.ts +0 -292
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
?
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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"
|
|
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
|
-
/**
|
|
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 —
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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"
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
261
|
+
error,
|
|
250
262
|
),
|
|
251
263
|
);
|
|
252
264
|
setPhase({ kind: "idle" });
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
283
|
-
//
|
|
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 "
|
|
22
|
-
import {
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
359
|
-
//
|
|
360
|
-
//
|
|
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,
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -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
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
-
};
|