@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.
@@ -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;