@remit/web-client 0.0.90 → 0.0.92

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.90",
3
+ "version": "0.0.92",
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": {
@@ -5,10 +5,9 @@ import { describe, it } from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  /**
8
- * The brief raises two selection surfaces the desktop toolbar and the touch
9
- * sheetand they offer the same verbs. Select-all was wired into the sheet
10
- * only, so the same list could be selected whole on a phone and not on a
11
- * desktop (#453).
8
+ * The brief raises one selection surface at every width, and select-all is on
9
+ * itwired into the touch sheet alone, the same list could be selected whole
10
+ * on a phone and not on a desktop (#453).
12
11
  *
13
12
  * As with `MessageList.selection.test.ts`, `DailyBrief` weaves routing, the
14
13
  * cursor and several data hooks together, so the rule is enforced by reading
@@ -27,23 +26,28 @@ const chromeBody = (): string => {
27
26
  };
28
27
 
29
28
  describe("the brief's select-all", () => {
30
- it("reaches the desktop toolbar and the touch sheet from one derivation", () => {
29
+ it("reaches the one selection bar from one derivation", () => {
31
30
  const body = chromeBody();
32
31
  const derivations = body.match(/const selectAll = useMemo\(/g) ?? [];
33
- assert.equal(
34
- derivations.length,
35
- 1,
36
- "one select-all, so the two surfaces cannot drift",
37
- );
32
+ assert.equal(derivations.length, 1, "one select-all");
33
+ const bars = body.match(/<SelectionTopBar\b/g) ?? [];
34
+ assert.equal(bars.length, 1, "one surface, at every width (#480)");
38
35
  const wired = body.match(/selectAll=\{selectAll\}/g) ?? [];
39
- assert.equal(
40
- wired.length,
41
- 2,
42
- "both SelectionToolbar and SelectionSheet take it",
43
- );
36
+ assert.equal(wired.length, 1, "the bar takes it");
44
37
  });
45
38
 
46
39
  it("is offered only when there are rows to select", () => {
47
40
  assert.match(chromeBody(), /orderedIds\.length > 0/);
48
41
  });
42
+
43
+ it("mounts the bar unconditionally — it is the list header (#480)", () => {
44
+ assert.match(
45
+ chromeBody(),
46
+ /const selectionBar = \(chrome: ListHeaderChrome\) => \(\s*<SelectionTopBar/,
47
+ );
48
+ });
49
+
50
+ it("keeps the free-text organize entry reachable, in the overflow", () => {
51
+ assert.match(chromeBody(), /onSomethingElse=\{/);
52
+ });
49
53
  });
@@ -16,9 +16,8 @@
16
16
  * the phone search takeover keeps its own copy of the filter sheet.
17
17
  *
18
18
  * Multi-select is the mailbox list's, not a copy of it: the same
19
- * `ThreadListInteraction` cursor and `useSelection` state, the same
20
- * `SelectionToolbar` replacing the pane header on desktop, and the same
21
- * peeking `SelectionSheet` on touch. A shift-range spans the rendered rows in
19
+ * `ThreadListInteraction` cursor and `useSelection` state, and the same
20
+ * `SelectionTopBar` in place of the pane header. A shift-range spans the rendered rows in
22
21
  * document order, so it crosses category sections exactly as the eye reads
23
22
  * them and never picks up a row hidden behind "Show N more" or a collapsed
24
23
  * header. Folder-scoped verbs (Move, Apply label, Organize) resolve their
@@ -43,9 +42,8 @@ import {
43
42
  type FilterSheetProps,
44
43
  type FilterSheetSource,
45
44
  KeyboardHintBar,
46
- SELECTION_SHEET_TEASER_HEIGHT,
47
45
  type SearchResult,
48
- SelectionSheet,
46
+ SelectionTopBar,
49
47
  type ThreadRowData,
50
48
  type ThreadSection,
51
49
  } from "@remit/ui";
@@ -65,6 +63,7 @@ import {
65
63
  isSyncingPhase,
66
64
  useInitialSyncProgress,
67
65
  } from "@/hooks/useInitialSyncProgress";
66
+ import { useLabelList } from "@/hooks/useLabels";
68
67
  import { useIsDesktop } from "@/hooks/useMediaQuery";
69
68
  import { useMoveMessages } from "@/hooks/useMoveMessages";
70
69
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
@@ -80,17 +79,17 @@ import {
80
79
  toThreadRowData,
81
80
  } from "@/lib/brief";
82
81
  import { isServerError } from "@/lib/error-classifier";
82
+ import type { ListHeaderChrome } from "@/lib/list-header-chrome";
83
83
  import { useMailContext } from "@/lib/mail-context";
84
84
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
85
85
  import { parseSearchTokens } from "@/lib/search-tokens";
86
- import { shouldShowSelectionSheet } from "@/lib/selection-sheet-mode";
86
+ import { LabelApplyTrigger } from "./LabelApplyTrigger";
87
87
  import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
88
88
  import type { MessageListCommands } from "./MessageList";
89
89
  import { MessageRow } from "./MessageRow";
90
90
  import { MoveToTrigger } from "./MoveToTrigger";
91
91
  import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
92
92
  import { OrganizeDialog } from "./organize/OrganizeDialog";
93
- import { SelectionToolbar } from "./SelectionToolbar";
94
93
  import {
95
94
  type OpenMessageOptions,
96
95
  ThreadListInteraction,
@@ -290,7 +289,7 @@ interface BriefSelectionChromeProps {
290
289
  /** Everything the header needs when no selection is active. */
291
290
  header: Omit<
292
291
  MailListHeaderProps,
293
- "children" | "selectionBar" | "selectionSheet"
292
+ "children" | "selectionBar" | "paneOverlay"
294
293
  >;
295
294
  /** The rows the list is showing, for resolving the selection's scope. */
296
295
  rows: readonly ThreadRowData[];
@@ -300,10 +299,9 @@ interface BriefSelectionChromeProps {
300
299
  }
301
300
 
302
301
  /**
303
- * The brief's selection chrome: the same two surfaces the mailbox list raises,
304
- * in the same places. Desktop replaces the pane header with `SelectionToolbar`;
305
- * touch leaves the header alone and raises the peeking `SelectionSheet` at two
306
- * or more selected. The selection itself comes from the enclosing
302
+ * The brief's selection chrome: the same surface the mailbox list raises, in
303
+ * the same place. The pane header carries the count and the verbs from the
304
+ * first ticked row. The selection itself comes from the enclosing
307
305
  * `ThreadListInteraction`, so the brief and the mailbox list run one model.
308
306
  */
309
307
  function BriefSelectionChrome({
@@ -328,6 +326,7 @@ function BriefSelectionChrome({
328
326
  [rows, selectedIds],
329
327
  );
330
328
  const { junkMailboxId } = useJunkMailbox(scope.accountId);
329
+ const { labels } = useLabelList(scope.accountId);
331
330
  const { moveMessages, isPending: isMoving } = useMoveMessages({
332
331
  mailboxId: scope.mailboxId ?? "",
333
332
  accountId: scope.accountId,
@@ -395,33 +394,65 @@ function BriefSelectionChrome({
395
394
  [orderedIds.length, allSelected, selectedCount, toggleAllLoaded],
396
395
  );
397
396
 
398
- const selectionBar =
399
- isDesktop && selectedCount > 0 ? (
400
- <SelectionToolbar
401
- selectedCount={selectedCount}
402
- onDelete={requestDeleteSelection}
403
- onClearSelection={exitSelection}
404
- onMarkAsRead={onMarkMessagesRead ? handleMarkAsRead : undefined}
405
- onMove={scoped ? handleMove : undefined}
406
- onOrganize={() => setOrganizeOpen(true)}
407
- isMoving={isMoving}
408
- accountId={scope.accountId}
409
- currentMailboxId={scope.mailboxId}
410
- selectedMessageIds={selectedMessageIds}
411
- moveDisabledHint={scope.moveDisabledHint}
412
- selectAll={selectAll}
413
- />
414
- ) : undefined;
415
-
416
- // The mobile sheet has no inline hint slot, so the scope restriction rides
417
- // in as a notice — the same fold-in the mailbox list does.
397
+ // The bar shows one notice at a time, so the scope restriction rides in as
398
+ // a notice the same fold-in the mailbox list does.
418
399
  const notice = scope.moveDisabledHint
419
400
  ? { tone: "warning" as const, text: scope.moveDisabledHint }
420
401
  : undefined;
421
- const showSheet =
422
- !isDesktop &&
423
- selectedCount > 0 &&
424
- shouldShowSelectionSheet(selectedCount, "idle", notice !== undefined);
402
+
403
+ const selectionBar = (chrome: ListHeaderChrome) => (
404
+ <SelectionTopBar
405
+ title={chrome.title}
406
+ navSlot={chrome.navSlot}
407
+ titleMeta={chrome.titleMeta}
408
+ searchSlot={chrome.searchSlot}
409
+ searchField={chrome.searchField}
410
+ idleSlot={chrome.makeFilterSlot}
411
+ count={selectedCount}
412
+ onCancel={exitSelection}
413
+ onDelete={requestDeleteSelection}
414
+ onOrganize={
415
+ scoped
416
+ ? () =>
417
+ isDesktop
418
+ ? setOrganizeOpen(true)
419
+ : setMobileOrganizeEntry("select-similar")
420
+ : undefined
421
+ }
422
+ onJunk={canJunk ? handleJunk : undefined}
423
+ onMarkRead={onMarkMessagesRead ? handleMarkAsRead : undefined}
424
+ onSomethingElse={
425
+ scoped && !isDesktop
426
+ ? () => setMobileOrganizeEntry("something-else")
427
+ : undefined
428
+ }
429
+ moveSlot={
430
+ scoped && scope.accountId && scope.mailboxId ? (
431
+ <MoveToTrigger
432
+ accountId={scope.accountId}
433
+ currentMailboxId={scope.mailboxId}
434
+ onMove={isMoving ? () => {} : handleMove}
435
+ label="Move selected messages"
436
+ />
437
+ ) : undefined
438
+ }
439
+ overflowSlot={
440
+ scope.accountId &&
441
+ scope.mailboxId &&
442
+ selectedCount > 0 &&
443
+ labels.length > 0 ? (
444
+ <LabelApplyTrigger
445
+ accountId={scope.accountId}
446
+ mailboxId={scope.mailboxId}
447
+ messageIds={selectedMessageIds}
448
+ />
449
+ ) : undefined
450
+ }
451
+ isBusy={isMoving}
452
+ selectAll={selectAll}
453
+ notice={notice}
454
+ />
455
+ );
425
456
 
426
457
  const organizeFlow =
427
458
  mobileOrganizeEntry &&
@@ -441,64 +472,14 @@ function BriefSelectionChrome({
441
472
  />
442
473
  ) : undefined;
443
474
 
444
- const selectionSheet =
445
- showSheet || organizeFlow ? (
446
- <>
447
- {showSheet && (
448
- <SelectionSheet
449
- count={selectedCount}
450
- onCancel={exitSelection}
451
- onDelete={requestDeleteSelection}
452
- onJunk={canJunk ? handleJunk : undefined}
453
- onMarkRead={onMarkMessagesRead ? handleMarkAsRead : undefined}
454
- onSelectSimilar={
455
- scoped
456
- ? () => setMobileOrganizeEntry("select-similar")
457
- : undefined
458
- }
459
- onSomethingElse={
460
- scoped
461
- ? () => setMobileOrganizeEntry("something-else")
462
- : undefined
463
- }
464
- moveSlot={
465
- scoped && scope.accountId && scope.mailboxId ? (
466
- <MoveToTrigger
467
- accountId={scope.accountId}
468
- currentMailboxId={scope.mailboxId}
469
- onMove={isMoving ? () => {} : handleMove}
470
- label="Move selected messages"
471
- />
472
- ) : undefined
473
- }
474
- isBusy={isMoving}
475
- selectAll={selectAll}
476
- notice={notice}
477
- />
478
- )}
479
- {organizeFlow}
480
- </>
481
- ) : undefined;
482
-
483
475
  return (
484
476
  <>
485
477
  <MailListHeader
486
478
  {...header}
487
479
  selectionBar={selectionBar}
488
- selectionSheet={selectionSheet}
480
+ paneOverlay={organizeFlow}
489
481
  >
490
- {/* Shrink the body by the teaser's height rather than letting the
491
- last rows hide behind it. */}
492
- <div
493
- className="h-full"
494
- style={
495
- showSheet
496
- ? { paddingBottom: SELECTION_SHEET_TEASER_HEIGHT }
497
- : undefined
498
- }
499
- >
500
- {children}
501
- </div>
482
+ {children}
502
483
  </MailListHeader>
503
484
  {organizeOpen && scope.accountId && (
504
485
  <OrganizeDialog
@@ -531,7 +512,7 @@ interface DailyBriefProps {
531
512
  commandsRef?: RefObject<MessageListCommands | null>;
532
513
  /** Cursor / selection / display order, reported up to the triage layer. */
533
514
  onTriageContextChange?: (context: TriageContextUpdate) => void;
534
- onDeleteMessages?: (messageIds: string[]) => void;
515
+ onDeleteMessages: (messageIds: string[]) => void;
535
516
  onMarkMessagesRead?: (messageIds: string[]) => void;
536
517
  }
537
518
 
@@ -30,6 +30,7 @@ import {
30
30
  toThreadRowData,
31
31
  } from "@/lib/brief";
32
32
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
33
+ import { useListHeaderChrome } from "@/lib/list-header-chrome";
33
34
  import { useMailContext } from "@/lib/mail-context";
34
35
  import { rowToSearchResult } from "@/lib/search-result";
35
36
  import { parseSearchTokens } from "@/lib/search-tokens";
@@ -55,7 +56,7 @@ interface FlaggedListProps {
55
56
  commandsRef?: RefObject<MessageListCommands | null>;
56
57
  /** Cursor / selection / display order, reported up to the triage layer. */
57
58
  onTriageContextChange?: (context: TriageContextUpdate) => void;
58
- onDeleteMessages?: (messageIds: string[]) => void;
59
+ onDeleteMessages: (messageIds: string[]) => void;
59
60
  onMarkMessagesRead?: (messageIds: string[]) => void;
60
61
  }
61
62
 
@@ -146,42 +147,32 @@ export function FlaggedList({
146
147
  window.open(url, "_blank", "noopener,noreferrer");
147
148
  }, []);
148
149
 
150
+ const chrome = useListHeaderChrome();
149
151
  const listBody = (
150
- <ThreadListInteraction
151
- selectedMessageId={selectedMessageId}
152
- onOpen={(id, options) => onSelectMessage?.(id, options)}
153
- onDeleteMessages={onDeleteMessages}
154
- commandsRef={commandsRef}
155
- onTriageContextChange={onTriageContextChange}
156
- >
157
- <div className="flex h-full min-h-0 flex-col">
158
- {onDeleteMessages ? (
159
- <ThreadListSelectionBar onMarkAsRead={onMarkMessagesRead} />
160
- ) : null}
161
- <div className="flex-1 overflow-y-auto">
162
- <div className="divide-y divide-line">
163
- {rows.map((thread) => (
164
- <MessageRow
165
- key={thread.id}
166
- thread={thread}
167
- active={thread.id === selectedMessageId}
168
- onClick={() => onSelectMessage?.(thread.id)}
169
- />
170
- ))}
171
- </div>
172
- {hasNextPage ? (
173
- <button
174
- type="button"
175
- className="w-full py-3 text-sm text-muted hover:text-fg disabled:opacity-50"
176
- onClick={() => fetchNextPage()}
177
- disabled={isFetchingNextPage}
178
- >
179
- {isFetchingNextPage ? "Loading…" : "Load more"}
180
- </button>
181
- ) : null}
152
+ <div className="flex h-full min-h-0 flex-col">
153
+ <div className="flex-1 overflow-y-auto">
154
+ <div className="divide-y divide-line">
155
+ {rows.map((thread) => (
156
+ <MessageRow
157
+ key={thread.id}
158
+ thread={thread}
159
+ active={thread.id === selectedMessageId}
160
+ onClick={() => onSelectMessage?.(thread.id)}
161
+ />
162
+ ))}
182
163
  </div>
164
+ {hasNextPage ? (
165
+ <button
166
+ type="button"
167
+ className="w-full py-3 text-sm text-muted hover:text-fg disabled:opacity-50"
168
+ onClick={() => fetchNextPage()}
169
+ disabled={isFetchingNextPage}
170
+ >
171
+ {isFetchingNextPage ? "Loading…" : "Load more"}
172
+ </button>
173
+ ) : null}
183
174
  </div>
184
- </ThreadListInteraction>
175
+ </div>
185
176
  );
186
177
 
187
178
  return (
@@ -198,22 +189,39 @@ export function FlaggedList({
198
189
  searchLoading={isLoading}
199
190
  onSelectSearchResult={(result) => onSelectMessage?.(result.id)}
200
191
  >
201
- <MessageListPane
202
- listTitle="Starred"
203
- sections={[{ id: "flagged", threads: rows }]}
204
- flatList
205
- hideHeader
206
- listState={listState}
207
- searchQuery={sq ? searchQuery : undefined}
208
- errorMessage={isError ? formatErrorMessage(error) : undefined}
209
- onRetry={() => refetch()}
210
- onReportError={handleReportError}
211
- selectedThreadId={selectedMessageId}
212
- onSelectThread={onSelectMessage}
213
- onSelectBriefCategory={() => undefined}
214
- isDesktop={isDesktop}
215
- listBody={listState === "ready" ? listBody : undefined}
216
- />
192
+ <ThreadListInteraction
193
+ selectedMessageId={selectedMessageId}
194
+ onOpen={(id, options) => onSelectMessage?.(id, options)}
195
+ onDeleteMessages={onDeleteMessages}
196
+ commandsRef={commandsRef}
197
+ onTriageContextChange={onTriageContextChange}
198
+ >
199
+ <MessageListPane
200
+ listTitle="Starred"
201
+ sections={[{ id: "flagged", threads: rows }]}
202
+ flatList
203
+ hideHeader
204
+ listState={chrome.searchResults ? "ready" : listState}
205
+ searchQuery={sq ? searchQuery : undefined}
206
+ errorMessage={isError ? formatErrorMessage(error) : undefined}
207
+ onRetry={() => refetch()}
208
+ onReportError={handleReportError}
209
+ selectedThreadId={selectedMessageId}
210
+ onSelectThread={onSelectMessage}
211
+ onSelectBriefCategory={() => undefined}
212
+ isDesktop={isDesktop}
213
+ selectionBar={
214
+ <ThreadListSelectionBar
215
+ title="Starred"
216
+ onMarkAsRead={onMarkMessagesRead}
217
+ />
218
+ }
219
+ listBody={
220
+ chrome.searchResults ??
221
+ (listState === "ready" ? listBody : undefined)
222
+ }
223
+ />
224
+ </ThreadListInteraction>
217
225
  </MailViewChrome>
218
226
  );
219
227
  }
@@ -17,10 +17,11 @@ interface LabelApplyTriggerProps {
17
17
 
18
18
  /**
19
19
  * "Apply label" for a selection — the manual "just these" scope (issue #26,
20
- * RFC 034 recap). A kebab-style menu of the account's labels; picking one
21
- * applies it to every selected message. Renders nothing when the account has
22
- * no labels yet — an empty menu offering only "create one in Settings" is
23
- * dead weight in the toolbar; Settings › Labels is where creation lives.
20
+ * RFC 034 recap). A worded row at the foot of the selection bar's overflow
21
+ * menu, opening the account's labels; picking one applies it to every selected
22
+ * message. Renders nothing when the account has no labels yet — an empty menu
23
+ * offering only "create one in Settings" is dead weight; Settings › Labels is
24
+ * where creation lives.
24
25
  */
25
26
  export function LabelApplyTrigger({
26
27
  accountId,
@@ -54,8 +55,13 @@ export function LabelApplyTrigger({
54
55
  return (
55
56
  <PopoverMenu
56
57
  triggerLabel="Apply label to selected messages"
57
- triggerIcon={<Tag className="size-4" />}
58
+ triggerIcon={<Tag className="size-4 text-fg-subtle" />}
59
+ triggerText="Apply label"
58
60
  items={items}
61
+ align="start"
62
+ nested
63
+ touch={false}
64
+ triggerClassName="min-h-11 w-full justify-start gap-3 px-4 py-2.5 text-sm font-normal text-fg"
59
65
  />
60
66
  );
61
67
  }