@remit/web-client 0.0.55 → 0.0.56

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.55",
3
+ "version": "0.0.56",
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": {
@@ -70,13 +70,14 @@ describe("MessageList escalated actions", () => {
70
70
  });
71
71
 
72
72
  it("keeps mark-read and the move slot on an escalated selection", () => {
73
+ // The mobile sheet always carries the mark-read verb (it hides it itself
74
+ // while counting or busy); an escalated selection must never lose it.
75
+ assert.match(source, /onMarkRead=\{handleMarkAsRead\}/);
76
+ // The move slot is offered for a bounded selection or an escalated one —
77
+ // #114's rule that an escalated selection is never delete-only.
73
78
  assert.match(
74
79
  source,
75
- /onMarkRead=\{\s*escalation\.phase\.kind === "counting" \? undefined : handleMarkAsRead/,
76
- );
77
- assert.match(
78
- source,
79
- /moveSlot=\{\s*escalation\.phase\.kind !== "counting"/,
80
+ /onMoveMessages \|\| escalation\.phase\.kind === "escalated"/,
80
81
  );
81
82
  });
82
83
 
@@ -3,15 +3,17 @@ import {
3
3
  Banner,
4
4
  type Density,
5
5
  MessageListPane,
6
- SelectionTopBar,
6
+ SELECTION_SHEET_TEASER_HEIGHT,
7
+ SelectionSheet,
7
8
  } from "@remit/ui";
8
9
  import { useBlocker, useNavigate } from "@tanstack/react-router";
9
10
  import { useVirtualizer } from "@tanstack/react-virtual";
10
- import { Search, Sparkles } from "lucide-react";
11
+ import { Search } from "lucide-react";
11
12
  import type { RefObject } from "react";
12
13
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
14
  import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
14
15
  import { formatErrorMessage } from "@/components/ui/ErrorState";
16
+ import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
15
17
  import {
16
18
  type EscalatedAction,
17
19
  type EscalationSearchQuery,
@@ -45,6 +47,10 @@ import {
45
47
  deriveIsMultiSelectMode,
46
48
  shouldExitSelectionOnNavigate,
47
49
  } from "@/lib/selection-mode";
50
+ import {
51
+ resolveSelectionSheetMode,
52
+ shouldShowSelectionSheet,
53
+ } from "@/lib/selection-sheet-mode";
48
54
  import { cn } from "@/lib/utils";
49
55
  import { MoveToTrigger } from "./MoveToTrigger";
50
56
  import { OrganizeDialog } from "./organize/OrganizeDialog";
@@ -244,6 +250,10 @@ export const MessageList = ({
244
250
  // Swipe-to-read toggle hook
245
251
  const { toggleReadFor } = useToggleReadFor({ mailboxId });
246
252
 
253
+ // The Junk quick action moves the selection to the account's appointed Junk
254
+ // mailbox — the message-flags API has no `$Junk` field, so "junk" is a move.
255
+ const { junkMailboxId } = useJunkMailbox(accountId);
256
+
247
257
  // Selection state
248
258
  const {
249
259
  selectedIds,
@@ -742,6 +752,14 @@ export const MessageList = ({
742
752
  ],
743
753
  );
744
754
 
755
+ // Junk quick action (mobile sheet): move the selection to the appointed Junk
756
+ // mailbox. Reuses the same bounded/escalated move path as any other move, so
757
+ // the chunked run and the cross-account guard apply unchanged.
758
+ const handleJunk = useCallback(() => {
759
+ if (!junkMailboxId) return;
760
+ handleMoveSelected(junkMailboxId);
761
+ }, [junkMailboxId, handleMoveSelected]);
762
+
745
763
  // Cross-account guard: every selected thread row must belong to the
746
764
  // same account as the current mailbox. The list is already scoped to
747
765
  // one mailbox so in practice this is always single-account, but we
@@ -1174,61 +1192,73 @@ export const MessageList = ({
1174
1192
  ? { tone: "warning" as const, text: moveDisabledHint }
1175
1193
  : undefined;
1176
1194
 
1177
- // Mobile multi-select bar replaces the pane header during selection mode.
1178
- const mobileSelectionBar =
1179
- isMultiSelectMode && !isDesktop ? (
1180
- <SelectionTopBar
1181
- count={mobileCount}
1182
- onCancel={handleMobileCancel}
1183
- onDelete={handleMobileDelete}
1184
- onMarkRead={
1185
- escalation.phase.kind === "counting" ? undefined : handleMarkAsRead
1186
- }
1187
- isBusy={mobileIsBusy}
1188
- isCounting={escalation.phase.kind === "counting"}
1189
- statusLabel={mobileStatusLabel}
1190
- selectAll={mobileSelectAll}
1191
- progress={
1192
- escalation.runningAction && escalation.progress
1193
- ? {
1194
- value: escalation.progress.done,
1195
- max: escalation.progress.total,
1196
- tone: bulkActionProgressTone(escalation.runningAction.kind),
1197
- }
1198
- : undefined
1199
- }
1200
- notice={mobileNotice}
1201
- moveSlot={
1202
- escalation.phase.kind !== "counting" &&
1203
- !escalation.isRunning &&
1204
- (onMoveMessages || escalation.phase.kind === "escalated") &&
1205
- accountId &&
1206
- mailboxId ? (
1207
- <>
1208
- {escalation.phase.kind === "idle" && !moveDisabledHint && (
1209
- <button
1210
- type="button"
1211
- onClick={() => setOrganizeOpen(true)}
1212
- className="min-h-11 min-w-11 inline-flex shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
1213
- aria-label="Organize similar messages"
1214
- >
1215
- <Sparkles className="size-4" />
1216
- </button>
1217
- )}
1218
- <MoveToTrigger
1219
- accountId={accountId}
1220
- currentMailboxId={mailboxId}
1221
- onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
1222
- disabledHint={moveDisabledHint}
1223
- label="Move selected messages"
1224
- />
1225
- </>
1226
- ) : undefined
1227
- }
1195
+ // Mobile: which content the peeking sheet routes to, from the escalation
1196
+ // machinery. `running` (a chunked delete/move/mark-read) and `counting` (a
1197
+ // predicate still paging) replace the idle quick actions with status and
1198
+ // progress; `escalated` keeps the verbs over the whole predicate.
1199
+ const mobileMode = resolveSelectionSheetMode({
1200
+ isRunning: escalation.isRunning,
1201
+ isCounting: escalation.phase.kind === "counting",
1202
+ isEscalated: escalation.phase.kind === "escalated",
1203
+ });
1204
+
1205
+ // The sheet is the sole mobile selection surface. It rises at 2+ selected
1206
+ // (the prototype threshold) or in any non-idle escalation state — a single
1207
+ // selected row enters selection mode without raising it, matching today.
1208
+ const showMobileSheet =
1209
+ !isDesktop &&
1210
+ isMultiSelectMode &&
1211
+ shouldShowSelectionSheet(
1212
+ mobileCount,
1213
+ mobileMode,
1214
+ mobileNotice !== undefined,
1215
+ );
1216
+
1217
+ const mobileMoveSlot =
1218
+ (onMoveMessages || escalation.phase.kind === "escalated") &&
1219
+ accountId &&
1220
+ mailboxId ? (
1221
+ <MoveToTrigger
1222
+ accountId={accountId}
1223
+ currentMailboxId={mailboxId}
1224
+ onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
1225
+ disabledHint={moveDisabledHint}
1226
+ label="Move selected messages"
1228
1227
  />
1229
1228
  ) : undefined;
1230
1229
 
1231
- const activeSelectionBar = desktopSelectionBar ?? mobileSelectionBar;
1230
+ const mobileSelectionSheet = showMobileSheet ? (
1231
+ <SelectionSheet
1232
+ count={mobileCount}
1233
+ mode={mobileMode}
1234
+ onCancel={handleMobileCancel}
1235
+ onDelete={handleMobileDelete}
1236
+ onJunk={
1237
+ junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
1238
+ ? handleJunk
1239
+ : undefined
1240
+ }
1241
+ onMarkRead={handleMarkAsRead}
1242
+ onSelectSimilar={accountId ? () => setOrganizeOpen(true) : undefined}
1243
+ onSomethingElse={accountId ? () => setOrganizeOpen(true) : undefined}
1244
+ moveSlot={mobileMoveSlot}
1245
+ isBusy={mobileIsBusy}
1246
+ selectAll={mobileSelectAll}
1247
+ statusLabel={mobileStatusLabel}
1248
+ progress={
1249
+ escalation.runningAction && escalation.progress
1250
+ ? {
1251
+ value: escalation.progress.done,
1252
+ max: escalation.progress.total,
1253
+ tone: bulkActionProgressTone(escalation.runningAction.kind),
1254
+ }
1255
+ : undefined
1256
+ }
1257
+ notice={mobileNotice}
1258
+ />
1259
+ ) : undefined;
1260
+
1261
+ const activeSelectionBar = desktopSelectionBar;
1232
1262
 
1233
1263
  // Roving tabindex: exactly one row is in the tab order, so Tab moves focus
1234
1264
  // into the list at the cursor and Shift+Tab moves back out to the side panel
@@ -1257,6 +1287,13 @@ export const MessageList = ({
1257
1287
  aria-multiselectable
1258
1288
  aria-label={listTitle}
1259
1289
  className="flex-1 overflow-y-auto"
1290
+ // Pad the list so its last rows clear the peeking teaser (~56px)
1291
+ // rather than hiding behind it.
1292
+ style={
1293
+ showMobileSheet
1294
+ ? { paddingBottom: SELECTION_SHEET_TEASER_HEIGHT }
1295
+ : undefined
1296
+ }
1260
1297
  >
1261
1298
  {/* Virtualizer scaffolding — presentational so the listbox sees the
1262
1299
  rows as its options rather than these positioning wrappers. */}
@@ -1364,6 +1401,7 @@ export const MessageList = ({
1364
1401
  isDesktop={isDesktop}
1365
1402
  hideHeader={hideHeader}
1366
1403
  selectionBar={activeSelectionBar}
1404
+ selectionSheet={mobileSelectionSheet}
1367
1405
  listBody={listState === "ready" ? virtualBody : undefined}
1368
1406
  />
1369
1407
  <ConfirmDialog
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import {
4
+ resolveSelectionSheetMode,
5
+ SELECTION_SHEET_MIN_COUNT,
6
+ shouldShowSelectionSheet,
7
+ } from "./selection-sheet-mode";
8
+
9
+ describe("resolveSelectionSheetMode", () => {
10
+ test("is idle for a plain bounded selection", () => {
11
+ assert.equal(
12
+ resolveSelectionSheetMode({
13
+ isRunning: false,
14
+ isCounting: false,
15
+ isEscalated: false,
16
+ }),
17
+ "idle",
18
+ );
19
+ });
20
+
21
+ test("is escalated once the selection is the search predicate", () => {
22
+ assert.equal(
23
+ resolveSelectionSheetMode({
24
+ isRunning: false,
25
+ isCounting: false,
26
+ isEscalated: true,
27
+ }),
28
+ "escalated",
29
+ );
30
+ });
31
+
32
+ test("is counting while the predicate is still paging to a total", () => {
33
+ assert.equal(
34
+ resolveSelectionSheetMode({
35
+ isRunning: false,
36
+ isCounting: true,
37
+ isEscalated: false,
38
+ }),
39
+ "counting",
40
+ );
41
+ });
42
+
43
+ test("running wins over escalated — a chunked run is both", () => {
44
+ assert.equal(
45
+ resolveSelectionSheetMode({
46
+ isRunning: true,
47
+ isCounting: false,
48
+ isEscalated: true,
49
+ }),
50
+ "running",
51
+ );
52
+ });
53
+
54
+ test("running wins over counting", () => {
55
+ assert.equal(
56
+ resolveSelectionSheetMode({
57
+ isRunning: true,
58
+ isCounting: true,
59
+ isEscalated: false,
60
+ }),
61
+ "running",
62
+ );
63
+ });
64
+
65
+ test("counting wins over escalated", () => {
66
+ assert.equal(
67
+ resolveSelectionSheetMode({
68
+ isRunning: false,
69
+ isCounting: true,
70
+ isEscalated: true,
71
+ }),
72
+ "counting",
73
+ );
74
+ });
75
+ });
76
+
77
+ describe("shouldShowSelectionSheet", () => {
78
+ test("hides at a single selected row while idle", () => {
79
+ assert.equal(shouldShowSelectionSheet(1, "idle"), false);
80
+ });
81
+
82
+ test("shows at the two-row threshold", () => {
83
+ assert.equal(
84
+ shouldShowSelectionSheet(SELECTION_SHEET_MIN_COUNT, "idle"),
85
+ true,
86
+ );
87
+ });
88
+
89
+ test("shows for any non-idle state even below the threshold", () => {
90
+ assert.equal(shouldShowSelectionSheet(0, "counting"), true);
91
+ assert.equal(shouldShowSelectionSheet(1, "running"), true);
92
+ assert.equal(shouldShowSelectionSheet(0, "escalated"), true);
93
+ });
94
+
95
+ test("shows a pending notice at a single idle row so it is never dropped", () => {
96
+ // A bulk delete that leaves one message behind returns to idle at count 1
97
+ // with a "1 couldn't be deleted / Retry" notice; the escalation offer can
98
+ // likewise sit at one loaded row. The sheet must mount to surface either.
99
+ assert.equal(shouldShowSelectionSheet(1, "idle", true), true);
100
+ });
101
+
102
+ test("still hides a single idle row with no notice", () => {
103
+ assert.equal(shouldShowSelectionSheet(1, "idle", false), false);
104
+ });
105
+ });
@@ -0,0 +1,49 @@
1
+ import type { SelectionSheetMode } from "@remit/ui";
2
+
3
+ /**
4
+ * Routes the mobile selection sheet to one of its four content states from the
5
+ * escalation machinery's flags. Pure so the routing — which is what decides
6
+ * whether the sheet shows idle quick actions or the counting / running /
7
+ * escalated status — is testable without the sheet or a DOM.
8
+ *
9
+ * Precedence, highest first:
10
+ * 1. `running` — a chunked delete/move/mark-read is in flight; progress owns
11
+ * the sheet even over an escalated selection (an escalated run is both).
12
+ * 2. `counting` — a search predicate is still paging to its total.
13
+ * 3. `escalated` — the selection is the search predicate, not a loaded id set.
14
+ * 4. `idle` — a bounded selection with the quick actions and smart-flow rows.
15
+ */
16
+ export const resolveSelectionSheetMode = (input: {
17
+ isRunning: boolean;
18
+ isCounting: boolean;
19
+ isEscalated: boolean;
20
+ }): SelectionSheetMode => {
21
+ if (input.isRunning) return "running";
22
+ if (input.isCounting) return "counting";
23
+ if (input.isEscalated) return "escalated";
24
+ return "idle";
25
+ };
26
+
27
+ /**
28
+ * The count threshold the peeking teaser rises at — two or more selected, the
29
+ * prototype's threshold. Below it selection mode is still entered (rows show
30
+ * their checkboxes) but no sheet appears, matching today's single-select
31
+ * behaviour of leaving the list chrome alone.
32
+ */
33
+ export const SELECTION_SHEET_MIN_COUNT = 2;
34
+
35
+ /**
36
+ * Whether the mobile sheet should be mounted: two or more rows selected, or any
37
+ * non-idle escalation state (counting, running, escalated), or a pending notice
38
+ * that has to be surfaced. The last case is why a single-row selection can still
39
+ * raise the sheet — a bulk run that leaves exactly one message behind returns to
40
+ * idle at count 1 with a "1 couldn't be deleted / Retry" notice, and a search
41
+ * with one loaded row plus more matches carries the escalation offer at count 1.
42
+ * Suppressing the sheet there would drop the notice with no way to retry.
43
+ */
44
+ export const shouldShowSelectionSheet = (
45
+ count: number,
46
+ mode: SelectionSheetMode,
47
+ hasNotice = false,
48
+ ): boolean =>
49
+ count >= SELECTION_SHEET_MIN_COUNT || mode !== "idle" || hasNotice;