@remit/web-client 0.0.56 → 0.0.58

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.56",
3
+ "version": "0.0.58",
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": {
@@ -46,6 +46,7 @@ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
46
46
  import { useMailContext } from "@/lib/mail-context";
47
47
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
48
48
  import { resultsScopeForRoute, routeMailboxId } from "@/lib/search-scope";
49
+ import { showInlineSearchResults } from "@/lib/search-surface";
49
50
  import {
50
51
  parseSearchTokens,
51
52
  removeSearchToken,
@@ -76,6 +77,17 @@ interface MailListHeaderProps {
76
77
  */
77
78
  searchResultsLabel?: string;
78
79
  relatedResultsLabel?: string;
80
+ /**
81
+ * The body renders the committed search itself as a selectable list — the
82
+ * mailbox route's `MessageList` filters to the search results and hosts the
83
+ * multi-select toolbar and the "Select all N matching" escalation (#212).
84
+ * When set, a committed query keeps the body on tablet/desktop instead of
85
+ * swapping in the read-only two-engine `SearchResults` panel; the panel still
86
+ * shows while the query is being typed (uncommitted). Views whose body is not
87
+ * a search-result list (the brief, flagged, global search) leave this unset
88
+ * and keep the panel for every query.
89
+ */
90
+ searchResultsInBody?: boolean;
79
91
  }
80
92
 
81
93
  export function MailListHeader({
@@ -91,6 +103,7 @@ export function MailListHeader({
91
103
  onSelectSearchResult,
92
104
  searchResultsLabel = "Top matches",
93
105
  relatedResultsLabel = "Related",
106
+ searchResultsInBody = false,
94
107
  }: MailListHeaderProps) {
95
108
  const {
96
109
  accounts,
@@ -209,10 +222,19 @@ export function MailListHeader({
209
222
  );
210
223
  }
211
224
 
212
- // Tablet + desktop keep the inline toolbar search; once a query is present the
213
- // list-pane body swaps to the same sectioned results the phone takeover shows,
214
- // under the same FilterSheet. Clearing the query restores the normal list.
215
- const showInlineResults = tier !== "phone" && hasQuery;
225
+ // Tablet + desktop keep the inline toolbar search; while a query is being
226
+ // typed the list-pane body swaps to the same sectioned results the phone
227
+ // takeover shows, under the same FilterSheet. A view whose own body renders
228
+ // the committed search as a selectable list (`searchResultsInBody`, the
229
+ // mailbox route) keeps the panel only until the query commits to the URL,
230
+ // then hands back to its `MessageList` so the multi-select toolbar and the
231
+ // escalation are reachable (#212). Clearing the query restores the normal list.
232
+ const showInlineResults = showInlineSearchResults({
233
+ tier,
234
+ hasLiveInput: hasQuery,
235
+ hasCommittedQuery: searchQuery.trim().length > 0,
236
+ bodyRendersCommittedResults: searchResultsInBody,
237
+ });
216
238
  const handleSelectInlineResult = (result: SearchResult) => {
217
239
  setRecentSearches(saveRecentSearch(searchInput));
218
240
  onSelectSearchResult?.(result);
@@ -44,6 +44,12 @@ interface MailViewChromeProps {
44
44
  /** Section headings; see `MailListHeader`. */
45
45
  searchResultsLabel?: string;
46
46
  relatedResultsLabel?: string;
47
+ /**
48
+ * The body renders the committed search as a selectable list itself; see
49
+ * `MailListHeader`. The mailbox route sets this so a committed search yields
50
+ * its multi-select `MessageList` (#212); flagged leaves it unset.
51
+ */
52
+ searchResultsInBody?: boolean;
47
53
  }
48
54
 
49
55
  export function MailViewChrome({
@@ -65,6 +71,7 @@ export function MailViewChrome({
65
71
  onSelectSearchResult,
66
72
  searchResultsLabel,
67
73
  relatedResultsLabel,
74
+ searchResultsInBody,
68
75
  }: MailViewChromeProps) {
69
76
  const [expanded, setExpanded] = useState(false);
70
77
 
@@ -95,6 +102,7 @@ export function MailViewChrome({
95
102
  onSelectSearchResult={onSelectSearchResult}
96
103
  searchResultsLabel={searchResultsLabel}
97
104
  relatedResultsLabel={relatedResultsLabel}
105
+ searchResultsInBody={searchResultsInBody}
98
106
  >
99
107
  <FilterSheet {...filterConfig}>{children}</FilterSheet>
100
108
  </MailListHeader>
@@ -985,6 +985,11 @@ function MailboxList() {
985
985
  relatedResults={relatedResults}
986
986
  relatedLoading={relatedLoading}
987
987
  onSelectSearchResult={handleSelectSearchResult}
988
+ // A committed mailbox search renders in the body's own `MessageList`
989
+ // (its threads filter to the results), so the multi-select toolbar and
990
+ // the "Select all N matching" escalation are reachable on desktop (#212).
991
+ // The typing/uncommitted state still shows the two-engine panel.
992
+ searchResultsInBody
988
993
  >
989
994
  {body}
990
995
  </MailViewChrome>
@@ -93,6 +93,60 @@ describe("MessageList escalated actions", () => {
93
93
  });
94
94
  });
95
95
 
96
+ /**
97
+ * Advanced selection is no longer mobile-only (#212): the escalation engine is
98
+ * opened to desktop, and the desktop `SelectionToolbar` renders the same
99
+ * offer → counting → escalated → progress → notice states the mobile sheet
100
+ * carries, from one shared set of derivations so the two surfaces never drift.
101
+ */
102
+ describe("MessageList escalation reaches desktop (#212)", () => {
103
+ it("no longer gates the escalation engine on the mobile viewport", () => {
104
+ assert.match(
105
+ source,
106
+ /const escalationEnabled = isSearching && !!searchPredicate;/,
107
+ );
108
+ assert.doesNotMatch(
109
+ source,
110
+ /!isDesktop && isSearching && !!searchPredicate/,
111
+ );
112
+ });
113
+
114
+ it("feeds the desktop toolbar the shared escalation state", () => {
115
+ const toolbar = source.match(/<SelectionToolbar[\s\S]*?\/>/)?.[0] ?? "";
116
+ assert.match(toolbar, /statusLabel=\{selectionStatusLabel\}/);
117
+ assert.match(toolbar, /notice=\{escalationNotice\}/);
118
+ assert.match(toolbar, /progress=\{selectionProgress\}/);
119
+ // Desktop wires select-all only while searching — the mobile sheet carries
120
+ // it for any bounded selection.
121
+ assert.match(
122
+ toolbar,
123
+ /selectAll=\{escalationEnabled \? selectionSelectAll : undefined\}/,
124
+ );
125
+ assert.match(
126
+ toolbar,
127
+ /isCounting=\{escalation\.phase\.kind === "counting"\}/,
128
+ );
129
+ });
130
+
131
+ it("drives both surfaces from the same derivations", () => {
132
+ // The sheet and the toolbar read the same status label, select-all control
133
+ // and progress — a second copy for desktop is exactly the drift this
134
+ // guards against.
135
+ for (const shared of [
136
+ "selectionStatusLabel",
137
+ "selectionSelectAll",
138
+ "selectionProgress",
139
+ "selectionCount",
140
+ ]) {
141
+ const uses = source.match(new RegExp(`\\b${shared}\\b`, "g")) ?? [];
142
+ assert.ok(
143
+ uses.length >= 3,
144
+ `${shared} should be defined once and read by both surfaces`,
145
+ );
146
+ }
147
+ });
148
+ });
149
+
96
150
  /**
97
151
  * A bounded confirm-delete used to open the surviving neighbour by writing
98
152
  * `selectedMessageId` into the URL. On desktop that fills the reading pane
@@ -53,6 +53,7 @@ import {
53
53
  } from "@/lib/selection-sheet-mode";
54
54
  import { cn } from "@/lib/utils";
55
55
  import { MoveToTrigger } from "./MoveToTrigger";
56
+ import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
56
57
  import { OrganizeDialog } from "./organize/OrganizeDialog";
57
58
  import { SelectionToolbar } from "./SelectionToolbar";
58
59
  import { SwipeableMessageRow } from "./SwipeableMessageRow";
@@ -221,6 +222,12 @@ export const MessageList = ({
221
222
  const navigate = useNavigate();
222
223
  const isDesktop = useIsDesktop();
223
224
  const [organizeOpen, setOrganizeOpen] = useState(false);
225
+ // Mobile only: which selection-sheet entry opened the guided organize flow
226
+ // (`null` when closed). Desktop uses `organizeOpen` + `OrganizeDialog`
227
+ // instead — a sheet is a touch pattern (issue #211).
228
+ const [mobileOrganizeEntry, setMobileOrganizeEntry] = useState<
229
+ "select-similar" | "something-else" | null
230
+ >(null);
224
231
  const isSearching = !!searchQuery?.trim();
225
232
 
226
233
  // Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
@@ -275,6 +282,13 @@ export const MessageList = ({
275
282
  // it back to the count, and across that render the two disagree.
276
283
  const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
277
284
 
285
+ // When the selection empties — cancel, a completed action, a mailbox switch —
286
+ // the guided organize entry must reset too, so a later re-selection doesn't
287
+ // reopen the flow on a stale entry (issue #211).
288
+ useEffect(() => {
289
+ if (selectedCount === 0) setMobileOrganizeEntry(null);
290
+ }, [selectedCount]);
291
+
278
292
  // Pending delete, awaiting confirmation. `null` means the dialog is closed.
279
293
  // `source: "ids"` snapshots the concrete ids at request time so a selection
280
294
  // change behind the dialog can't retarget the delete — every keyboard/desktop
@@ -288,12 +302,12 @@ export const MessageList = ({
288
302
  null,
289
303
  );
290
304
 
291
- // Search-scoped escalated selection + chunked bulk delete (issue #92):
292
- // mobile only, and only while search has more matches than are loaded.
293
- // `orderedIds` below feeds `allLoadedSelected`; declared after this hook so
294
- // its callback deps stay simple — see the `orderedIds`/`handleRowSelect`
295
- // block.
296
- const escalationEnabled = !isDesktop && isSearching && !!searchPredicate;
305
+ // Search-scoped escalated selection + chunked bulk actions (issues #92, #212):
306
+ // available on both surfaces, and only while search has more matches than
307
+ // are loaded. `orderedIds` below feeds `allLoadedSelected`; declared after
308
+ // this hook so its callback deps stay simple — see the
309
+ // `orderedIds`/`handleRowSelect` block.
310
+ const escalationEnabled = isSearching && !!searchPredicate;
297
311
  const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
298
312
  const escalation = useEscalatedActions({
299
313
  mailboxId,
@@ -503,9 +517,9 @@ export const MessageList = ({
503
517
  [onDeleteMessages, focusedMessageId],
504
518
  );
505
519
 
506
- // Mobile-only: open the delete confirmation for the escalated predicate.
507
- // `escalation.phase` must already be "escalated" — the caller (the mobile
508
- // bar's onDelete) only wires this up in that state.
520
+ // Open the delete confirmation for the escalated predicate. `escalation.phase`
521
+ // must already be "escalated" — the selection bar's onDelete (both surfaces)
522
+ // only wires this up in that state.
509
523
  const requestEscalatedDelete = useCallback(() => {
510
524
  if (escalation.phase.kind !== "escalated") return;
511
525
  focusBeforeConfirmRef.current = focusedMessageId ?? null;
@@ -810,9 +824,10 @@ export const MessageList = ({
810
824
  [isDesktop, select],
811
825
  );
812
826
 
813
- // Mobile bar's Trash tap: an escalated selection has no materialized ids to
814
- // hand `requestDelete`, so it opens the predicate confirmation instead.
815
- const handleMobileDelete = useCallback(() => {
827
+ // The selection bar's Trash tap (both surfaces): an escalated selection has
828
+ // no materialized ids to hand `requestDelete`, so it opens the predicate
829
+ // confirmation instead.
830
+ const handleSelectionDelete = useCallback(() => {
816
831
  if (escalation.phase.kind === "escalated") {
817
832
  requestEscalatedDelete();
818
833
  return;
@@ -820,12 +835,12 @@ export const MessageList = ({
820
835
  handleDelete();
821
836
  }, [escalation.phase, requestEscalatedDelete, handleDelete]);
822
837
 
823
- // Mobile bar's X: means "stop what's happening" throughout, not just
824
- // "cancel selection" (issue #92 — the review flagged the X reading as
825
- // ambiguous once a delete is running). Counting and deleting both stop at
826
- // the next page boundary; an escalated-but-idle selection drops back to
827
- // bounded on the way out, same as tapping "Clear selection" first.
828
- const handleMobileCancel = useCallback(() => {
838
+ // The selection bar's X (both surfaces): means "stop what's happening"
839
+ // throughout, not just "cancel selection" (issue #92 — the review flagged
840
+ // the X reading as ambiguous once a run is going). Counting and a chunked
841
+ // run both stop at the next page boundary; an escalated-but-idle selection
842
+ // drops back to bounded on the way out, same as tapping "Clear selection".
843
+ const handleSelectionCancel = useCallback(() => {
829
844
  if (escalation.isRunning || escalation.phase.kind === "counting") {
830
845
  escalation.stop();
831
846
  return;
@@ -1080,24 +1095,6 @@ export const MessageList = ({
1080
1095
  // Single flat section — the mailbox view doesn't group by date.
1081
1096
  const sections = [{ id: "inbox", threads: [] }];
1082
1097
 
1083
- // Desktop selection toolbar replaces the pane header when any rows are selected.
1084
- const desktopSelectionBar =
1085
- hasSelection && isDesktop ? (
1086
- <SelectionToolbar
1087
- selectedCount={selectedCount}
1088
- onDelete={handleDelete}
1089
- onClearSelection={exitSelection}
1090
- onMarkAsRead={handleMarkAsRead}
1091
- onMove={onMoveMessages ? handleMoveSelected : undefined}
1092
- onOrganize={() => setOrganizeOpen(true)}
1093
- isDeleting={isDeleting}
1094
- isMoving={isMoving}
1095
- accountId={accountId}
1096
- currentMailboxId={mailboxId}
1097
- moveDisabledHint={moveDisabledHint}
1098
- />
1099
- ) : undefined;
1100
-
1101
1098
  // Tier one of the two-tier select-all (issue #92, following Gmail web):
1102
1099
  // every loaded row is checked. Computed against actual membership, not a
1103
1100
  // count comparison, so a transient mismatch (mid-render, before the
@@ -1117,13 +1114,15 @@ export const MessageList = ({
1117
1114
  escalation.phase.kind === "idle" &&
1118
1115
  !escalation.isRunning;
1119
1116
 
1120
- const mobileIsBusy = isDeleting || isMoving || escalation.isRunning;
1121
- const mobileCount =
1117
+ // The escalation-derived surface state viewport-independent, fed to both
1118
+ // the mobile sheet and the desktop toolbar so the two never diverge (#212).
1119
+ const selectionIsBusy = isDeleting || isMoving || escalation.isRunning;
1120
+ const selectionCount =
1122
1121
  escalation.phase.kind === "escalated"
1123
1122
  ? escalation.phase.total
1124
1123
  : selectedCount;
1125
1124
 
1126
- const mobileStatusLabel = escalation.runningAction
1125
+ const selectionStatusLabel = escalation.runningAction
1127
1126
  ? bulkActionProgressLabel(
1128
1127
  escalation.runningAction.kind,
1129
1128
  escalation.progress?.done ?? 0,
@@ -1137,7 +1136,10 @@ export const MessageList = ({
1137
1136
  ? escalatedStatusLabel(searchPredicate ?? {}, escalation.phase.total)
1138
1137
  : undefined;
1139
1138
 
1140
- const mobileSelectAll =
1139
+ // The select-all-loaded control. The mobile sheet carries it for any bounded
1140
+ // selection; the desktop toolbar only wires it while searching (below), where
1141
+ // escalating past the loaded page is possible.
1142
+ const selectionSelectAll =
1141
1143
  escalation.phase.kind !== "escalated" && orderedIds.length > 0
1142
1144
  ? {
1143
1145
  checked: allLoadedSelected,
@@ -1146,11 +1148,22 @@ export const MessageList = ({
1146
1148
  }
1147
1149
  : undefined;
1148
1150
 
1149
- // At most one notice at a time, ranked by how actionable it is: an
1150
- // in-progress counting/escalated state and its own action always wins;
1151
+ const selectionProgress =
1152
+ escalation.runningAction && escalation.progress
1153
+ ? {
1154
+ value: escalation.progress.done,
1155
+ max: escalation.progress.total,
1156
+ tone: bulkActionProgressTone(escalation.runningAction.kind),
1157
+ }
1158
+ : undefined;
1159
+
1160
+ // At most one escalation notice at a time, ranked by how actionable it is:
1161
+ // an in-progress counting/escalated state and its own action always wins;
1151
1162
  // otherwise a fresh escalation offer; otherwise a just-finished partial
1152
- // failure's Retry; otherwise the (rare) cross-account move hint.
1153
- const mobileNotice =
1163
+ // failure's Retry. The (rare) cross-account move hint is layered on per
1164
+ // surface below — the desktop toolbar shows it inline, the mobile sheet
1165
+ // folds it in as the lowest-priority notice.
1166
+ const escalationNotice =
1154
1167
  escalation.phase.kind === "counting"
1155
1168
  ? {
1156
1169
  tone: "info" as const,
@@ -1188,9 +1201,15 @@ export const MessageList = ({
1188
1201
  onClick: handleRetryFailed,
1189
1202
  },
1190
1203
  }
1191
- : moveDisabledHint
1192
- ? { tone: "warning" as const, text: moveDisabledHint }
1193
- : undefined;
1204
+ : undefined;
1205
+
1206
+ // The mobile sheet has no inline hint slot, so the cross-account move
1207
+ // restriction rides in as the lowest-priority notice.
1208
+ const mobileNotice =
1209
+ escalationNotice ??
1210
+ (moveDisabledHint
1211
+ ? { tone: "warning" as const, text: moveDisabledHint }
1212
+ : undefined);
1194
1213
 
1195
1214
  // Mobile: which content the peeking sheet routes to, from the escalation
1196
1215
  // machinery. `running` (a chunked delete/move/mark-read) and `counting` (a
@@ -1209,7 +1228,7 @@ export const MessageList = ({
1209
1228
  !isDesktop &&
1210
1229
  isMultiSelectMode &&
1211
1230
  shouldShowSelectionSheet(
1212
- mobileCount,
1231
+ selectionCount,
1213
1232
  mobileMode,
1214
1233
  mobileNotice !== undefined,
1215
1234
  );
@@ -1227,37 +1246,88 @@ export const MessageList = ({
1227
1246
  />
1228
1247
  ) : undefined;
1229
1248
 
1249
+ // The guided organize flow (issue #211) overlays the same positioned pane
1250
+ // ancestor as the sheet, so it is rendered from the sheet slot. The sheet
1251
+ // stays mounted underneath as the flow's dimmed backdrop.
1252
+ const mobileOrganizeFlow =
1253
+ mobileOrganizeEntry && accountId && !isDesktop && selectedCount > 0 ? (
1254
+ <MobileOrganizeFlow
1255
+ entry={mobileOrganizeEntry}
1256
+ accountId={accountId}
1257
+ mailboxId={mailboxId}
1258
+ selectedMessageIds={Array.from(selectedIds)}
1259
+ junkMailboxId={junkMailboxId}
1260
+ onClose={() => {
1261
+ setMobileOrganizeEntry(null);
1262
+ exitSelection();
1263
+ }}
1264
+ />
1265
+ ) : undefined;
1266
+
1230
1267
  const mobileSelectionSheet = showMobileSheet ? (
1231
1268
  <SelectionSheet
1232
- count={mobileCount}
1269
+ count={selectionCount}
1233
1270
  mode={mobileMode}
1234
- onCancel={handleMobileCancel}
1235
- onDelete={handleMobileDelete}
1271
+ onCancel={handleSelectionCancel}
1272
+ onDelete={handleSelectionDelete}
1236
1273
  onJunk={
1237
1274
  junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
1238
1275
  ? handleJunk
1239
1276
  : undefined
1240
1277
  }
1241
1278
  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
1279
+ onSelectSimilar={
1280
+ accountId ? () => setMobileOrganizeEntry("select-similar") : undefined
1256
1281
  }
1282
+ onSomethingElse={
1283
+ accountId ? () => setMobileOrganizeEntry("something-else") : undefined
1284
+ }
1285
+ moveSlot={mobileMoveSlot}
1286
+ isBusy={selectionIsBusy}
1287
+ selectAll={selectionSelectAll}
1288
+ statusLabel={selectionStatusLabel}
1289
+ progress={selectionProgress}
1257
1290
  notice={mobileNotice}
1258
1291
  />
1259
1292
  ) : undefined;
1260
1293
 
1294
+ // Both mobile overlays share the pane's positioned ancestor via the sheet
1295
+ // slot: the peeking sheet, and the guided organize flow above it.
1296
+ const mobileSelectionSurface =
1297
+ mobileSelectionSheet || mobileOrganizeFlow ? (
1298
+ <>
1299
+ {mobileSelectionSheet}
1300
+ {mobileOrganizeFlow}
1301
+ </>
1302
+ ) : undefined;
1303
+
1304
+ // Desktop selection toolbar replaces the pane header when any rows are
1305
+ // selected. It gains the same search-escalation states the mobile sheet
1306
+ // carries (issue #212) — the offer, counting, the escalated predicate and a
1307
+ // chunked run's progress — from the shared derivations above; the
1308
+ // cross-account move hint stays inline (the toolbar has a slot for it).
1309
+ const desktopSelectionBar =
1310
+ hasSelection && isDesktop ? (
1311
+ <SelectionToolbar
1312
+ selectedCount={selectionCount}
1313
+ onDelete={handleSelectionDelete}
1314
+ onClearSelection={handleSelectionCancel}
1315
+ onMarkAsRead={handleMarkAsRead}
1316
+ onMove={onMoveMessages ? handleMoveSelected : undefined}
1317
+ onOrganize={() => setOrganizeOpen(true)}
1318
+ isDeleting={isDeleting}
1319
+ isMoving={isMoving}
1320
+ accountId={accountId}
1321
+ currentMailboxId={mailboxId}
1322
+ moveDisabledHint={moveDisabledHint}
1323
+ selectAll={escalationEnabled ? selectionSelectAll : undefined}
1324
+ statusLabel={selectionStatusLabel}
1325
+ isCounting={escalation.phase.kind === "counting"}
1326
+ progress={selectionProgress}
1327
+ notice={escalationNotice}
1328
+ />
1329
+ ) : undefined;
1330
+
1261
1331
  const activeSelectionBar = desktopSelectionBar;
1262
1332
 
1263
1333
  // Roving tabindex: exactly one row is in the tab order, so Tab moves focus
@@ -1401,7 +1471,7 @@ export const MessageList = ({
1401
1471
  isDesktop={isDesktop}
1402
1472
  hideHeader={hideHeader}
1403
1473
  selectionBar={activeSelectionBar}
1404
- selectionSheet={mobileSelectionSheet}
1474
+ selectionSheet={mobileSelectionSurface}
1405
1475
  listBody={listState === "ready" ? virtualBody : undefined}
1406
1476
  />
1407
1477
  <ConfirmDialog
@@ -143,3 +143,132 @@ describe("SelectionToolbar", () => {
143
143
  assert.equal(organized, 1);
144
144
  });
145
145
  });
146
+
147
+ /**
148
+ * The search-escalation states desktop gains in #212: the same offer →
149
+ * counting → escalated → chunked-run flow the mobile sheet carries, driven by
150
+ * the shared derivations in `MessageList`. These assert the toolbar routes each
151
+ * state, never that it re-implements the engine.
152
+ */
153
+ describe("SelectionToolbar — search escalation", () => {
154
+ it("renders the select-all-loaded checkbox only when the control is wired", () => {
155
+ assert.equal(mount().query('[aria-label="Select all"]'), null);
156
+ harness?.close();
157
+ harness = undefined;
158
+ const dom = mount({
159
+ selectAll: {
160
+ checked: false,
161
+ indeterminate: true,
162
+ onChange: () => undefined,
163
+ },
164
+ });
165
+ assert.ok(dom.query('[aria-label="Select all"]'));
166
+ });
167
+
168
+ it("names the escalated scope through statusLabel instead of a bare count", () => {
169
+ const dom = mount({
170
+ selectedCount: 3412,
171
+ statusLabel: 'All 3,412 matching "npm" selected',
172
+ });
173
+ assert.match(dom.text(), /All 3,412 matching "npm" selected/);
174
+ assert.doesNotMatch(dom.text(), /messages selected/);
175
+ });
176
+
177
+ it("hides Delete while the total is still counting, and offers Stop", () => {
178
+ let stopped = 0;
179
+ const dom = mount({
180
+ isCounting: true,
181
+ statusLabel: "Counting… 1,900 so far",
182
+ notice: {
183
+ tone: "info",
184
+ text: "",
185
+ action: {
186
+ label: "Stop",
187
+ onClick: () => {
188
+ stopped += 1;
189
+ },
190
+ },
191
+ },
192
+ });
193
+ assert.equal(dom.query('[aria-label="Delete selected messages"]'), null);
194
+ dom.click(dom.byText("button", "Stop"));
195
+ assert.equal(stopped, 1);
196
+ });
197
+
198
+ it("offers the escalation control naming the scope, and escalating fires it", () => {
199
+ let escalated = 0;
200
+ const dom = mount({
201
+ selectAll: { checked: true, onChange: () => undefined },
202
+ notice: {
203
+ tone: "info",
204
+ text: "",
205
+ action: {
206
+ label: 'Select all matching "npm"',
207
+ onClick: () => {
208
+ escalated += 1;
209
+ },
210
+ },
211
+ },
212
+ });
213
+ dom.click(dom.byText("button", 'Select all matching "npm"'));
214
+ assert.equal(escalated, 1);
215
+ });
216
+
217
+ it("keeps every verb over an escalated selection — not delete-only (#114)", () => {
218
+ const dom = mount({
219
+ selectedCount: 3412,
220
+ statusLabel: 'All 3,412 matching "npm" selected',
221
+ onMarkAsRead: () => undefined,
222
+ onMove: () => undefined,
223
+ onOrganize: () => undefined,
224
+ accountId: "acc-1",
225
+ currentMailboxId: "mbx-inbox",
226
+ notice: {
227
+ tone: "info",
228
+ text: "",
229
+ action: { label: "Clear selection", onClick: () => undefined },
230
+ },
231
+ });
232
+ assert.ok(dom.query('[aria-label="Mark as read"]'));
233
+ assert.ok(dom.query('[aria-label="Move selected messages"]'));
234
+ assert.ok(dom.query('[aria-label="Delete selected messages"]'));
235
+ // Organize has no escalated-predicate path, so it withdraws once the
236
+ // selection names a scope through statusLabel.
237
+ assert.equal(dom.query('[aria-label="Organize similar messages"]'), null);
238
+ });
239
+
240
+ it("shows a progress bar mid-run and takes the verbs off screen", () => {
241
+ const dom = mount({
242
+ selectedCount: 3412,
243
+ statusLabel: "Moving 1,200 of 3,412…",
244
+ onMarkAsRead: () => undefined,
245
+ progress: { value: 1200, max: 3412, tone: "info" },
246
+ });
247
+ assert.ok(dom.query('[role="progressbar"]'));
248
+ assert.equal(dom.query('[aria-label="Delete selected messages"]'), null);
249
+ assert.equal(dom.query('[aria-label="Mark as read"]'), null);
250
+ });
251
+
252
+ it("names the succeeded count and retries exactly what is left over", () => {
253
+ let retried = 0;
254
+ const dom = mount({
255
+ selectedCount: 340,
256
+ notice: {
257
+ tone: "danger",
258
+ text: "3,072 moved to Trash. 340 couldn't be deleted.",
259
+ action: {
260
+ label: "Retry 340",
261
+ onClick: () => {
262
+ retried += 1;
263
+ },
264
+ },
265
+ },
266
+ });
267
+ assert.match(
268
+ dom.text(),
269
+ /3,072 moved to Trash\. 340 couldn't be deleted\./,
270
+ );
271
+ dom.click(dom.byText("button", "Retry 340"));
272
+ assert.equal(retried, 1);
273
+ });
274
+ });