@remit/ui 0.0.58 → 0.0.60

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,8 +1,17 @@
1
- import { Loader2, MailOpen, Trash2, X } from "lucide-react";
1
+ import {
2
+ ArrowLeft,
3
+ Loader2,
4
+ MailOpen,
5
+ ShieldAlert,
6
+ Sparkles,
7
+ Trash2,
8
+ Wand2,
9
+ } from "lucide-react";
2
10
  import type { ReactNode } from "react";
3
11
  import { Banner, type BannerTone } from "./banner.js";
4
12
  import { Button } from "./button.js";
5
13
  import { Checkbox } from "./checkbox.js";
14
+ import { PopoverMenu, type PopoverMenuItem } from "./popover-menu.js";
6
15
  import { ProgressBar } from "./progress-bar.js";
7
16
 
8
17
  const formatCount = (n: number): string => n.toLocaleString();
@@ -18,40 +27,82 @@ export interface SelectionTopBarNotice {
18
27
  action?: SelectionTopBarNoticeAction;
19
28
  }
20
29
 
30
+ export interface SelectionTopBarSelectAll {
31
+ checked: boolean;
32
+ indeterminate?: boolean;
33
+ onChange: () => void;
34
+ }
35
+
21
36
  export interface SelectionTopBarProps {
37
+ /** Names the surface while nothing is ticked — the mailbox, the brief. */
38
+ title: string;
22
39
  count: number;
40
+ /** Leaves selection, from the back arrow at the head of the bar. */
23
41
  onCancel: () => void;
24
42
  onDelete: () => void;
25
- /** Optional hide the mark-read button when omitted, or while `isBusy`. */
43
+ /** Opens Organize for the selection. Omitted where organize cannot be
44
+ * scoped to one account. */
45
+ onOrganize?: () => void;
46
+ /** Moves the selection to Junk. Omitted in Junk itself, or with no Junk
47
+ * folder appointed. */
48
+ onJunk?: () => void;
49
+ /** Optional — dropped from the overflow menu while `isBusy`. */
26
50
  onMarkRead?: () => void;
27
51
  /**
28
- * Slot for a move-to-folder trigger. Rendered between mark-read and delete.
29
- * Kept as a render prop so the caller controls API dependencies.
52
+ * Opens the free-text organize panel. An overflow verb rather than a glyph:
53
+ * it collapses a verb, a destination and a scope into one tap, which is the
54
+ * opposite of what the bar's own verbs do.
55
+ */
56
+ onSomethingElse?: () => void;
57
+ /**
58
+ * The list header's own chrome, rendered only while nothing is ticked: the
59
+ * nav button, the unread count beside the title, and the search affordance.
60
+ * The bar is the list header, so these live here rather than on a second
61
+ * row above it.
62
+ */
63
+ navSlot?: ReactNode;
64
+ titleMeta?: ReactNode;
65
+ searchSlot?: ReactNode;
66
+ /**
67
+ * The expanded search field, which owns the title's place for as long as it
68
+ * is up. Idle only — a ticked row hands the row back to the count.
69
+ */
70
+ searchField?: ReactNode;
71
+ /**
72
+ * A row below the action row, up only while nothing is ticked — the
73
+ * search's make-this-a-filter affordance. A selection's own verbs take the
74
+ * bar over, so the second way into the same wizard stands down.
75
+ */
76
+ idleSlot?: ReactNode;
77
+ /**
78
+ * The Move verb. A render prop because the trigger owns the folder picker
79
+ * and its API dependencies; the bar only gives it a place in the verb row.
30
80
  */
31
81
  moveSlot?: ReactNode;
32
82
  /**
33
- * True while a delete or move mutation is in flight. The delete button
34
- * shows a spinner and mark-read is hidden (never disabled nothing here
35
- * disables, states that can't act are hidden instead).
83
+ * Extra rows at the foot of the overflow menu the apply-label picker,
84
+ * whose list belongs to the account rather than to the bar. Withdrawn while
85
+ * `isBusy` or `isCounting`, like the other overflow verbs.
86
+ */
87
+ overflowSlot?: ReactNode;
88
+ /**
89
+ * True while a delete or move mutation is in flight. Delete shows a spinner
90
+ * and every other verb drops out — nothing here disables, states that
91
+ * cannot act are hidden instead.
36
92
  */
37
93
  isBusy?: boolean;
38
94
  /**
39
95
  * True while a search result set is still paging to find its total. Hides
40
- * delete — the count it would act on isn't known yet.
96
+ * the verbs — the count they would act on isn't known yet.
41
97
  */
42
98
  isCounting?: boolean;
43
99
  /**
44
- * Select-all control rendered between cancel and the count label. Presence
45
- * of this prop is what renders the checkbox — omit it for a bar with no
46
- * select-all affordance. `indeterminate` renders the some-selected tri-state
47
- * (`Checkbox`'s dash), `checked` is the all-selected state. The checkbox
48
- * itself stays visually small; a wrapping 44px hit area makes it tappable.
100
+ * The labelled select-all control. Inline in the bar from 768px up, whether
101
+ * or not anything is ticked; below that it takes a second row while
102
+ * selecting, so row one stays a count and a row of verbs. `indeterminate`
103
+ * renders the some-selected tri-state.
49
104
  */
50
- selectAll?: {
51
- checked: boolean;
52
- indeterminate?: boolean;
53
- onChange: () => void;
54
- };
105
+ selectAll?: SelectionTopBarSelectAll;
55
106
  /**
56
107
  * Overrides the default "{count} messages selected" text. Required once the
57
108
  * count's scope is anything other than "every loaded row is selected" —
@@ -74,22 +125,63 @@ export interface SelectionTopBarProps {
74
125
  * Toned status line below the action row, sometimes carrying an action
75
126
  * button — a cross-account move restriction, a "Select all N matching…"
76
127
  * escalation, a "Stop" during counting, or a partial-failure "Retry N".
77
- * Replaces the old `moveDisabledHint`/`failureHint` pair: a caller shows
78
- * at most one notice at a time.
79
128
  */
80
129
  notice?: SelectionTopBarNotice;
81
130
  }
82
131
 
132
+ /** Words, not a bare box: a tick with no legend never says what "all" covers. */
133
+ function SelectAllToggle({
134
+ selectAll,
135
+ selecting,
136
+ className,
137
+ }: {
138
+ selectAll: SelectionTopBarSelectAll;
139
+ selecting: boolean;
140
+ className: string;
141
+ }) {
142
+ return (
143
+ <Checkbox
144
+ className={className}
145
+ checked={selectAll.checked}
146
+ indeterminate={selectAll.indeterminate}
147
+ onChange={selectAll.onChange}
148
+ label={
149
+ <span className="font-medium text-accent">
150
+ {selectAll.checked && selecting ? "Deselect all" : "Select all"}
151
+ </span>
152
+ }
153
+ />
154
+ );
155
+ }
156
+
83
157
  /**
84
- * Replaces the list header in narrow-width multi-select: a count plus the bulk
85
- * verbs (cancel, mark read, move, delete). Real Buttons that never disable.
158
+ * The list header and the selection action bar, as one surface. With nothing
159
+ * ticked it names the mailbox; from the first ticked row it carries the count
160
+ * and the verbs in place of that title, in the same place at the top of the
161
+ * pane.
162
+ *
163
+ * Delete, Move and Organize carry a glyph; Junk, Mark read and whatever
164
+ * `overflowSlot` adds live in the overflow menu. Select-all is a labelled control — permanent from 768px up,
165
+ * where row one has the room to carry it, and a second row below that, where
166
+ * row one is a count and the verbs. A back arrow leaves selection at every
167
+ * width, so a partial selection is always one press from being dropped.
86
168
  */
87
169
  export function SelectionTopBar({
170
+ title,
88
171
  count,
89
172
  onCancel,
90
- onMarkRead,
91
173
  onDelete,
174
+ onOrganize,
175
+ onJunk,
176
+ onMarkRead,
177
+ onSomethingElse,
178
+ navSlot,
179
+ titleMeta,
180
+ searchSlot,
181
+ searchField,
182
+ idleSlot,
92
183
  moveSlot,
184
+ overflowSlot,
93
185
  isBusy = false,
94
186
  isCounting = false,
95
187
  selectAll,
@@ -97,68 +189,140 @@ export function SelectionTopBar({
97
189
  progress,
98
190
  notice,
99
191
  }: SelectionTopBarProps) {
192
+ const selecting = count > 0 || isCounting;
100
193
  const defaultLabel = selectAll?.checked
101
194
  ? `All ${formatCount(count)} loaded selected`
102
195
  : `${formatCount(count)} ${count === 1 ? "message" : "messages"} selected`;
103
196
 
197
+ const overflowItems: PopoverMenuItem[] = [];
198
+ if (onJunk && !isBusy && !isCounting) {
199
+ overflowItems.push({
200
+ key: "junk",
201
+ label: "Junk",
202
+ icon: <ShieldAlert className="size-4" />,
203
+ onSelect: onJunk,
204
+ });
205
+ }
206
+ if (onMarkRead && !isBusy && !isCounting) {
207
+ overflowItems.push({
208
+ key: "mark-read",
209
+ label: "Mark read",
210
+ icon: <MailOpen className="size-4" />,
211
+ onSelect: onMarkRead,
212
+ });
213
+ }
214
+ if (onSomethingElse && !isBusy && !isCounting) {
215
+ overflowItems.push({
216
+ key: "something-else",
217
+ label: "Something else",
218
+ icon: <Wand2 className="size-4" />,
219
+ onSelect: onSomethingElse,
220
+ });
221
+ }
222
+
104
223
  return (
105
- <header className="flex shrink-0 flex-col border-b border-line bg-surface-sunken">
106
- <div className="flex h-pane-header items-center gap-2 px-row-inset">
107
- <Button
108
- variant="ghost"
109
- size="touch"
110
- icon={<X className="size-4" />}
111
- onClick={onCancel}
112
- aria-label="Cancel selection"
113
- className="-ml-2 shrink-0"
114
- />
115
- {selectAll && (
116
- // biome-ignore lint/a11y/noLabelWithoutControl: label wraps Checkbox's own input, giving the 20px control a real 44px hit area
117
- <label className="-ml-1.5 flex size-11 shrink-0 cursor-pointer items-center justify-center">
118
- <Checkbox
119
- aria-label="Select all"
120
- checked={selectAll.checked}
121
- indeterminate={selectAll.indeterminate}
122
- onChange={selectAll.onChange}
123
- />
124
- </label>
125
- )}
126
- <span
127
- className="min-w-0 flex-1 truncate text-sm font-medium text-fg"
128
- role="status"
129
- aria-live="polite"
130
- >
131
- {statusLabel ?? defaultLabel}
132
- </span>
133
- {onMarkRead && !isBusy && (
224
+ <header
225
+ data-selection-bar=""
226
+ className="flex shrink-0 flex-col border-b border-line bg-surface-sunken"
227
+ >
228
+ <div
229
+ data-selection-bar-row="actions"
230
+ className="flex h-pane-header items-center gap-1 px-row-inset"
231
+ >
232
+ {selecting ? (
134
233
  <Button
135
234
  variant="ghost"
136
235
  size="touch"
137
- icon={<MailOpen className="size-4" />}
138
- onClick={onMarkRead}
139
- aria-label="Mark as read"
140
- className="shrink-0"
236
+ icon={<ArrowLeft className="size-5" />}
237
+ onClick={onCancel}
238
+ aria-label="Cancel selection"
239
+ className="-ml-2 shrink-0"
141
240
  />
241
+ ) : (
242
+ navSlot
142
243
  )}
143
- {moveSlot}
144
- {!isCounting && (
145
- <Button
146
- variant="ghost"
147
- size="touch"
148
- icon={
149
- isBusy ? (
150
- <Loader2 className="size-4 animate-spin" />
151
- ) : (
152
- <Trash2 className="size-4 text-danger" />
153
- )
154
- }
155
- onClick={isBusy ? undefined : onDelete}
156
- aria-label="Move selected messages to Trash"
157
- aria-busy={isBusy || undefined}
158
- className="ml-4 shrink-0"
244
+ {selectAll && (
245
+ <SelectAllToggle
246
+ selectAll={selectAll}
247
+ selecting={selecting}
248
+ className="hidden shrink-0 pr-2 md:flex"
159
249
  />
160
250
  )}
251
+ {selecting ? (
252
+ <span
253
+ data-selection-count=""
254
+ className="min-w-0 flex-1 truncate px-1 text-sm font-medium text-fg"
255
+ role="status"
256
+ aria-live="polite"
257
+ >
258
+ {statusLabel ?? defaultLabel}
259
+ </span>
260
+ ) : searchField ? (
261
+ <div className="flex min-w-0 flex-1 items-center gap-1">
262
+ {searchField}
263
+ </div>
264
+ ) : (
265
+ <>
266
+ <h1 className="min-w-0 flex-1 truncate px-1 text-sm font-semibold text-fg">
267
+ {title}
268
+ </h1>
269
+ {titleMeta}
270
+ {searchSlot}
271
+ </>
272
+ )}
273
+ {selecting && !isCounting && (
274
+ <>
275
+ <Button
276
+ variant="ghost"
277
+ size="touch"
278
+ icon={
279
+ isBusy ? (
280
+ <Loader2 className="size-5 animate-spin" />
281
+ ) : (
282
+ <Trash2 className="size-5 text-danger" />
283
+ )
284
+ }
285
+ onClick={isBusy ? undefined : onDelete}
286
+ aria-label="Move selected messages to Trash"
287
+ aria-busy={isBusy || undefined}
288
+ className="shrink-0"
289
+ />
290
+ {!isBusy && moveSlot}
291
+ {!isBusy && onOrganize && (
292
+ <Button
293
+ variant="ghost"
294
+ size="touch"
295
+ icon={<Sparkles className="size-5" />}
296
+ onClick={onOrganize}
297
+ aria-label="Organize selected messages"
298
+ className="shrink-0"
299
+ />
300
+ )}
301
+ </>
302
+ )}
303
+ {selecting && (
304
+ <PopoverMenu
305
+ triggerLabel="More actions"
306
+ items={overflowItems}
307
+ className="shrink-0"
308
+ >
309
+ {!isBusy && !isCounting && overflowSlot}
310
+ </PopoverMenu>
311
+ )}
161
312
  </div>
313
+ {!selecting && idleSlot}
314
+ {selecting && selectAll && (
315
+ <div
316
+ data-selection-bar-row="select-all"
317
+ className="px-row-inset pb-2 md:hidden"
318
+ >
319
+ <SelectAllToggle
320
+ selectAll={selectAll}
321
+ selecting={selecting}
322
+ className="w-full"
323
+ />
324
+ </div>
325
+ )}
162
326
  {progress && (
163
327
  <div className="px-row-inset pb-2">
164
328
  <ProgressBar
@@ -170,6 +334,7 @@ export function SelectionTopBar({
170
334
  )}
171
335
  {notice && (
172
336
  <Banner
337
+ data-selection-notice=""
173
338
  tone={notice.tone}
174
339
  variant="soft"
175
340
  role="status"