@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.
@@ -39,11 +39,12 @@
39
39
  * `resultsScopeForRoute` and `lib/spam-offer.ts`.
40
40
  */
41
41
  import {
42
+ Button,
42
43
  type FilterSheetProps,
43
44
  isConvertible,
44
- MailHeader,
45
45
  MakeFilterAction,
46
46
  MobileSearchView,
47
+ SearchBar,
47
48
  type SearchCaretRequest,
48
49
  type SearchFieldSuggest,
49
50
  type SearchResult,
@@ -56,6 +57,7 @@ import {
56
57
  useSuggestList,
57
58
  } from "@remit/ui";
58
59
  import { useNavigate, useRouterState } from "@tanstack/react-router";
60
+ import { Menu, Search, X } from "lucide-react";
59
61
  import {
60
62
  type ReactNode,
61
63
  useCallback,
@@ -68,6 +70,10 @@ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
68
70
  import { useSearchScope } from "@/hooks/useSearchScope";
69
71
  import { useSearchSuggestions } from "@/hooks/useSearchSuggestions";
70
72
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
73
+ import {
74
+ type ListHeaderChrome,
75
+ ListHeaderChromeContext,
76
+ } from "@/lib/list-header-chrome";
71
77
  import { useMailContext } from "@/lib/mail-context";
72
78
  import { convertSearchToRule } from "@/lib/organize/search-to-rule";
73
79
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
@@ -88,16 +94,18 @@ export interface MailListHeaderProps {
88
94
  /** The list body (filter sheet / sections / virtualized rows). */
89
95
  children: ReactNode;
90
96
  /**
91
- * Replaces the header for as long as a selection is active the same slot
92
- * contract the kit `MessageListPane` gives the mailbox list, so every list
93
- * puts its bulk-action bar in the same place.
97
+ * The header itself, built from the chrome this component owns. Views whose
98
+ * selection state sits above this one (the brief) render their bar here;
99
+ * views whose selection sits below it (the mailbox list, starred) leave this
100
+ * unset and render the bar in their own pane header slot, reading the same
101
+ * chrome from `useListHeaderChrome`.
94
102
  */
95
- selectionBar?: ReactNode;
103
+ selectionBar?: (chrome: ListHeaderChrome) => ReactNode;
96
104
  /**
97
- * Overlay anchored to the bottom of the pane (the mobile selection sheet).
98
- * The pane is the positioned ancestor the sheet measures against.
105
+ * An overlay covering the pane, above the list (the guided organize flow).
106
+ * The pane is the positioned ancestor it measures against.
99
107
  */
100
- selectionSheet?: ReactNode;
108
+ paneOverlay?: ReactNode;
101
109
  /** Pinned below the scrollable list (e.g. the keyboard hint bar). */
102
110
  footer?: ReactNode;
103
111
  /** Filter chrome for the phone search takeover. Omit to drop the filter row. */
@@ -134,7 +142,7 @@ export function MailListHeader({
134
142
  unreadCount,
135
143
  children,
136
144
  selectionBar,
137
- selectionSheet,
145
+ paneOverlay,
138
146
  footer,
139
147
  searchFilter,
140
148
  searchResults,
@@ -212,13 +220,20 @@ export function MailListHeader({
212
220
  if (suggestion) applySuggestion(suggestion);
213
221
  },
214
222
  });
215
- const searchSuggest: SearchFieldSuggest = {
216
- comboboxProps: suggest.comboboxProps,
217
- onKeyDown: suggest.handleKeyDown,
218
- onCaretChange: setCaretPosition,
219
- onFocusChange: setFieldFocused,
220
- ...(caretRequest ? { caret: caretRequest } : {}),
221
- };
223
+ // Stable while nothing about the field changes: it reaches the list through
224
+ // the header chrome, and a fresh object every render would re-render the
225
+ // virtualized body for nothing.
226
+ const { comboboxProps, handleKeyDown } = suggest;
227
+ const searchSuggest = useMemo<SearchFieldSuggest>(
228
+ () => ({
229
+ comboboxProps,
230
+ onKeyDown: handleKeyDown,
231
+ onCaretChange: setCaretPosition,
232
+ onFocusChange: setFieldFocused,
233
+ ...(caretRequest ? { caret: caretRequest } : {}),
234
+ }),
235
+ [comboboxProps, handleKeyDown, caretRequest],
236
+ );
222
237
  // Under the field and in flow, on both tiers: a phone's soft keyboard owns
223
238
  // the lower half of the screen, and a list floating over the field would
224
239
  // cover the query it is completing.
@@ -337,10 +352,11 @@ export function MailListHeader({
337
352
  : "Add a sender or words to filter on",
338
353
  }
339
354
  : undefined;
340
- // A selection replaces the header with its bulk-action bar and owns the pane;
341
- // the search affordance stands down until the selection clears.
342
- const makeFilterAction =
343
- makeFilter && !selectionBar ? <MakeFilterAction {...makeFilter} /> : null;
355
+ // Handed to the bar rather than rendered here: the bar knows whether rows
356
+ // are ticked, and a selection's own verbs own the surface while they are up.
357
+ const makeFilterAction = makeFilter ? (
358
+ <MakeFilterAction {...makeFilter} />
359
+ ) : null;
344
360
  const filterDialog =
345
361
  filterOpen && targetAccountId ? (
346
362
  <SearchFilterDialog
@@ -352,6 +368,125 @@ export function MailListHeader({
352
368
  />
353
369
  ) : null;
354
370
 
371
+ // Tablet + desktop keep the inline toolbar search; while a query is being
372
+ // typed the list-pane body swaps to the same sectioned results the phone
373
+ // takeover shows, under the same FilterSheet. A view whose own body renders
374
+ // the committed search as a selectable list (`searchResultsInBody`, the
375
+ // mailbox route) keeps the panel only until the query commits to the URL,
376
+ // then hands back to its `MessageList` so the multi-select toolbar and the
377
+ // escalation are reachable (#212). Clearing the query restores the normal list.
378
+ const showInlineResults = showInlineSearchResults({
379
+ tier,
380
+ hasLiveInput: hasQuery,
381
+ hasCommittedQuery: searchQuery.trim().length > 0,
382
+ bodyRendersCommittedResults: searchResultsInBody,
383
+ });
384
+ const handleSelectInlineResult = (result: SearchResult) => {
385
+ setRecentSearches(saveRecentSearch(searchInput));
386
+ onSelectSearchResult?.(result);
387
+ };
388
+ const results = (
389
+ <SearchResults
390
+ value={searchInput}
391
+ sections={sections}
392
+ loading={resultsLoading}
393
+ onSelectResult={handleSelectInlineResult}
394
+ tokens={tokenChips}
395
+ scope={resultsScope}
396
+ />
397
+ );
398
+ // The header lives inside `children` for every view whose selection sits
399
+ // below this one, so swapping the body out from here would take the header
400
+ // with it — the pane would lose its title, its search field mid-keystroke,
401
+ // and its selection bar. Those views get the panel through the chrome and
402
+ // put it where their own rows go; the brief, whose header this component
403
+ // renders, keeps the plain swap.
404
+ const bodyOwnsHeader = selectionBar === undefined;
405
+ const resultsPane = showInlineResults ? (
406
+ <div className="h-full overflow-y-auto">{results}</div>
407
+ ) : null;
408
+ const body = resultsPane && !bodyOwnsHeader ? resultsPane : children;
409
+ const chromeResults = resultsPane && bodyOwnsHeader ? resultsPane : null;
410
+
411
+ // Desktop mounts the app top bar, which owns search for the whole shell — the
412
+ // list header shows no field there, so the page never has two search inputs
413
+ // competing for "/" and for focus. Below desktop the header keeps a compact
414
+ // magnifier: on phone it opens the full-screen takeover above, on tablet it
415
+ // expands over the title. `isSinglePaneTier` is the same predicate the shell
416
+ // gates the top bar on, so the two cannot drift into zero or two fields.
417
+ const ownsSearch = isSinglePaneTier(tier);
418
+ const searchExpanded = ownsSearch && (searchOpen || hasQuery);
419
+ const chrome = useMemo<ListHeaderChrome>(
420
+ () => ({
421
+ title,
422
+ searchResults: chromeResults,
423
+ makeFilterSlot: makeFilterAction,
424
+ navSlot: layout && !layout.showNavPane && (
425
+ <Button
426
+ variant="ghost"
427
+ size="touch"
428
+ icon={<Menu className="size-5" />}
429
+ onClick={() => layout.openNav()}
430
+ aria-label="Menu"
431
+ className="-ml-2 shrink-0"
432
+ />
433
+ ),
434
+ titleMeta: (
435
+ <span className="shrink-0 text-2xs text-fg-subtle">
436
+ {unreadCount.toLocaleString()} unread
437
+ </span>
438
+ ),
439
+ searchSlot: ownsSearch && !searchExpanded && (
440
+ <Button
441
+ variant="ghost"
442
+ size="touch"
443
+ icon={<Search className="size-5" />}
444
+ onClick={() => setSearchOpen(true)}
445
+ aria-label="Search"
446
+ className="shrink-0"
447
+ />
448
+ ),
449
+ searchField: searchExpanded && (
450
+ <>
451
+ <div className="min-w-0 flex-1">
452
+ <SearchBar
453
+ value={searchInput}
454
+ onChange={onSearchChange}
455
+ onClear={onSearchClear}
456
+ globalFocusKey={false}
457
+ showClearButton={false}
458
+ suggest={searchSuggest}
459
+ />
460
+ </div>
461
+ <Button
462
+ variant="ghost"
463
+ size="touch"
464
+ icon={<X className="size-5" />}
465
+ onClick={() => {
466
+ onSearchClear();
467
+ setSearchOpen(false);
468
+ }}
469
+ aria-label="Close search"
470
+ className="shrink-0"
471
+ />
472
+ </>
473
+ ),
474
+ }),
475
+ [
476
+ title,
477
+ unreadCount,
478
+ layout,
479
+ ownsSearch,
480
+ searchExpanded,
481
+ searchInput,
482
+ onSearchChange,
483
+ onSearchClear,
484
+ searchSuggest,
485
+ chromeResults,
486
+ makeFilterAction,
487
+ ],
488
+ );
489
+
355
490
  if (tier === "phone" && searchOpen) {
356
491
  const handleSelectResult = (result: SearchResult) => {
357
492
  setRecentSearches(saveRecentSearch(searchInput));
@@ -385,71 +520,16 @@ export function MailListHeader({
385
520
  );
386
521
  }
387
522
 
388
- // Tablet + desktop keep the inline toolbar search; while a query is being
389
- // typed the list-pane body swaps to the same sectioned results the phone
390
- // takeover shows, under the same FilterSheet. A view whose own body renders
391
- // the committed search as a selectable list (`searchResultsInBody`, the
392
- // mailbox route) keeps the panel only until the query commits to the URL,
393
- // then hands back to its `MessageList` so the multi-select toolbar and the
394
- // escalation are reachable (#212). Clearing the query restores the normal list.
395
- const showInlineResults = showInlineSearchResults({
396
- tier,
397
- hasLiveInput: hasQuery,
398
- hasCommittedQuery: searchQuery.trim().length > 0,
399
- bodyRendersCommittedResults: searchResultsInBody,
400
- });
401
- const handleSelectInlineResult = (result: SearchResult) => {
402
- setRecentSearches(saveRecentSearch(searchInput));
403
- onSelectSearchResult?.(result);
404
- };
405
- const results = (
406
- <SearchResults
407
- value={searchInput}
408
- sections={sections}
409
- loading={resultsLoading}
410
- onSelectResult={handleSelectInlineResult}
411
- tokens={tokenChips}
412
- scope={resultsScope}
413
- />
414
- );
415
- // The filter chrome is not rendered while a query is active — see `makeFilter`
416
- // above — so the results panel gets the plain scroll container either way.
417
- const body = showInlineResults ? (
418
- <div className="h-full overflow-y-auto">{results}</div>
419
- ) : (
420
- children
421
- );
422
-
423
523
  return (
424
- <section className="relative flex h-full w-full flex-col bg-surface">
425
- {selectionBar ?? (
426
- <MailHeader
427
- title={title}
428
- unreadCount={unreadCount}
429
- // Desktop mounts the app top bar, which owns search for the whole
430
- // shell — the list header shows no field there, so the page never
431
- // has two search inputs competing for "/" and for focus. Below
432
- // desktop the header keeps a compact magnifier: on phone it opens
433
- // the full-screen takeover above, on tablet it expands over the
434
- // title. `isSinglePaneTier` is the same predicate the shell gates
435
- // the top bar on, so the two cannot drift into zero or two fields.
436
- isDesktop={false}
437
- showSearch={isSinglePaneTier(tier)}
438
- onMenuClick={() => layout?.openNav()}
439
- searchValue={searchInput}
440
- onSearchChange={onSearchChange}
441
- onSearchClear={onSearchClear}
442
- searchOpen={searchOpen}
443
- onSearchOpenChange={setSearchOpen}
444
- searchSuggest={searchSuggest}
445
- />
446
- )}
447
- {suggestList}
448
- {makeFilterAction}
449
- <div className="min-h-0 flex-1">{body}</div>
450
- {footer}
451
- {filterDialog}
452
- {selectionSheet}
453
- </section>
524
+ <ListHeaderChromeContext.Provider value={chrome}>
525
+ <section className="relative flex h-full w-full flex-col bg-surface">
526
+ {selectionBar?.(chrome)}
527
+ {suggestList}
528
+ <div className="min-h-0 flex-1">{body}</div>
529
+ {footer}
530
+ {filterDialog}
531
+ {paneOverlay}
532
+ </section>
533
+ </ListHeaderChromeContext.Provider>
454
534
  );
455
535
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * MailViewChrome — the shared list-pane chrome for the inbox and flagged views.
3
3
  *
4
- * Wraps the header-only `MailListHeader` and slots the `FilterSheet` expando
4
+ * Wraps `MailListHeader` and slots the `FilterSheet` expando
5
5
  * directly into its body, exactly as the kit story does. The caller supplies the
6
6
  * filter preset (`inboxFilterConfig` / `flaggedFilterConfig`) and owns the
7
7
  * category / attribute / source selection state. The same filter config feeds
@@ -117,11 +117,12 @@ export function MailViewChrome({
117
117
  relatedResultsLabel={relatedResultsLabel}
118
118
  searchResultsInBody={searchResultsInBody}
119
119
  >
120
- {searching ? (
121
- <div className="h-full overflow-y-auto">{children}</div>
122
- ) : (
123
- <FilterSheet {...filterConfig}>{children}</FilterSheet>
124
- )}
120
+ {/* One shell either way: the children's parent must not change when a
121
+ query starts, or everything under it — including the header's own
122
+ search field — remounts mid-keystroke. */}
123
+ <FilterSheet {...filterConfig} hideChrome={searching}>
124
+ {children}
125
+ </FilterSheet>
125
126
  </MailListHeader>
126
127
  );
127
128
  }
@@ -70,8 +70,8 @@ describe("MessageList escalated actions", () => {
70
70
  });
71
71
 
72
72
  it("keeps mark-read and the move slot on an escalated selection", () => {
73
- // The mobile sheet always carries the mark-read verb (it hides it itself
74
- // while counting or busy); an escalated selection must never lose it.
73
+ // The bar always carries the mark-read verb (it drops it itself while
74
+ // counting or busy); an escalated selection must never lose it.
75
75
  assert.match(source, /onMarkRead=\{handleMarkAsRead\}/);
76
76
  // The move slot is offered for a bounded selection or an escalated one —
77
77
  // #114's rule that an escalated selection is never delete-only.
@@ -95,9 +95,8 @@ describe("MessageList escalated actions", () => {
95
95
 
96
96
  /**
97
97
  * Advanced selection is no longer mobile-only (#212): the escalation engine is
98
- * opened to desktop, and the desktop `SelectionToolbar` renders the same
99
- * offer → counting → escalated → progress → notice states the mobile sheet
100
- * carries, from one shared set of derivations so the two surfaces never drift.
98
+ * opened to every width, and the one selection bar renders the
99
+ * offer → counting → escalated → progress → notice states.
101
100
  */
102
101
  describe("MessageList escalation reaches desktop (#212)", () => {
103
102
  it("no longer gates the escalation engine on the mobile viewport", () => {
@@ -111,39 +110,38 @@ describe("MessageList escalation reaches desktop (#212)", () => {
111
110
  );
112
111
  });
113
112
 
114
- it("feeds the desktop toolbar the shared escalation state", () => {
115
- const toolbar = source.match(/<SelectionToolbar[\s\S]*?\/>/)?.[0] ?? "";
116
- assert.match(toolbar, /statusLabel=\{selectionStatusLabel\}/);
117
- assert.match(toolbar, /notice=\{escalationNotice\}/);
118
- assert.match(toolbar, /progress=\{selectionProgress\}/);
119
- // Desktop wires select-all only while searching — the mobile sheet carries
120
- // it for any bounded selection.
121
- assert.match(
122
- toolbar,
123
- /selectAll=\{escalationEnabled \? selectionSelectAll : undefined\}/,
124
- );
125
- assert.match(
126
- toolbar,
127
- /isCounting=\{escalation\.phase\.kind === "counting"\}/,
128
- );
113
+ it("feeds the bar the shared escalation state", () => {
114
+ // Up to the bar's own closing tag, at its own indent — a nested slot
115
+ // element closes first and would cut the match short.
116
+ const bar = source.match(/<SelectionTopBar[\s\S]*?\n\t\t\/>/)?.[0] ?? "";
117
+ assert.match(bar, /statusLabel=\{selectionStatusLabel\}/);
118
+ assert.match(bar, /notice=\{selectionNotice\}/);
119
+ assert.match(bar, /progress=\{selectionProgress\}/);
120
+ assert.match(bar, /selectAll=\{selectionSelectAll\}/);
121
+ assert.match(bar, /isCounting=\{escalation\.phase\.kind === "counting"\}/);
122
+ });
123
+
124
+ it("raises one selection surface, at every width (#480)", () => {
125
+ const bars = source.match(/<SelectionTopBar\b/g) ?? [];
126
+ assert.equal(bars.length, 1, "one bar, so no two surfaces can drift");
127
+ assert.doesNotMatch(source, /<SelectionSheet\b/);
128
+ });
129
+
130
+ it("mounts that surface unconditionally — it is the list header", () => {
131
+ // Gated on a selection, the bar's zero-ticked state (the mailbox name,
132
+ // and select-all inline from 768px up) reaches nothing.
133
+ assert.match(source, /const activeSelectionBar = \(\s*<SelectionTopBar/);
134
+ assert.match(source, /title=\{listHeaderChrome\.title \|\| listTitle\}/);
135
+ assert.match(source, /navSlot=\{listHeaderChrome\.navSlot\}/);
136
+ });
137
+
138
+ it("keeps the free-text organize entry reachable, in the overflow", () => {
139
+ assert.match(source, /onSomethingElse=\{/);
140
+ assert.match(source, /setMobileOrganizeEntry\("something-else"\)/);
129
141
  });
130
142
 
131
- it("drives both surfaces from the same derivations", () => {
132
- // The sheet and the toolbar read the same status label, select-all control
133
- // and progress — a second copy for desktop is exactly the drift this
134
- // guards against.
135
- for (const shared of [
136
- "selectionStatusLabel",
137
- "selectionSelectAll",
138
- "selectionProgress",
139
- "selectionCount",
140
- ]) {
141
- const uses = source.match(new RegExp(`\\b${shared}\\b`, "g")) ?? [];
142
- assert.ok(
143
- uses.length >= 3,
144
- `${shared} should be defined once and read by both surfaces`,
145
- );
146
- }
143
+ it("offers the label picker only when the account has labels", () => {
144
+ assert.match(source, /labels\.length > 0 \? \(/);
147
145
  });
148
146
  });
149
147