@remit/web-client 0.0.57 → 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.57",
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
@@ -302,12 +302,12 @@ export const MessageList = ({
302
302
  null,
303
303
  );
304
304
 
305
- // Search-scoped escalated selection + chunked bulk delete (issue #92):
306
- // mobile only, and only while search has more matches than are loaded.
307
- // `orderedIds` below feeds `allLoadedSelected`; declared after this hook so
308
- // its callback deps stay simple — see the `orderedIds`/`handleRowSelect`
309
- // block.
310
- 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;
311
311
  const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
312
312
  const escalation = useEscalatedActions({
313
313
  mailboxId,
@@ -517,9 +517,9 @@ export const MessageList = ({
517
517
  [onDeleteMessages, focusedMessageId],
518
518
  );
519
519
 
520
- // Mobile-only: open the delete confirmation for the escalated predicate.
521
- // `escalation.phase` must already be "escalated" — the caller (the mobile
522
- // 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.
523
523
  const requestEscalatedDelete = useCallback(() => {
524
524
  if (escalation.phase.kind !== "escalated") return;
525
525
  focusBeforeConfirmRef.current = focusedMessageId ?? null;
@@ -824,9 +824,10 @@ export const MessageList = ({
824
824
  [isDesktop, select],
825
825
  );
826
826
 
827
- // Mobile bar's Trash tap: an escalated selection has no materialized ids to
828
- // hand `requestDelete`, so it opens the predicate confirmation instead.
829
- 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(() => {
830
831
  if (escalation.phase.kind === "escalated") {
831
832
  requestEscalatedDelete();
832
833
  return;
@@ -834,12 +835,12 @@ export const MessageList = ({
834
835
  handleDelete();
835
836
  }, [escalation.phase, requestEscalatedDelete, handleDelete]);
836
837
 
837
- // Mobile bar's X: means "stop what's happening" throughout, not just
838
- // "cancel selection" (issue #92 — the review flagged the X reading as
839
- // ambiguous once a delete is running). Counting and deleting both stop at
840
- // the next page boundary; an escalated-but-idle selection drops back to
841
- // bounded on the way out, same as tapping "Clear selection" first.
842
- 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(() => {
843
844
  if (escalation.isRunning || escalation.phase.kind === "counting") {
844
845
  escalation.stop();
845
846
  return;
@@ -1094,24 +1095,6 @@ export const MessageList = ({
1094
1095
  // Single flat section — the mailbox view doesn't group by date.
1095
1096
  const sections = [{ id: "inbox", threads: [] }];
1096
1097
 
1097
- // Desktop selection toolbar replaces the pane header when any rows are selected.
1098
- const desktopSelectionBar =
1099
- hasSelection && isDesktop ? (
1100
- <SelectionToolbar
1101
- selectedCount={selectedCount}
1102
- onDelete={handleDelete}
1103
- onClearSelection={exitSelection}
1104
- onMarkAsRead={handleMarkAsRead}
1105
- onMove={onMoveMessages ? handleMoveSelected : undefined}
1106
- onOrganize={() => setOrganizeOpen(true)}
1107
- isDeleting={isDeleting}
1108
- isMoving={isMoving}
1109
- accountId={accountId}
1110
- currentMailboxId={mailboxId}
1111
- moveDisabledHint={moveDisabledHint}
1112
- />
1113
- ) : undefined;
1114
-
1115
1098
  // Tier one of the two-tier select-all (issue #92, following Gmail web):
1116
1099
  // every loaded row is checked. Computed against actual membership, not a
1117
1100
  // count comparison, so a transient mismatch (mid-render, before the
@@ -1131,13 +1114,15 @@ export const MessageList = ({
1131
1114
  escalation.phase.kind === "idle" &&
1132
1115
  !escalation.isRunning;
1133
1116
 
1134
- const mobileIsBusy = isDeleting || isMoving || escalation.isRunning;
1135
- 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 =
1136
1121
  escalation.phase.kind === "escalated"
1137
1122
  ? escalation.phase.total
1138
1123
  : selectedCount;
1139
1124
 
1140
- const mobileStatusLabel = escalation.runningAction
1125
+ const selectionStatusLabel = escalation.runningAction
1141
1126
  ? bulkActionProgressLabel(
1142
1127
  escalation.runningAction.kind,
1143
1128
  escalation.progress?.done ?? 0,
@@ -1151,7 +1136,10 @@ export const MessageList = ({
1151
1136
  ? escalatedStatusLabel(searchPredicate ?? {}, escalation.phase.total)
1152
1137
  : undefined;
1153
1138
 
1154
- 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 =
1155
1143
  escalation.phase.kind !== "escalated" && orderedIds.length > 0
1156
1144
  ? {
1157
1145
  checked: allLoadedSelected,
@@ -1160,11 +1148,22 @@ export const MessageList = ({
1160
1148
  }
1161
1149
  : undefined;
1162
1150
 
1163
- // At most one notice at a time, ranked by how actionable it is: an
1164
- // 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;
1165
1162
  // otherwise a fresh escalation offer; otherwise a just-finished partial
1166
- // failure's Retry; otherwise the (rare) cross-account move hint.
1167
- 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 =
1168
1167
  escalation.phase.kind === "counting"
1169
1168
  ? {
1170
1169
  tone: "info" as const,
@@ -1202,9 +1201,15 @@ export const MessageList = ({
1202
1201
  onClick: handleRetryFailed,
1203
1202
  },
1204
1203
  }
1205
- : moveDisabledHint
1206
- ? { tone: "warning" as const, text: moveDisabledHint }
1207
- : 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);
1208
1213
 
1209
1214
  // Mobile: which content the peeking sheet routes to, from the escalation
1210
1215
  // machinery. `running` (a chunked delete/move/mark-read) and `counting` (a
@@ -1223,7 +1228,7 @@ export const MessageList = ({
1223
1228
  !isDesktop &&
1224
1229
  isMultiSelectMode &&
1225
1230
  shouldShowSelectionSheet(
1226
- mobileCount,
1231
+ selectionCount,
1227
1232
  mobileMode,
1228
1233
  mobileNotice !== undefined,
1229
1234
  );
@@ -1261,10 +1266,10 @@ export const MessageList = ({
1261
1266
 
1262
1267
  const mobileSelectionSheet = showMobileSheet ? (
1263
1268
  <SelectionSheet
1264
- count={mobileCount}
1269
+ count={selectionCount}
1265
1270
  mode={mobileMode}
1266
- onCancel={handleMobileCancel}
1267
- onDelete={handleMobileDelete}
1271
+ onCancel={handleSelectionCancel}
1272
+ onDelete={handleSelectionDelete}
1268
1273
  onJunk={
1269
1274
  junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
1270
1275
  ? handleJunk
@@ -1278,18 +1283,10 @@ export const MessageList = ({
1278
1283
  accountId ? () => setMobileOrganizeEntry("something-else") : undefined
1279
1284
  }
1280
1285
  moveSlot={mobileMoveSlot}
1281
- isBusy={mobileIsBusy}
1282
- selectAll={mobileSelectAll}
1283
- statusLabel={mobileStatusLabel}
1284
- progress={
1285
- escalation.runningAction && escalation.progress
1286
- ? {
1287
- value: escalation.progress.done,
1288
- max: escalation.progress.total,
1289
- tone: bulkActionProgressTone(escalation.runningAction.kind),
1290
- }
1291
- : undefined
1292
- }
1286
+ isBusy={selectionIsBusy}
1287
+ selectAll={selectionSelectAll}
1288
+ statusLabel={selectionStatusLabel}
1289
+ progress={selectionProgress}
1293
1290
  notice={mobileNotice}
1294
1291
  />
1295
1292
  ) : undefined;
@@ -1304,6 +1301,33 @@ export const MessageList = ({
1304
1301
  </>
1305
1302
  ) : undefined;
1306
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
+
1307
1331
  const activeSelectionBar = desktopSelectionBar;
1308
1332
 
1309
1333
  // Roving tabindex: exactly one row is in the tab order, so Tab moves focus
@@ -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
+ });
@@ -0,0 +1,194 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { SelectionToolbar } from "./SelectionToolbar";
3
+
4
+ /**
5
+ * The desktop bulk-action bar. It keeps its bounded verbs (mark-read, move,
6
+ * delete, organize) and, from issue #212, gains the search-escalation states
7
+ * the mobile `SelectionSheet` already carries: the "Select all N matching…"
8
+ * offer, a running count, the escalated predicate, a chunked run's progress,
9
+ * and a partial-failure Retry. Both surfaces read the same derivations in
10
+ * `MessageList`, so these stories and the kit `SelectionTopBar` stories track
11
+ * the one state matrix.
12
+ *
13
+ * The Move verb needs the account-scoped `MoveToTrigger` (its own query and
14
+ * folder data), so it is left out here to keep these stories provider-free —
15
+ * its visual is covered by the kit `SelectionTopBar` stories' move slot. The
16
+ * escalated stories below still assert verb parity through the mark-read and
17
+ * delete verbs remaining available over the predicate.
18
+ */
19
+ const meta: Meta<typeof SelectionToolbar> = {
20
+ title: "Screens/WebClient/SelectionToolbar",
21
+ component: SelectionToolbar,
22
+ parameters: { layout: "padded" },
23
+ args: {
24
+ selectedCount: 3,
25
+ onDelete: () => undefined,
26
+ onClearSelection: () => undefined,
27
+ onMarkAsRead: () => undefined,
28
+ },
29
+ render: (args) => (
30
+ <div className="w-full rounded-md border border-line">
31
+ <SelectionToolbar {...args} />
32
+ </div>
33
+ ),
34
+ };
35
+ export default meta;
36
+
37
+ type Story = StoryObj<typeof SelectionToolbar>;
38
+
39
+ /** A plain bounded selection: the count in words, mark-read and delete. */
40
+ export const Bounded: Story = {};
41
+
42
+ export const OneSelected: Story = { args: { selectedCount: 1 } };
43
+
44
+ /**
45
+ * Some but not all loaded rows checked while searching: the select-all control
46
+ * renders the tri-state dash.
47
+ */
48
+ export const SelectAllIndeterminate: Story = {
49
+ args: {
50
+ selectAll: {
51
+ checked: false,
52
+ indeterminate: true,
53
+ onChange: () => undefined,
54
+ },
55
+ },
56
+ };
57
+
58
+ /**
59
+ * Every loaded row checked. The label names the loaded scope — "All 47 loaded
60
+ * selected" — rather than a bare count next to a fully ticked box.
61
+ */
62
+ export const AllLoadedSelected: Story = {
63
+ args: {
64
+ selectedCount: 47,
65
+ selectAll: { checked: true, onChange: () => undefined },
66
+ },
67
+ };
68
+
69
+ /**
70
+ * Search has more matches than are loaded: an escalation offer naming the
71
+ * scope (a real button, not prose). Tapping it pays for the real count.
72
+ */
73
+ export const EscalationAvailable: Story = {
74
+ args: {
75
+ selectedCount: 47,
76
+ selectAll: { checked: true, onChange: () => undefined },
77
+ notice: {
78
+ tone: "info",
79
+ text: "",
80
+ action: {
81
+ label: 'Select all matching "npm"',
82
+ onClick: () => undefined,
83
+ },
84
+ },
85
+ },
86
+ };
87
+
88
+ /**
89
+ * The result set is still paging to its total: a running count, Delete hidden
90
+ * (the count it would act on isn't known yet), and an explicit Stop.
91
+ */
92
+ export const Counting: Story = {
93
+ args: {
94
+ selectedCount: 47,
95
+ isCounting: true,
96
+ statusLabel: "Counting… 1,900 so far",
97
+ selectAll: { checked: true, onChange: () => undefined },
98
+ notice: {
99
+ tone: "info",
100
+ text: "",
101
+ action: { label: "Stop", onClick: () => undefined },
102
+ },
103
+ },
104
+ };
105
+
106
+ /** Past ~10s the counting state says so rather than looking stuck. */
107
+ export const CountingLargeResultSet: Story = {
108
+ args: {
109
+ selectedCount: 0,
110
+ isCounting: true,
111
+ statusLabel: "Counting… 12,400 so far. This is a big result set.",
112
+ notice: {
113
+ tone: "info",
114
+ text: "",
115
+ action: { label: "Stop", onClick: () => undefined },
116
+ },
117
+ },
118
+ };
119
+
120
+ /**
121
+ * The selection is now the search predicate: the count names the query's total,
122
+ * every verb stays available over it (#114 — not delete-only), and the notice
123
+ * offers a way back to the bounded selection.
124
+ */
125
+ export const Escalated: Story = {
126
+ args: {
127
+ selectedCount: 3412,
128
+ statusLabel: 'All 3,412 matching "npm" selected',
129
+ notice: {
130
+ tone: "info",
131
+ text: "",
132
+ action: { label: "Clear selection", onClick: () => undefined },
133
+ },
134
+ },
135
+ };
136
+
137
+ /**
138
+ * A chunked delete over the predicate: a running total and a determinate
139
+ * progress bar, the verbs off screen (the bar is the only thing that can act
140
+ * mid-run), toned as destructive.
141
+ */
142
+ export const DeletingWithProgress: Story = {
143
+ args: {
144
+ selectedCount: 3412,
145
+ statusLabel: "Deleting 1,200 of 3,412…",
146
+ progress: { value: 1200, max: 3412, tone: "danger" },
147
+ },
148
+ };
149
+
150
+ /** A move over the escalated selection: the same run, toned as ordinary
151
+ * progress and worded for the action that is running. */
152
+ export const MovingWithProgress: Story = {
153
+ args: {
154
+ selectedCount: 3412,
155
+ statusLabel: "Moving 1,200 of 3,412…",
156
+ progress: { value: 1200, max: 3412, tone: "info" },
157
+ },
158
+ };
159
+
160
+ /** Mark-read over the escalated selection. */
161
+ export const MarkingReadWithProgress: Story = {
162
+ args: {
163
+ selectedCount: 3412,
164
+ statusLabel: "Marking 1,200 of 3,412 as read…",
165
+ progress: { value: 1200, max: 3412, tone: "info" },
166
+ },
167
+ };
168
+
169
+ /**
170
+ * A run left some messages unreached: the notice names how many landed, the
171
+ * count reflects only what is still selected (the failures), and Retry targets
172
+ * exactly that.
173
+ */
174
+ export const PartialFailure: Story = {
175
+ args: {
176
+ selectedCount: 340,
177
+ notice: {
178
+ tone: "danger",
179
+ text: "3,072 moved to Trash. 340 couldn't be deleted.",
180
+ action: { label: "Retry 340", onClick: () => undefined },
181
+ },
182
+ },
183
+ };
184
+
185
+ /**
186
+ * A selection spanning accounts: Move is withdrawn (it only works within one
187
+ * account) and the reason is stated inline.
188
+ */
189
+ export const CrossAccountHint: Story = {
190
+ args: {
191
+ moveDisabledHint:
192
+ "Move only works within one account — clear selection or pick messages from a single account",
193
+ },
194
+ };
@@ -1,7 +1,14 @@
1
+ import { Banner, type BannerTone, Checkbox, ProgressBar } from "@remit/ui";
1
2
  import { Loader2, MailOpen, Sparkles, Trash2, X } from "lucide-react";
2
3
  import { cn } from "@/lib/utils";
3
4
  import { MoveToTrigger } from "./MoveToTrigger";
4
5
 
6
+ export interface SelectionToolbarNotice {
7
+ tone: BannerTone;
8
+ text: string;
9
+ action?: { label: string; onClick: () => void };
10
+ }
11
+
5
12
  interface SelectionToolbarProps {
6
13
  selectedCount: number;
7
14
  onDelete: () => void;
@@ -33,6 +40,42 @@ interface SelectionToolbarProps {
33
40
  * Move only works within one account.
34
41
  */
35
42
  moveDisabledHint?: string;
43
+ /**
44
+ * Select-all-loaded control, rendered between the clear button and the
45
+ * count. Presence renders the checkbox; `indeterminate` is the some-selected
46
+ * tri-state. Wired only while searching, where escalating past the loaded
47
+ * page is possible (issue #212).
48
+ */
49
+ selectAll?: {
50
+ checked: boolean;
51
+ indeterminate?: boolean;
52
+ onChange: () => void;
53
+ };
54
+ /**
55
+ * Overrides the "{count} messages selected" text. Names the scope once it is
56
+ * anything other than a materialized selection — an escalated predicate
57
+ * ("All 3,412 matching \"npm\" selected"), a running count ("Counting… 1,900
58
+ * so far"), or bulk-run progress ("Deleting 1,200 of 3,412…").
59
+ */
60
+ statusLabel?: string;
61
+ /**
62
+ * True while a search result set is still paging to its total. Hides Delete
63
+ * (and Move) — the count they would act on isn't known yet.
64
+ */
65
+ isCounting?: boolean;
66
+ /**
67
+ * Determinate progress for a chunked/escalated run in flight. Renders a
68
+ * `ProgressBar` below the action row and takes the verbs off screen — the
69
+ * bar is the only thing that can act mid-run.
70
+ */
71
+ progress?: { value: number; max: number; tone?: BannerTone };
72
+ /**
73
+ * At-most-one toned status line below the action row, optionally carrying an
74
+ * action button — the "Select all N matching…" escalation offer, a "Stop"
75
+ * during counting, a "Clear selection" once escalated, or a partial-failure
76
+ * "Retry N" (issue #212).
77
+ */
78
+ notice?: SelectionToolbarNotice;
36
79
  }
37
80
 
38
81
  export const SelectionToolbar = ({
@@ -47,93 +90,164 @@ export const SelectionToolbar = ({
47
90
  accountId,
48
91
  currentMailboxId,
49
92
  moveDisabledHint,
93
+ selectAll,
94
+ statusLabel,
95
+ isCounting = false,
96
+ progress,
97
+ notice,
50
98
  }: SelectionToolbarProps) => {
51
- const isBusy = isDeleting || isMoving;
52
99
  if (selectedCount === 0) return null;
53
100
 
101
+ // A chunked/escalated run owns the bar: the progress bar is the only signal
102
+ // that can act, so the verbs come off screen until it ends (issue #212).
103
+ const isRunning = progress !== undefined;
104
+ const isBusy = isDeleting || isMoving || isRunning;
105
+ // The verbs are suppressed while a run is in flight or a count is still
106
+ // paging; an escalated-but-idle selection keeps every verb (verb parity).
107
+ const showVerbs = !isRunning && !isCounting;
108
+
54
109
  const canShowMove = !!onMove && !!accountId && !!currentMailboxId;
110
+ // Organize has no escalated-predicate path — it acts on the materialized
111
+ // selection — so it's withdrawn the moment the selection escalates or a run
112
+ // takes over (any state that names itself through `statusLabel`).
55
113
  const canShowOrganize =
56
- !!onOrganize && !!accountId && !!currentMailboxId && !moveDisabledHint;
114
+ !!onOrganize &&
115
+ !!accountId &&
116
+ !!currentMailboxId &&
117
+ !moveDisabledHint &&
118
+ !statusLabel;
119
+
120
+ const defaultLabel = selectAll?.checked
121
+ ? `All ${selectedCount} loaded selected`
122
+ : `${selectedCount} ${selectedCount === 1 ? "message" : "messages"} selected`;
57
123
 
58
124
  return (
59
- <div className="sticky top-0 z-10 flex items-center justify-between px-3 py-2 bg-surface-sunken border-b border-line">
60
- <div className="flex items-center gap-3">
61
- <button
62
- type="button"
63
- onClick={onClearSelection}
64
- className="min-h-11 min-w-11 inline-flex items-center justify-center rounded hover:bg-surface-raised transition-colors"
65
- aria-label="Clear selection"
66
- >
67
- <X className="size-4 text-fg-muted" />
68
- </button>
69
- <span className="text-sm font-medium">
70
- {selectedCount} {selectedCount === 1 ? "message" : "messages"}{" "}
71
- selected
72
- </span>
73
- {moveDisabledHint && (
74
- // biome-ignore lint/a11y/useSemanticElements: <span> with role="status" keeps inline layout; <output> would shift flow
75
- <span
76
- className="text-xs text-fg-muted"
77
- role="status"
78
- aria-live="polite"
79
- >
80
- {moveDisabledHint}
81
- </span>
82
- )}
83
- </div>
84
- <div className="flex items-center gap-1">
85
- {canShowOrganize && onOrganize && (
86
- <button
87
- type="button"
88
- onClick={isBusy ? undefined : onOrganize}
89
- className="min-h-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors hover:bg-surface-raised"
90
- aria-label="Organize similar messages"
91
- >
92
- <Sparkles className="size-4" />
93
- <span className="hidden sm:inline">Organize</span>
94
- </button>
95
- )}
96
- {onMarkAsRead && (
125
+ <div className="sticky top-0 z-10 flex flex-col bg-surface-sunken border-b border-line">
126
+ <div className="flex items-center justify-between px-3 py-2">
127
+ <div className="flex items-center gap-3">
97
128
  <button
98
129
  type="button"
99
- onClick={isBusy ? undefined : onMarkAsRead}
100
- className="min-h-11 min-w-11 inline-flex items-center justify-center rounded text-sm font-medium transition-colors hover:bg-surface-raised"
101
- aria-label="Mark as read"
102
- aria-busy={isBusy}
130
+ onClick={onClearSelection}
131
+ className="min-h-11 min-w-11 inline-flex items-center justify-center rounded hover:bg-surface-raised transition-colors"
132
+ aria-label="Clear selection"
103
133
  >
104
- <MailOpen className="size-4" />
134
+ <X className="size-4 text-fg-muted" />
105
135
  </button>
106
- )}
107
- {canShowMove && onMove && accountId && currentMailboxId && (
108
- <MoveToTrigger
109
- accountId={accountId}
110
- currentMailboxId={currentMailboxId}
111
- onMove={isBusy ? () => {} : onMove}
112
- disabledHint={moveDisabledHint}
113
- variant="icon-only"
114
- label="Move selected messages"
115
- />
116
- )}
117
- <button
118
- type="button"
119
- onClick={isBusy ? undefined : onDelete}
120
- className={cn(
121
- "min-h-11 min-w-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors",
122
- "bg-danger text-canvas hover:bg-danger/90",
136
+ {selectAll && (
137
+ // biome-ignore lint/a11y/noLabelWithoutControl: the label wraps Checkbox's own input, giving the 20px control a 44px hit area
138
+ <label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
139
+ <Checkbox
140
+ aria-label="Select all"
141
+ checked={selectAll.checked}
142
+ indeterminate={selectAll.indeterminate}
143
+ onChange={selectAll.onChange}
144
+ />
145
+ </label>
123
146
  )}
124
- aria-label="Delete selected messages"
125
- aria-busy={isDeleting}
126
- >
127
- {isDeleting ? (
128
- <Loader2 className="size-4 animate-spin" />
129
- ) : (
130
- <Trash2 className="size-4" />
131
- )}
132
- <span className="hidden sm:inline">
133
- {isDeleting ? "Deleting..." : "Delete"}
147
+ <span className="text-sm font-medium">
148
+ {statusLabel ?? defaultLabel}
134
149
  </span>
135
- </button>
150
+ {moveDisabledHint && !notice && (
151
+ <span
152
+ className="text-xs text-fg-muted"
153
+ role="status"
154
+ aria-live="polite"
155
+ >
156
+ {moveDisabledHint}
157
+ </span>
158
+ )}
159
+ </div>
160
+ <div className="flex items-center gap-1">
161
+ {canShowOrganize && onOrganize && (
162
+ <button
163
+ type="button"
164
+ onClick={isBusy ? undefined : onOrganize}
165
+ className="min-h-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors hover:bg-surface-raised"
166
+ aria-label="Organize similar messages"
167
+ >
168
+ <Sparkles className="size-4" />
169
+ <span className="hidden sm:inline">Organize</span>
170
+ </button>
171
+ )}
172
+ {showVerbs && onMarkAsRead && (
173
+ <button
174
+ type="button"
175
+ onClick={isBusy ? undefined : onMarkAsRead}
176
+ className="min-h-11 min-w-11 inline-flex items-center justify-center rounded text-sm font-medium transition-colors hover:bg-surface-raised"
177
+ aria-label="Mark as read"
178
+ aria-busy={isBusy}
179
+ >
180
+ <MailOpen className="size-4" />
181
+ </button>
182
+ )}
183
+ {showVerbs &&
184
+ canShowMove &&
185
+ onMove &&
186
+ accountId &&
187
+ currentMailboxId && (
188
+ <MoveToTrigger
189
+ accountId={accountId}
190
+ currentMailboxId={currentMailboxId}
191
+ onMove={isBusy ? () => {} : onMove}
192
+ disabledHint={moveDisabledHint}
193
+ variant="icon-only"
194
+ label="Move selected messages"
195
+ />
196
+ )}
197
+ {showVerbs && (
198
+ <button
199
+ type="button"
200
+ onClick={isBusy ? undefined : onDelete}
201
+ className={cn(
202
+ "min-h-11 min-w-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors",
203
+ "bg-danger text-canvas hover:bg-danger/90",
204
+ )}
205
+ aria-label="Delete selected messages"
206
+ aria-busy={isDeleting}
207
+ >
208
+ {isDeleting ? (
209
+ <Loader2 className="size-4 animate-spin" />
210
+ ) : (
211
+ <Trash2 className="size-4" />
212
+ )}
213
+ <span className="hidden sm:inline">
214
+ {isDeleting ? "Deleting..." : "Delete"}
215
+ </span>
216
+ </button>
217
+ )}
218
+ </div>
136
219
  </div>
220
+ {progress && (
221
+ <div className="px-3 pb-2">
222
+ <ProgressBar
223
+ value={progress.value}
224
+ max={progress.max}
225
+ tone={progress.tone}
226
+ />
227
+ </div>
228
+ )}
229
+ {notice && (
230
+ <Banner
231
+ tone={notice.tone}
232
+ variant="soft"
233
+ role="status"
234
+ aria-live="polite"
235
+ className="mx-3 mb-2"
236
+ >
237
+ <div className="flex items-center justify-between gap-2">
238
+ {notice.text && <span>{notice.text}</span>}
239
+ {notice.action && (
240
+ <button
241
+ type="button"
242
+ onClick={notice.action.onClick}
243
+ className="-my-1 min-h-11 shrink-0 inline-flex items-center px-3 rounded text-sm font-medium transition-colors hover:bg-surface-raised"
244
+ >
245
+ {notice.action.label}
246
+ </button>
247
+ )}
248
+ </div>
249
+ </Banner>
250
+ )}
137
251
  </div>
138
252
  );
139
253
  };
@@ -62,8 +62,8 @@ interface UseEscalatedActionsOptions {
62
62
  mailboxId: string;
63
63
  /** Owning account, forwarded to the unseen-count invalidation on completion. */
64
64
  accountId?: string;
65
- /** Disables escalation entirely (e.g. not searching, or desktop — this is a
66
- * mobile-only affordance). Resets any in-flight phase back to idle. */
65
+ /** Disables escalation entirely (e.g. not searching). Resets any in-flight
66
+ * phase back to idle. */
67
67
  enabled: boolean;
68
68
  /** Identifies the active predicate; escalation resets to idle whenever this
69
69
  * changes (a different search is a different question). */
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { showInlineSearchResults } from "./search-surface.js";
4
+
5
+ describe("showInlineSearchResults", () => {
6
+ test("phone never swaps in the inline panel — it keeps its takeover", () => {
7
+ assert.equal(
8
+ showInlineSearchResults({
9
+ tier: "phone",
10
+ hasLiveInput: true,
11
+ hasCommittedQuery: true,
12
+ bodyRendersCommittedResults: false,
13
+ }),
14
+ false,
15
+ );
16
+ });
17
+
18
+ test("no query means the normal list, on every tier", () => {
19
+ assert.equal(
20
+ showInlineSearchResults({
21
+ tier: "desktop",
22
+ hasLiveInput: false,
23
+ hasCommittedQuery: false,
24
+ bodyRendersCommittedResults: true,
25
+ }),
26
+ false,
27
+ );
28
+ });
29
+
30
+ test("a view without its own results body keeps the panel for any query", () => {
31
+ for (const hasCommittedQuery of [true, false]) {
32
+ for (const tier of ["tablet", "desktop"] as const) {
33
+ assert.equal(
34
+ showInlineSearchResults({
35
+ tier,
36
+ hasLiveInput: true,
37
+ hasCommittedQuery,
38
+ bodyRendersCommittedResults: false,
39
+ }),
40
+ true,
41
+ `${tier} committed=${hasCommittedQuery} should keep the panel`,
42
+ );
43
+ }
44
+ }
45
+ });
46
+
47
+ test("the mailbox route shows the panel while typing an uncommitted query", () => {
48
+ for (const tier of ["tablet", "desktop"] as const) {
49
+ assert.equal(
50
+ showInlineSearchResults({
51
+ tier,
52
+ hasLiveInput: true,
53
+ hasCommittedQuery: false,
54
+ bodyRendersCommittedResults: true,
55
+ }),
56
+ true,
57
+ );
58
+ }
59
+ });
60
+
61
+ test("the mailbox route hands back to its selectable body once the query commits", () => {
62
+ for (const tier of ["tablet", "desktop"] as const) {
63
+ assert.equal(
64
+ showInlineSearchResults({
65
+ tier,
66
+ hasLiveInput: true,
67
+ hasCommittedQuery: true,
68
+ bodyRendersCommittedResults: true,
69
+ }),
70
+ false,
71
+ );
72
+ }
73
+ });
74
+ });
@@ -0,0 +1,34 @@
1
+ import type { LayoutTier } from "@/hooks/useLayoutTier";
2
+
3
+ /**
4
+ * Which surface a list view shows its search on. Two surfaces exist:
5
+ *
6
+ * - the read-only two-engine `SearchResults` panel (literal "Top matches" +
7
+ * semantic "Related", cross-folder, spam offer), swapped into the list-pane
8
+ * body on tablet/desktop while searching; and
9
+ * - the view's own body — for the mailbox route a selectable `MessageList` whose
10
+ * threads filter to the committed search — which hosts multi-select and the
11
+ * "Select all N matching" escalation (#212).
12
+ *
13
+ * Phone never swaps: it shows the body and keeps the panel in its full-screen
14
+ * takeover instead, so this only decides tablet/desktop.
15
+ *
16
+ * A view whose body renders committed results itself (`bodyRendersCommittedResults`,
17
+ * the mailbox route) keeps the panel only while the query is still being typed —
18
+ * uncommitted, not yet mirrored to the URL — and hands back to the body the moment
19
+ * the query commits, so the committed search is a selectable list. Every other view
20
+ * (the brief, flagged, global search) leaves the flag off and keeps the panel for
21
+ * any query, committed or not.
22
+ */
23
+ export const showInlineSearchResults = (input: {
24
+ tier: LayoutTier;
25
+ /** The live field value is non-empty (something is being searched). */
26
+ hasLiveInput: boolean;
27
+ /** The query has settled into the URL (`?q=`), i.e. it is committed. */
28
+ hasCommittedQuery: boolean;
29
+ /** This view's body renders the committed search as a selectable list. */
30
+ bodyRendersCommittedResults: boolean;
31
+ }): boolean =>
32
+ input.tier !== "phone" &&
33
+ input.hasLiveInput &&
34
+ !(input.bodyRendersCommittedResults && input.hasCommittedQuery);