@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 +1 -1
- package/src/components/mail/DailyBrief.selection.test.ts +19 -15
- package/src/components/mail/DailyBrief.tsx +70 -89
- package/src/components/mail/FlaggedList.tsx +58 -50
- package/src/components/mail/LabelApplyTrigger.tsx +11 -5
- package/src/components/mail/MailListHeader.tsx +165 -85
- package/src/components/mail/MailViewChrome.tsx +7 -6
- package/src/components/mail/MessageList.selection.test.ts +42 -37
- package/src/components/mail/MessageList.tsx +70 -98
- package/src/components/mail/MoveToTrigger.tsx +1 -1
- package/src/components/mail/SelectionWizardHost.tsx +191 -0
- package/src/components/mail/ThreadListInteraction.test.ts +8 -2
- package/src/components/mail/ThreadListInteraction.tsx +56 -24
- package/src/hooks/useInitialSyncProgress.render.test.ts +11 -5
- package/src/lib/list-header-chrome.ts +50 -0
- package/src/lib/mail-context.ts +9 -0
- package/src/lib/selection-mode.test.ts +20 -6
- package/src/lib/selection-mode.ts +8 -1
- package/src/lib/wizard-history.test.ts +176 -0
- package/src/lib/wizard-history.ts +116 -0
- package/src/routes/mail.tsx +11 -0
- package/src/components/mail/SelectionToolbar.render.test.ts +0 -324
- package/src/components/mail/SelectionToolbar.stories.tsx +0 -199
- package/src/components/mail/SelectionToolbar.tsx +0 -274
- package/src/lib/selection-sheet-mode.test.ts +0 -105
- package/src/lib/selection-sheet-mode.ts +0 -49
|
@@ -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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
*
|
|
98
|
-
* The pane is the positioned ancestor
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
const makeFilterAction =
|
|
343
|
-
|
|
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
|
-
<
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
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
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
}
|
|
@@ -45,10 +45,17 @@ describe("MessageList selection mode", () => {
|
|
|
45
45
|
it("turns the back gesture into an exit, and only while selecting", () => {
|
|
46
46
|
assert.match(
|
|
47
47
|
source,
|
|
48
|
-
/shouldExitSelectionOnNavigate\(action, hasSelection\)/,
|
|
48
|
+
/shouldExitSelectionOnNavigate\(action, hasSelection, wizardStep\)/,
|
|
49
49
|
);
|
|
50
50
|
assert.match(source, /disabled: !hasSelection/);
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
it("leaves the back gesture to the wizard while the wizard is open", () => {
|
|
54
|
+
assert.match(
|
|
55
|
+
source,
|
|
56
|
+
/disabled: !hasSelection \|\| wizardStep !== undefined/,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
52
59
|
});
|
|
53
60
|
|
|
54
61
|
/**
|
|
@@ -70,8 +77,8 @@ describe("MessageList escalated actions", () => {
|
|
|
70
77
|
});
|
|
71
78
|
|
|
72
79
|
it("keeps mark-read and the move slot on an escalated selection", () => {
|
|
73
|
-
// The
|
|
74
|
-
//
|
|
80
|
+
// The bar always carries the mark-read verb (it drops it itself while
|
|
81
|
+
// counting or busy); an escalated selection must never lose it.
|
|
75
82
|
assert.match(source, /onMarkRead=\{handleMarkAsRead\}/);
|
|
76
83
|
// The move slot is offered for a bounded selection or an escalated one —
|
|
77
84
|
// #114's rule that an escalated selection is never delete-only.
|
|
@@ -95,9 +102,8 @@ describe("MessageList escalated actions", () => {
|
|
|
95
102
|
|
|
96
103
|
/**
|
|
97
104
|
* Advanced selection is no longer mobile-only (#212): the escalation engine is
|
|
98
|
-
* opened to
|
|
99
|
-
* offer → counting → escalated → progress → notice states
|
|
100
|
-
* carries, from one shared set of derivations so the two surfaces never drift.
|
|
105
|
+
* opened to every width, and the one selection bar renders the
|
|
106
|
+
* offer → counting → escalated → progress → notice states.
|
|
101
107
|
*/
|
|
102
108
|
describe("MessageList escalation reaches desktop (#212)", () => {
|
|
103
109
|
it("no longer gates the escalation engine on the mobile viewport", () => {
|
|
@@ -111,39 +117,38 @@ describe("MessageList escalation reaches desktop (#212)", () => {
|
|
|
111
117
|
);
|
|
112
118
|
});
|
|
113
119
|
|
|
114
|
-
it("feeds the
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
assert.match(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
assert.match(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
);
|
|
120
|
+
it("feeds the bar the shared escalation state", () => {
|
|
121
|
+
// Up to the bar's own closing tag, at its own indent — a nested slot
|
|
122
|
+
// element closes first and would cut the match short.
|
|
123
|
+
const bar = source.match(/<SelectionTopBar[\s\S]*?\n\t\t\/>/)?.[0] ?? "";
|
|
124
|
+
assert.match(bar, /statusLabel=\{selectionStatusLabel\}/);
|
|
125
|
+
assert.match(bar, /notice=\{selectionNotice\}/);
|
|
126
|
+
assert.match(bar, /progress=\{selectionProgress\}/);
|
|
127
|
+
assert.match(bar, /selectAll=\{selectionSelectAll\}/);
|
|
128
|
+
assert.match(bar, /isCounting=\{escalation\.phase\.kind === "counting"\}/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("raises one selection surface, at every width (#480)", () => {
|
|
132
|
+
const bars = source.match(/<SelectionTopBar\b/g) ?? [];
|
|
133
|
+
assert.equal(bars.length, 1, "one bar, so no two surfaces can drift");
|
|
134
|
+
assert.doesNotMatch(source, /<SelectionSheet\b/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("mounts that surface unconditionally — it is the list header", () => {
|
|
138
|
+
// Gated on a selection, the bar's zero-ticked state (the mailbox name,
|
|
139
|
+
// and select-all inline from 768px up) reaches nothing.
|
|
140
|
+
assert.match(source, /const activeSelectionBar = \(\s*<SelectionTopBar/);
|
|
141
|
+
assert.match(source, /title=\{listHeaderChrome\.title \|\| listTitle\}/);
|
|
142
|
+
assert.match(source, /navSlot=\{listHeaderChrome\.navSlot\}/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("keeps the free-text organize entry reachable, in the overflow", () => {
|
|
146
|
+
assert.match(source, /onSomethingElse=\{/);
|
|
147
|
+
assert.match(source, /setMobileOrganizeEntry\("something-else"\)/);
|
|
129
148
|
});
|
|
130
149
|
|
|
131
|
-
it("
|
|
132
|
-
|
|
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
|
-
}
|
|
150
|
+
it("offers the label picker only when the account has labels", () => {
|
|
151
|
+
assert.match(source, /labels\.length > 0 \? \(/);
|
|
147
152
|
});
|
|
148
153
|
});
|
|
149
154
|
|