@remit/web-client 0.0.90 → 0.0.91
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/DailyBrief.selection.test.ts +19 -15
- package/src/components/mail/DailyBrief.tsx +70 -89
- package/src/components/mail/FlaggedList.tsx +58 -50
- package/src/components/mail/LabelApplyTrigger.tsx +11 -5
- package/src/components/mail/MailListHeader.tsx +165 -85
- package/src/components/mail/MailViewChrome.tsx +7 -6
- package/src/components/mail/MessageList.selection.test.ts +34 -36
- package/src/components/mail/MessageList.tsx +64 -96
- package/src/components/mail/MoveToTrigger.tsx +1 -1
- package/src/components/mail/ThreadListInteraction.test.ts +8 -2
- package/src/components/mail/ThreadListInteraction.tsx +47 -24
- package/src/hooks/useInitialSyncProgress.render.test.ts +11 -5
- package/src/lib/list-header-chrome.ts +50 -0
- package/src/components/mail/SelectionToolbar.render.test.ts +0 -324
- package/src/components/mail/SelectionToolbar.stories.tsx +0 -199
- package/src/components/mail/SelectionToolbar.tsx +0 -274
- package/src/lib/selection-sheet-mode.test.ts +0 -105
- package/src/lib/selection-sheet-mode.ts +0 -49
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import { Banner, type BannerTone, Checkbox, ProgressBar } from "@remit/ui";
|
|
2
|
-
import { Loader2, MailOpen, Sparkles, Trash2, X } from "lucide-react";
|
|
3
|
-
import { cn } from "@/lib/utils";
|
|
4
|
-
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
5
|
-
import { MoveToTrigger } from "./MoveToTrigger";
|
|
6
|
-
|
|
7
|
-
export interface SelectionToolbarNotice {
|
|
8
|
-
tone: BannerTone;
|
|
9
|
-
text: string;
|
|
10
|
-
action?: { label: string; onClick: () => void };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
interface SelectionToolbarProps {
|
|
14
|
-
selectedCount: number;
|
|
15
|
-
onDelete: () => void;
|
|
16
|
-
onClearSelection: () => void;
|
|
17
|
-
onMarkAsRead?: () => void;
|
|
18
|
-
onMove?: (destinationMailboxId: string) => void;
|
|
19
|
-
/**
|
|
20
|
-
* Open the smart-organize flow for the current selection. Rendered only
|
|
21
|
-
* when a single-account move target is resolvable (same guard as Move) —
|
|
22
|
-
* organize is account-scoped.
|
|
23
|
-
*/
|
|
24
|
-
onOrganize?: () => void;
|
|
25
|
-
isDeleting?: boolean;
|
|
26
|
-
/**
|
|
27
|
-
* True while a move mutation is in flight. Action buttons no-op while
|
|
28
|
-
* busy — never disabled.
|
|
29
|
-
*/
|
|
30
|
-
isMoving?: boolean;
|
|
31
|
-
/**
|
|
32
|
-
* Owning account for the current mailbox view. Required to scope the
|
|
33
|
-
* Move-to-folder picker. The Move button only renders when both
|
|
34
|
-
* `onMove` and `accountId` are present.
|
|
35
|
-
*/
|
|
36
|
-
accountId?: string;
|
|
37
|
-
currentMailboxId?: string;
|
|
38
|
-
/**
|
|
39
|
-
* The materialized selection, for the apply-label action (issue #26). Only
|
|
40
|
-
* `Apply` is offered from here — "just these" — never `appliedByFilterId`.
|
|
41
|
-
* Absent (or accountId/currentMailboxId absent) hides the label trigger.
|
|
42
|
-
*/
|
|
43
|
-
selectedMessageIds?: string[];
|
|
44
|
-
/**
|
|
45
|
-
* When the user's selection spans multiple accounts the toolbar
|
|
46
|
-
* disables Move and surfaces this hint inline next to the button.
|
|
47
|
-
* Move only works within one account.
|
|
48
|
-
*/
|
|
49
|
-
moveDisabledHint?: string;
|
|
50
|
-
/**
|
|
51
|
-
* Select-all-loaded control, rendered between the clear button and the
|
|
52
|
-
* count. Presence renders the checkbox; `indeterminate` is the some-selected
|
|
53
|
-
* tri-state. Wired only while searching, where escalating past the loaded
|
|
54
|
-
* page is possible (issue #212).
|
|
55
|
-
*/
|
|
56
|
-
selectAll?: {
|
|
57
|
-
checked: boolean;
|
|
58
|
-
indeterminate?: boolean;
|
|
59
|
-
onChange: () => void;
|
|
60
|
-
};
|
|
61
|
-
/**
|
|
62
|
-
* Overrides the "{count} messages selected" text. Names the scope once it is
|
|
63
|
-
* anything other than a materialized selection — an escalated predicate
|
|
64
|
-
* ("All 3,412 matching \"npm\" selected"), a running count ("Counting… 1,900
|
|
65
|
-
* so far"), or bulk-run progress ("Deleting 1,200 of 3,412…").
|
|
66
|
-
*/
|
|
67
|
-
statusLabel?: string;
|
|
68
|
-
/**
|
|
69
|
-
* True while a search result set is still paging to its total. Hides Delete
|
|
70
|
-
* (and Move) — the count they would act on isn't known yet.
|
|
71
|
-
*/
|
|
72
|
-
isCounting?: boolean;
|
|
73
|
-
/**
|
|
74
|
-
* Determinate progress for a chunked/escalated run in flight. Renders a
|
|
75
|
-
* `ProgressBar` below the action row and takes the verbs off screen — the
|
|
76
|
-
* bar is the only thing that can act mid-run.
|
|
77
|
-
*/
|
|
78
|
-
progress?: { value: number; max: number; tone?: BannerTone };
|
|
79
|
-
/**
|
|
80
|
-
* At-most-one toned status line below the action row, optionally carrying an
|
|
81
|
-
* action button — the "Select all N matching…" escalation offer, a "Stop"
|
|
82
|
-
* during counting, a "Clear selection" once escalated, or a partial-failure
|
|
83
|
-
* "Retry N" (issue #212).
|
|
84
|
-
*/
|
|
85
|
-
notice?: SelectionToolbarNotice;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export const SelectionToolbar = ({
|
|
89
|
-
selectedCount,
|
|
90
|
-
onDelete,
|
|
91
|
-
onClearSelection,
|
|
92
|
-
onMarkAsRead,
|
|
93
|
-
onMove,
|
|
94
|
-
onOrganize,
|
|
95
|
-
isDeleting = false,
|
|
96
|
-
isMoving = false,
|
|
97
|
-
accountId,
|
|
98
|
-
currentMailboxId,
|
|
99
|
-
selectedMessageIds,
|
|
100
|
-
moveDisabledHint,
|
|
101
|
-
selectAll,
|
|
102
|
-
statusLabel,
|
|
103
|
-
isCounting = false,
|
|
104
|
-
progress,
|
|
105
|
-
notice,
|
|
106
|
-
}: SelectionToolbarProps) => {
|
|
107
|
-
if (selectedCount === 0) return null;
|
|
108
|
-
|
|
109
|
-
// A chunked/escalated run owns the bar: the progress bar is the only signal
|
|
110
|
-
// that can act, so the verbs come off screen until it ends (issue #212).
|
|
111
|
-
const isRunning = progress !== undefined;
|
|
112
|
-
const isBusy = isDeleting || isMoving || isRunning;
|
|
113
|
-
// The verbs are suppressed while a run is in flight or a count is still
|
|
114
|
-
// paging; an escalated-but-idle selection keeps every verb (verb parity).
|
|
115
|
-
const showVerbs = !isRunning && !isCounting;
|
|
116
|
-
|
|
117
|
-
const canShowMove = !!onMove && !!accountId && !!currentMailboxId;
|
|
118
|
-
const canShowLabel =
|
|
119
|
-
!!accountId && !!currentMailboxId && !!selectedMessageIds?.length;
|
|
120
|
-
// Organize has no escalated-predicate path — it acts on the materialized
|
|
121
|
-
// selection — so it's withdrawn the moment the selection escalates or a run
|
|
122
|
-
// takes over (any state that names itself through `statusLabel`).
|
|
123
|
-
const canShowOrganize =
|
|
124
|
-
!!onOrganize &&
|
|
125
|
-
!!accountId &&
|
|
126
|
-
!!currentMailboxId &&
|
|
127
|
-
!moveDisabledHint &&
|
|
128
|
-
!statusLabel;
|
|
129
|
-
|
|
130
|
-
const defaultLabel = selectAll?.checked
|
|
131
|
-
? `All ${selectedCount} loaded selected`
|
|
132
|
-
: `${selectedCount} ${selectedCount === 1 ? "message" : "messages"} selected`;
|
|
133
|
-
|
|
134
|
-
return (
|
|
135
|
-
<div className="sticky top-0 z-10 flex flex-col bg-surface-sunken border-b border-line">
|
|
136
|
-
<div className="flex items-center justify-between px-3 py-2">
|
|
137
|
-
<div className="flex items-center gap-3">
|
|
138
|
-
<button
|
|
139
|
-
type="button"
|
|
140
|
-
onClick={onClearSelection}
|
|
141
|
-
className="min-h-11 min-w-11 inline-flex items-center justify-center rounded hover:bg-surface-raised transition-colors"
|
|
142
|
-
aria-label="Clear selection"
|
|
143
|
-
>
|
|
144
|
-
<X className="size-4 text-fg-muted" />
|
|
145
|
-
</button>
|
|
146
|
-
{selectAll && (
|
|
147
|
-
// biome-ignore lint/a11y/noLabelWithoutControl: the label wraps Checkbox's own input, giving the 20px control a 44px hit area
|
|
148
|
-
<label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
|
|
149
|
-
<Checkbox
|
|
150
|
-
aria-label="Select all"
|
|
151
|
-
checked={selectAll.checked}
|
|
152
|
-
indeterminate={selectAll.indeterminate}
|
|
153
|
-
onChange={selectAll.onChange}
|
|
154
|
-
/>
|
|
155
|
-
</label>
|
|
156
|
-
)}
|
|
157
|
-
<span className="text-sm font-medium">
|
|
158
|
-
{statusLabel ?? defaultLabel}
|
|
159
|
-
</span>
|
|
160
|
-
{moveDisabledHint && !notice && (
|
|
161
|
-
<span
|
|
162
|
-
className="text-xs text-fg-muted"
|
|
163
|
-
role="status"
|
|
164
|
-
aria-live="polite"
|
|
165
|
-
>
|
|
166
|
-
{moveDisabledHint}
|
|
167
|
-
</span>
|
|
168
|
-
)}
|
|
169
|
-
</div>
|
|
170
|
-
<div className="flex items-center gap-1">
|
|
171
|
-
{canShowOrganize && onOrganize && (
|
|
172
|
-
<button
|
|
173
|
-
type="button"
|
|
174
|
-
onClick={isBusy ? undefined : onOrganize}
|
|
175
|
-
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"
|
|
176
|
-
aria-label="Organize similar messages"
|
|
177
|
-
>
|
|
178
|
-
<Sparkles className="size-4" />
|
|
179
|
-
<span className="hidden sm:inline">Organize</span>
|
|
180
|
-
</button>
|
|
181
|
-
)}
|
|
182
|
-
{showVerbs && onMarkAsRead && (
|
|
183
|
-
<button
|
|
184
|
-
type="button"
|
|
185
|
-
onClick={isBusy ? undefined : onMarkAsRead}
|
|
186
|
-
className="min-h-11 min-w-11 inline-flex items-center justify-center rounded text-sm font-medium transition-colors hover:bg-surface-raised"
|
|
187
|
-
aria-label="Mark as read"
|
|
188
|
-
aria-busy={isBusy}
|
|
189
|
-
>
|
|
190
|
-
<MailOpen className="size-4" />
|
|
191
|
-
</button>
|
|
192
|
-
)}
|
|
193
|
-
{showVerbs &&
|
|
194
|
-
canShowMove &&
|
|
195
|
-
onMove &&
|
|
196
|
-
accountId &&
|
|
197
|
-
currentMailboxId && (
|
|
198
|
-
<MoveToTrigger
|
|
199
|
-
accountId={accountId}
|
|
200
|
-
currentMailboxId={currentMailboxId}
|
|
201
|
-
onMove={isBusy ? () => {} : onMove}
|
|
202
|
-
disabledHint={moveDisabledHint}
|
|
203
|
-
variant="icon-only"
|
|
204
|
-
label="Move selected messages"
|
|
205
|
-
/>
|
|
206
|
-
)}
|
|
207
|
-
{showVerbs &&
|
|
208
|
-
canShowLabel &&
|
|
209
|
-
accountId &&
|
|
210
|
-
currentMailboxId &&
|
|
211
|
-
selectedMessageIds && (
|
|
212
|
-
<LabelApplyTrigger
|
|
213
|
-
accountId={accountId}
|
|
214
|
-
mailboxId={currentMailboxId}
|
|
215
|
-
messageIds={selectedMessageIds}
|
|
216
|
-
/>
|
|
217
|
-
)}
|
|
218
|
-
{showVerbs && (
|
|
219
|
-
<button
|
|
220
|
-
type="button"
|
|
221
|
-
onClick={isBusy ? undefined : onDelete}
|
|
222
|
-
className={cn(
|
|
223
|
-
"min-h-11 min-w-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors",
|
|
224
|
-
"bg-danger text-canvas hover:bg-danger/90",
|
|
225
|
-
)}
|
|
226
|
-
aria-label="Delete selected messages"
|
|
227
|
-
aria-busy={isDeleting}
|
|
228
|
-
>
|
|
229
|
-
{isDeleting ? (
|
|
230
|
-
<Loader2 className="size-4 animate-spin" />
|
|
231
|
-
) : (
|
|
232
|
-
<Trash2 className="size-4" />
|
|
233
|
-
)}
|
|
234
|
-
<span className="hidden sm:inline">
|
|
235
|
-
{isDeleting ? "Deleting..." : "Delete"}
|
|
236
|
-
</span>
|
|
237
|
-
</button>
|
|
238
|
-
)}
|
|
239
|
-
</div>
|
|
240
|
-
</div>
|
|
241
|
-
{progress && (
|
|
242
|
-
<div className="px-3 pb-2">
|
|
243
|
-
<ProgressBar
|
|
244
|
-
value={progress.value}
|
|
245
|
-
max={progress.max}
|
|
246
|
-
tone={progress.tone}
|
|
247
|
-
/>
|
|
248
|
-
</div>
|
|
249
|
-
)}
|
|
250
|
-
{notice && (
|
|
251
|
-
<Banner
|
|
252
|
-
tone={notice.tone}
|
|
253
|
-
variant="soft"
|
|
254
|
-
role="status"
|
|
255
|
-
aria-live="polite"
|
|
256
|
-
className="mx-3 mb-2"
|
|
257
|
-
>
|
|
258
|
-
<div className="flex items-center justify-between gap-2">
|
|
259
|
-
{notice.text && <span>{notice.text}</span>}
|
|
260
|
-
{notice.action && (
|
|
261
|
-
<button
|
|
262
|
-
type="button"
|
|
263
|
-
onClick={notice.action.onClick}
|
|
264
|
-
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"
|
|
265
|
-
>
|
|
266
|
-
{notice.action.label}
|
|
267
|
-
</button>
|
|
268
|
-
)}
|
|
269
|
-
</div>
|
|
270
|
-
</Banner>
|
|
271
|
-
)}
|
|
272
|
-
</div>
|
|
273
|
-
);
|
|
274
|
-
};
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, test } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
resolveSelectionSheetMode,
|
|
5
|
-
SELECTION_SHEET_MIN_COUNT,
|
|
6
|
-
shouldShowSelectionSheet,
|
|
7
|
-
} from "./selection-sheet-mode";
|
|
8
|
-
|
|
9
|
-
describe("resolveSelectionSheetMode", () => {
|
|
10
|
-
test("is idle for a plain bounded selection", () => {
|
|
11
|
-
assert.equal(
|
|
12
|
-
resolveSelectionSheetMode({
|
|
13
|
-
isRunning: false,
|
|
14
|
-
isCounting: false,
|
|
15
|
-
isEscalated: false,
|
|
16
|
-
}),
|
|
17
|
-
"idle",
|
|
18
|
-
);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("is escalated once the selection is the search predicate", () => {
|
|
22
|
-
assert.equal(
|
|
23
|
-
resolveSelectionSheetMode({
|
|
24
|
-
isRunning: false,
|
|
25
|
-
isCounting: false,
|
|
26
|
-
isEscalated: true,
|
|
27
|
-
}),
|
|
28
|
-
"escalated",
|
|
29
|
-
);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("is counting while the predicate is still paging to a total", () => {
|
|
33
|
-
assert.equal(
|
|
34
|
-
resolveSelectionSheetMode({
|
|
35
|
-
isRunning: false,
|
|
36
|
-
isCounting: true,
|
|
37
|
-
isEscalated: false,
|
|
38
|
-
}),
|
|
39
|
-
"counting",
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("running wins over escalated — a chunked run is both", () => {
|
|
44
|
-
assert.equal(
|
|
45
|
-
resolveSelectionSheetMode({
|
|
46
|
-
isRunning: true,
|
|
47
|
-
isCounting: false,
|
|
48
|
-
isEscalated: true,
|
|
49
|
-
}),
|
|
50
|
-
"running",
|
|
51
|
-
);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("running wins over counting", () => {
|
|
55
|
-
assert.equal(
|
|
56
|
-
resolveSelectionSheetMode({
|
|
57
|
-
isRunning: true,
|
|
58
|
-
isCounting: true,
|
|
59
|
-
isEscalated: false,
|
|
60
|
-
}),
|
|
61
|
-
"running",
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("counting wins over escalated", () => {
|
|
66
|
-
assert.equal(
|
|
67
|
-
resolveSelectionSheetMode({
|
|
68
|
-
isRunning: false,
|
|
69
|
-
isCounting: true,
|
|
70
|
-
isEscalated: true,
|
|
71
|
-
}),
|
|
72
|
-
"counting",
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe("shouldShowSelectionSheet", () => {
|
|
78
|
-
test("hides at a single selected row while idle", () => {
|
|
79
|
-
assert.equal(shouldShowSelectionSheet(1, "idle"), false);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("shows at the two-row threshold", () => {
|
|
83
|
-
assert.equal(
|
|
84
|
-
shouldShowSelectionSheet(SELECTION_SHEET_MIN_COUNT, "idle"),
|
|
85
|
-
true,
|
|
86
|
-
);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("shows for any non-idle state even below the threshold", () => {
|
|
90
|
-
assert.equal(shouldShowSelectionSheet(0, "counting"), true);
|
|
91
|
-
assert.equal(shouldShowSelectionSheet(1, "running"), true);
|
|
92
|
-
assert.equal(shouldShowSelectionSheet(0, "escalated"), true);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("shows a pending notice at a single idle row so it is never dropped", () => {
|
|
96
|
-
// A bulk delete that leaves one message behind returns to idle at count 1
|
|
97
|
-
// with a "1 couldn't be deleted / Retry" notice; the escalation offer can
|
|
98
|
-
// likewise sit at one loaded row. The sheet must mount to surface either.
|
|
99
|
-
assert.equal(shouldShowSelectionSheet(1, "idle", true), true);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("still hides a single idle row with no notice", () => {
|
|
103
|
-
assert.equal(shouldShowSelectionSheet(1, "idle", false), false);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import type { SelectionSheetMode } from "@remit/ui";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Routes the mobile selection sheet to one of its four content states from the
|
|
5
|
-
* escalation machinery's flags. Pure so the routing — which is what decides
|
|
6
|
-
* whether the sheet shows idle quick actions or the counting / running /
|
|
7
|
-
* escalated status — is testable without the sheet or a DOM.
|
|
8
|
-
*
|
|
9
|
-
* Precedence, highest first:
|
|
10
|
-
* 1. `running` — a chunked delete/move/mark-read is in flight; progress owns
|
|
11
|
-
* the sheet even over an escalated selection (an escalated run is both).
|
|
12
|
-
* 2. `counting` — a search predicate is still paging to its total.
|
|
13
|
-
* 3. `escalated` — the selection is the search predicate, not a loaded id set.
|
|
14
|
-
* 4. `idle` — a bounded selection with the quick actions and smart-flow rows.
|
|
15
|
-
*/
|
|
16
|
-
export const resolveSelectionSheetMode = (input: {
|
|
17
|
-
isRunning: boolean;
|
|
18
|
-
isCounting: boolean;
|
|
19
|
-
isEscalated: boolean;
|
|
20
|
-
}): SelectionSheetMode => {
|
|
21
|
-
if (input.isRunning) return "running";
|
|
22
|
-
if (input.isCounting) return "counting";
|
|
23
|
-
if (input.isEscalated) return "escalated";
|
|
24
|
-
return "idle";
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The count threshold the peeking teaser rises at — two or more selected, the
|
|
29
|
-
* prototype's threshold. Below it selection mode is still entered (rows show
|
|
30
|
-
* their checkboxes) but no sheet appears, matching today's single-select
|
|
31
|
-
* behaviour of leaving the list chrome alone.
|
|
32
|
-
*/
|
|
33
|
-
export const SELECTION_SHEET_MIN_COUNT = 2;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Whether the mobile sheet should be mounted: two or more rows selected, or any
|
|
37
|
-
* non-idle escalation state (counting, running, escalated), or a pending notice
|
|
38
|
-
* that has to be surfaced. The last case is why a single-row selection can still
|
|
39
|
-
* raise the sheet — a bulk run that leaves exactly one message behind returns to
|
|
40
|
-
* idle at count 1 with a "1 couldn't be deleted / Retry" notice, and a search
|
|
41
|
-
* with one loaded row plus more matches carries the escalation offer at count 1.
|
|
42
|
-
* Suppressing the sheet there would drop the notice with no way to retry.
|
|
43
|
-
*/
|
|
44
|
-
export const shouldShowSelectionSheet = (
|
|
45
|
-
count: number,
|
|
46
|
-
mode: SelectionSheetMode,
|
|
47
|
-
hasNotice = false,
|
|
48
|
-
): boolean =>
|
|
49
|
-
count >= SELECTION_SHEET_MIN_COUNT || mode !== "idle" || hasNotice;
|