@remit/ui 0.0.59 → 0.0.61

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
+ FolderInput,
4
+ Loader2,
5
+ MailOpen,
6
+ ShieldAlert,
7
+ Sparkles,
8
+ Trash2,
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`. */
26
- onMarkRead?: () => void;
27
43
  /**
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.
44
+ * Opens the wizard on Move. Omitted where a destination cannot be resolved —
45
+ * a selection spanning accounts, or a surface with no owning mailbox.
46
+ */
47
+ onMove?: () => void;
48
+ /**
49
+ * The Move verb for a selection the wizard cannot take: an escalated
50
+ * predicate, which no bounded list of ids stands in for, so it keeps the
51
+ * caller's own folder picker. Rendered only in `onMove`'s place.
30
52
  */
31
53
  moveSlot?: ReactNode;
54
+ /** Opens Organize for the selection. Omitted where organize cannot be
55
+ * scoped to one account. */
56
+ onOrganize?: () => void;
57
+ /** Moves the selection to Junk. Omitted in Junk itself, or with no Junk
58
+ * folder appointed. */
59
+ onJunk?: () => void;
60
+ /** Optional — dropped from the overflow menu while `isBusy`. */
61
+ onMarkRead?: () => void;
32
62
  /**
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).
63
+ * The list header's own chrome, rendered only while nothing is ticked: the
64
+ * nav button, the unread count beside the title, and the search affordance.
65
+ * The bar is the list header, so these live here rather than on a second
66
+ * row above it.
67
+ */
68
+ navSlot?: ReactNode;
69
+ titleMeta?: ReactNode;
70
+ searchSlot?: ReactNode;
71
+ /**
72
+ * The expanded search field, which owns the title's place for as long as it
73
+ * is up. Idle only — a ticked row hands the row back to the count.
74
+ */
75
+ searchField?: ReactNode;
76
+ /**
77
+ * A row below the action row, up only while nothing is ticked — the
78
+ * search's make-this-a-filter affordance. A selection's own verbs take the
79
+ * bar over, so the second way into the same wizard stands down.
80
+ */
81
+ idleSlot?: ReactNode;
82
+ /**
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
+ onMove,
92
175
  moveSlot,
176
+ onOrganize,
177
+ onJunk,
178
+ onMarkRead,
179
+ navSlot,
180
+ titleMeta,
181
+ searchSlot,
182
+ searchField,
183
+ idleSlot,
184
+ overflowSlot,
93
185
  isBusy = false,
94
186
  isCounting = false,
95
187
  selectAll,
@@ -97,68 +189,143 @@ 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
+ }
104
214
  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 && (
215
+ <header
216
+ data-selection-bar=""
217
+ className="flex shrink-0 flex-col border-b border-line bg-surface-sunken"
218
+ >
219
+ <div
220
+ data-selection-bar-row="actions"
221
+ className="flex h-pane-header items-center gap-1 px-row-inset"
222
+ >
223
+ {selecting ? (
134
224
  <Button
135
225
  variant="ghost"
136
226
  size="touch"
137
- icon={<MailOpen className="size-4" />}
138
- onClick={onMarkRead}
139
- aria-label="Mark as read"
140
- className="shrink-0"
227
+ icon={<ArrowLeft className="size-5" />}
228
+ onClick={onCancel}
229
+ aria-label="Cancel selection"
230
+ className="-ml-2 shrink-0"
141
231
  />
232
+ ) : (
233
+ navSlot
142
234
  )}
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"
235
+ {selectAll && (
236
+ <SelectAllToggle
237
+ selectAll={selectAll}
238
+ selecting={selecting}
239
+ className="hidden shrink-0 pr-2 md:flex"
159
240
  />
160
241
  )}
242
+ {selecting ? (
243
+ <span
244
+ data-selection-count=""
245
+ className="min-w-0 flex-1 truncate px-1 text-sm font-medium text-fg"
246
+ role="status"
247
+ aria-live="polite"
248
+ >
249
+ {statusLabel ?? defaultLabel}
250
+ </span>
251
+ ) : searchField ? (
252
+ <div className="flex min-w-0 flex-1 items-center gap-1">
253
+ {searchField}
254
+ </div>
255
+ ) : (
256
+ <>
257
+ <h1 className="min-w-0 flex-1 truncate px-1 text-sm font-semibold text-fg">
258
+ {title}
259
+ </h1>
260
+ {titleMeta}
261
+ {searchSlot}
262
+ </>
263
+ )}
264
+ {selecting && !isCounting && (
265
+ <>
266
+ <Button
267
+ variant="ghost"
268
+ size="touch"
269
+ icon={
270
+ isBusy ? (
271
+ <Loader2 className="size-5 animate-spin" />
272
+ ) : (
273
+ <Trash2 className="size-5 text-danger" />
274
+ )
275
+ }
276
+ onClick={isBusy ? undefined : onDelete}
277
+ aria-label="Move selected messages to Trash"
278
+ aria-busy={isBusy || undefined}
279
+ className="shrink-0"
280
+ />
281
+ {!isBusy &&
282
+ (onMove ? (
283
+ <Button
284
+ variant="ghost"
285
+ size="touch"
286
+ icon={<FolderInput className="size-5" />}
287
+ onClick={onMove}
288
+ aria-label="Move selected messages"
289
+ className="shrink-0"
290
+ />
291
+ ) : (
292
+ moveSlot
293
+ ))}
294
+ {!isBusy && onOrganize && (
295
+ <Button
296
+ variant="ghost"
297
+ size="touch"
298
+ icon={<Sparkles className="size-5" />}
299
+ onClick={onOrganize}
300
+ aria-label="Organize selected messages"
301
+ className="shrink-0"
302
+ />
303
+ )}
304
+ </>
305
+ )}
306
+ {selecting && (
307
+ <PopoverMenu
308
+ triggerLabel="More actions"
309
+ items={overflowItems}
310
+ className="shrink-0"
311
+ >
312
+ {!isBusy && !isCounting && overflowSlot}
313
+ </PopoverMenu>
314
+ )}
161
315
  </div>
316
+ {!selecting && idleSlot}
317
+ {selecting && selectAll && (
318
+ <div
319
+ data-selection-bar-row="select-all"
320
+ className="px-row-inset pb-2 md:hidden"
321
+ >
322
+ <SelectAllToggle
323
+ selectAll={selectAll}
324
+ selecting={selecting}
325
+ className="w-full"
326
+ />
327
+ </div>
328
+ )}
162
329
  {progress && (
163
330
  <div className="px-row-inset pb-2">
164
331
  <ProgressBar
@@ -170,6 +337,7 @@ export function SelectionTopBar({
170
337
  )}
171
338
  {notice && (
172
339
  <Banner
340
+ data-selection-notice=""
173
341
  tone={notice.tone}
174
342
  variant="soft"
175
343
  role="status"
@@ -581,7 +581,7 @@ describe("SelectionWizard", () => {
581
581
  wizardProps({ verb: "organize", steps, step: "properties" }),
582
582
  ),
583
583
  );
584
- assert.match(text(html), /Step 1 of 4 · Apply to/);
584
+ assert.match(text(html), /Step 1 of 5 · Apply to/);
585
585
  assert.match(html, /What should this apply to\?/);
586
586
  assert.match(html, /These 2 messages/);
587
587
  assert.doesNotMatch(html, /Match properties/);
@@ -657,23 +657,41 @@ describe("SelectionWizard", () => {
657
657
  }),
658
658
  ),
659
659
  );
660
- assert.match(html, /Add a property to match on\./);
661
- assert.match(html, /aria-disabled="true"/);
660
+ assert.match(text(html), /Add a property to match on\./);
661
+ assert.match(html, /opacity-55/);
662
+ // Nothing disables (#477 1.7), and `aria-disabled` disables it for
663
+ // everyone driving the page by anything other than a mouse.
664
+ assert.doesNotMatch(html, /aria-disabled/);
662
665
  assert.doesNotMatch(html, /<button[^>]*disabled=""/);
663
666
  });
664
667
 
665
- it("holds the blocked reason back until Continue is pressed", () => {
666
- const html = renderToString(
668
+ it("carries the blocked reason on the control before it is pressed, and shows it after", () => {
669
+ const quiet = renderToString(
670
+ createElement(
671
+ SelectionWizard,
672
+ wizardProps({
673
+ step: "properties",
674
+ blockedReason: "Add a property to match on.",
675
+ }),
676
+ ),
677
+ );
678
+ // Described, so it is heard on the control; not on screen until pressed.
679
+ assert.match(quiet, /aria-describedby="/);
680
+ assert.match(quiet, /sr-only/);
681
+ assert.doesNotMatch(quiet, /role="status"/);
682
+
683
+ const nudged = renderToString(
667
684
  createElement(
668
685
  SelectionWizard,
669
686
  wizardProps({
670
687
  step: "properties",
671
688
  blockedReason: "Add a property to match on.",
689
+ nudged: true,
672
690
  }),
673
691
  ),
674
692
  );
675
- assert.doesNotMatch(html, /Add a property to match on\./);
676
- assert.match(html, /aria-disabled="true"/);
693
+ assert.match(nudged, /role="status"/);
694
+ assert.doesNotMatch(nudged, /sr-only/);
677
695
  });
678
696
 
679
697
  it("commits under the scope's own label, in the verb's own tone", () => {