@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 +1 -1
- package/src/components/mail/MailListHeader.tsx +26 -4
- package/src/components/mail/MailViewChrome.tsx +8 -0
- package/src/components/mail/MailboxPane.tsx +5 -0
- package/src/components/mail/MessageList.selection.test.ts +54 -0
- package/src/components/mail/MessageList.tsx +87 -63
- package/src/components/mail/SelectionToolbar.render.test.ts +129 -0
- package/src/components/mail/SelectionToolbar.stories.tsx +194 -0
- package/src/components/mail/SelectionToolbar.tsx +187 -73
- package/src/hooks/useEscalatedActions.ts +2 -2
- package/src/lib/search-surface.test.ts +74 -0
- package/src/lib/search-surface.ts +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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;
|
|
213
|
-
// list-pane body swaps to the same sectioned results the phone
|
|
214
|
-
// under the same FilterSheet.
|
|
215
|
-
|
|
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
|
|
306
|
-
//
|
|
307
|
-
// `orderedIds` below feeds `allLoadedSelected`; declared after
|
|
308
|
-
// its callback deps stay simple — see the
|
|
309
|
-
// block.
|
|
310
|
-
const escalationEnabled =
|
|
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
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
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
|
-
//
|
|
828
|
-
// hand `requestDelete`, so it opens the predicate
|
|
829
|
-
|
|
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
|
-
//
|
|
838
|
-
// "cancel selection" (issue #92 — the review flagged
|
|
839
|
-
// ambiguous once a
|
|
840
|
-
// the next page boundary; an escalated-but-idle selection
|
|
841
|
-
// bounded on the way out, same as tapping "Clear selection"
|
|
842
|
-
const
|
|
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
|
-
|
|
1135
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1164
|
-
|
|
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
|
|
1167
|
-
|
|
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
|
-
:
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
|
|
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={
|
|
1269
|
+
count={selectionCount}
|
|
1265
1270
|
mode={mobileMode}
|
|
1266
|
-
onCancel={
|
|
1267
|
-
onDelete={
|
|
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={
|
|
1282
|
-
selectAll={
|
|
1283
|
-
statusLabel={
|
|
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 &&
|
|
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
|
|
60
|
-
<div className="flex items-center
|
|
61
|
-
<
|
|
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={
|
|
100
|
-
className="min-h-11 min-w-11 inline-flex items-center justify-center rounded
|
|
101
|
-
aria-label="
|
|
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
|
-
<
|
|
134
|
+
<X className="size-4 text-fg-muted" />
|
|
105
135
|
</button>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
*
|
|
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);
|