@remit/ui 0.0.78 → 0.0.80
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/app-shell-types.ts +9 -0
- package/src/components/app-shell.tsx +1 -2
- package/src/components/app-top-bar.stories.tsx +48 -151
- package/src/components/brief-sections.tsx +38 -8
- package/src/components/filter-sheet.tsx +36 -8
- package/src/components/message-list-pane.brief-filter.test.ts +185 -0
- package/src/components/message-list-pane.render.test.ts +0 -1
- package/src/components/message-list-pane.stories.tsx +0 -1
- package/src/components/message-list-pane.tsx +11 -7
- package/src/components/shell-top-bar.render.test.ts +95 -0
- package/src/components/shell-top-bar.stories.tsx +132 -0
- package/src/components/shell-top-bar.tsx +127 -0
- package/src/index.ts +12 -0
package/package.json
CHANGED
|
@@ -213,6 +213,15 @@ export const briefCategories: ReadonlyArray<{
|
|
|
213
213
|
{ id: "social", label: "Social" },
|
|
214
214
|
];
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Whether an id names one of the brief's category scopes. A host holding one
|
|
218
|
+
* category across views whose sheets speak plain strings narrows it with this
|
|
219
|
+
* rather than asserting it.
|
|
220
|
+
*/
|
|
221
|
+
export function isBriefCategory(id: string): id is BriefCategoryFilter {
|
|
222
|
+
return briefCategories.some((c) => c.id === id);
|
|
223
|
+
}
|
|
224
|
+
|
|
216
225
|
export interface ThreadRowLabel {
|
|
217
226
|
labelId: string;
|
|
218
227
|
name: string;
|
|
@@ -244,11 +244,10 @@ function AppShellList({
|
|
|
244
244
|
searchQuery={searchQuery}
|
|
245
245
|
onRetry={onRetry}
|
|
246
246
|
onReportError={onReportError}
|
|
247
|
-
|
|
247
|
+
briefFilter={{ briefCategory, onSelectBriefCategory }}
|
|
248
248
|
selectedThreadId={selectedThreadId}
|
|
249
249
|
density={density}
|
|
250
250
|
onSelectThread={onSelectThread}
|
|
251
|
-
onSelectBriefCategory={onSelectBriefCategory}
|
|
252
251
|
onOpenNav={showNavPane ? undefined : layout?.openNav}
|
|
253
252
|
isDesktop={showReadingPane}
|
|
254
253
|
initialTouchState={initialTouchState}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Bug, SquarePen } from "lucide-react";
|
|
3
2
|
import { useState } from "react";
|
|
4
3
|
import { AppTopBar } from "./app-top-bar.js";
|
|
5
|
-
import {
|
|
6
|
-
import { Button } from "./button.js";
|
|
7
|
-
import { type SearchChip, SearchChipInput } from "./search-chip-input.js";
|
|
4
|
+
import { SearchChipInput } from "./search-chip-input.js";
|
|
8
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The bar's geometry. What fills it — which actions, in what order, with what
|
|
8
|
+
* wording — is `ShellTopBar`, which is what the app and the shell prototype
|
|
9
|
+
* both mount; these stories show only the row the slots sit in.
|
|
10
|
+
*/
|
|
9
11
|
const meta: Meta<typeof AppTopBar> = {
|
|
10
12
|
title: "Mail/AppTopBar",
|
|
11
13
|
component: AppTopBar,
|
|
@@ -15,169 +17,64 @@ export default meta;
|
|
|
15
17
|
|
|
16
18
|
type Story = StoryObj<typeof AppTopBar>;
|
|
17
19
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
size="sm"
|
|
23
|
-
icon={<SquarePen className="size-4" />}
|
|
24
|
-
title="Compose"
|
|
25
|
-
aria-label="Compose"
|
|
26
|
-
/>
|
|
27
|
-
<Button
|
|
28
|
-
variant="ghost"
|
|
29
|
-
size="sm"
|
|
30
|
-
icon={<Bug className="size-4" />}
|
|
31
|
-
title="Report a bug"
|
|
32
|
-
aria-label="Report a bug"
|
|
33
|
-
/>
|
|
34
|
-
<Avatar name="Matthijs van Henten" email="mvh@example.com" size="sm" />
|
|
35
|
-
</>
|
|
20
|
+
const Slot = ({ label }: { label: string }) => (
|
|
21
|
+
<div className="rounded border border-dashed border-line px-2 py-1 text-2xs text-fg-subtle">
|
|
22
|
+
{label}
|
|
23
|
+
</div>
|
|
36
24
|
);
|
|
37
25
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The placeholders the app pairs with each scope state. Only the unscoped brief
|
|
42
|
-
* may claim to search all mail; a scoped view says so, and a mailbox route
|
|
43
|
-
* whose name has not loaded yet gets neutral wording rather than a placeholder
|
|
44
|
-
* asserting the wrong scope.
|
|
45
|
-
*/
|
|
46
|
-
const PLACEHOLDER = {
|
|
47
|
-
global: "Search all mail",
|
|
48
|
-
pending: "Search mail",
|
|
49
|
-
scoped: "Search this folder",
|
|
50
|
-
} as const;
|
|
51
|
-
|
|
52
|
-
const Bar = ({
|
|
53
|
-
initialChips = [],
|
|
54
|
-
placeholder = PLACEHOLDER.global,
|
|
55
|
-
}: {
|
|
56
|
-
initialChips?: SearchChip[];
|
|
57
|
-
placeholder?: string;
|
|
58
|
-
}) => {
|
|
59
|
-
const [chips, setChips] = useState<SearchChip[]>(initialChips);
|
|
26
|
+
const Field = () => {
|
|
60
27
|
const [value, setValue] = useState("");
|
|
61
28
|
return (
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
onChange={setValue}
|
|
71
|
-
onClear={() => {
|
|
72
|
-
setValue("");
|
|
73
|
-
setChips([]);
|
|
74
|
-
}}
|
|
75
|
-
onClearQuery={() => setValue("")}
|
|
76
|
-
globalFocusKey={false}
|
|
77
|
-
placeholder={placeholder}
|
|
78
|
-
/>
|
|
79
|
-
}
|
|
29
|
+
<SearchChipInput
|
|
30
|
+
size="lg"
|
|
31
|
+
value={value}
|
|
32
|
+
onChange={setValue}
|
|
33
|
+
onClear={() => setValue("")}
|
|
34
|
+
onClearQuery={() => setValue("")}
|
|
35
|
+
globalFocusKey={false}
|
|
36
|
+
placeholder="Search all mail"
|
|
80
37
|
/>
|
|
81
38
|
);
|
|
82
39
|
};
|
|
83
40
|
|
|
84
|
-
/**
|
|
85
|
-
const
|
|
86
|
-
<div className="flex h-96 flex-col bg-canvas">
|
|
87
|
-
{children}
|
|
88
|
-
<div className="flex min-h-0 flex-1">
|
|
89
|
-
<div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
|
|
90
|
-
Nav — under the bar, like every other pane
|
|
91
|
-
</div>
|
|
92
|
-
<div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
|
|
93
|
-
Message list
|
|
94
|
-
</div>
|
|
95
|
-
<div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
|
|
96
|
-
Message pane — its own toolbar lives here, under the bar
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
</div>
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* The daily brief's state: search unscoped, nothing narrowing it. No chip, and
|
|
104
|
-
* the only placeholder allowed to claim it searches all mail — which it now
|
|
105
|
-
* genuinely does, across every folder of every account.
|
|
106
|
-
*/
|
|
107
|
-
export const Unscoped: Story = {
|
|
108
|
-
render: () => <Bar />,
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* A narrowing scope in the bar, tinted to mark it as the view the user is in
|
|
113
|
-
* rather than a filter they typed. Removing it widens the search again — a
|
|
114
|
-
* navigation back to the brief, not an edit of the text, because the chip
|
|
115
|
-
* mirrors the route.
|
|
116
|
-
*
|
|
117
|
-
* The placeholder narrows with the chip. A scoped bar reading "Search all mail"
|
|
118
|
-
* is a state the app never produces.
|
|
119
|
-
*/
|
|
120
|
-
export const Scoped: Story = {
|
|
121
|
-
render: () => <Bar initialChips={[SCOPE]} placeholder={PLACEHOLDER.scoped} />,
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* The third scope state: a mailbox route whose name has not resolved yet. The
|
|
126
|
-
* list underneath is already narrowed, so the bar must not claim to search
|
|
127
|
-
* everything — but a chip reading a raw uuid is worse than no chip, so it shows
|
|
128
|
-
* none and falls back to neutral wording until the name arrives.
|
|
129
|
-
*/
|
|
130
|
-
export const ScopePending: Story = {
|
|
131
|
-
render: () => <Bar placeholder={PLACEHOLDER.pending} />,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The virtual collections scope the bar too, and their chips read as whatever
|
|
136
|
-
* describes the collection. Flagged is a marker on the mail rather than a
|
|
137
|
-
* place, so it chips `is:starred`; the outbox is a place mail sits in and keeps
|
|
138
|
-
* the `in:` form.
|
|
139
|
-
*/
|
|
140
|
-
export const ScopedToFlagged: Story = {
|
|
41
|
+
/** Leading · search · actions. The field is the only slot that grows. */
|
|
42
|
+
export const Slots: Story = {
|
|
141
43
|
render: () => (
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
44
|
+
<AppTopBar
|
|
45
|
+
leading={<Slot label="leading" />}
|
|
46
|
+
search={<Field />}
|
|
47
|
+
actions={<Slot label="actions" />}
|
|
145
48
|
/>
|
|
146
49
|
),
|
|
147
50
|
};
|
|
148
51
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
initialChips={[{ id: "in:outbox", label: "in:outbox", tone: "scope" }]}
|
|
153
|
-
placeholder={PLACEHOLDER.scoped}
|
|
154
|
-
/>
|
|
155
|
-
),
|
|
52
|
+
/** With nothing but the field, the bar is still the page's one search surface. */
|
|
53
|
+
export const SearchOnly: Story = {
|
|
54
|
+
render: () => <AppTopBar search={<Field />} />,
|
|
156
55
|
};
|
|
157
56
|
|
|
158
|
-
/**
|
|
159
|
-
*
|
|
57
|
+
/** One row across the top of the shell, over the nav, the list and the message
|
|
58
|
+
* pane alike. */
|
|
160
59
|
export const OverTheLayout: Story = {
|
|
161
60
|
render: () => (
|
|
162
|
-
<
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
/>
|
|
61
|
+
<div className="flex h-96 flex-col bg-canvas">
|
|
62
|
+
<AppTopBar
|
|
63
|
+
leading={<Slot label="leading" />}
|
|
64
|
+
search={<Field />}
|
|
65
|
+
actions={<Slot label="actions" />}
|
|
66
|
+
/>
|
|
67
|
+
<div className="flex min-h-0 flex-1">
|
|
68
|
+
<div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
|
|
69
|
+
Nav — under the bar, like every other pane
|
|
70
|
+
</div>
|
|
71
|
+
<div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
|
|
72
|
+
Message list
|
|
73
|
+
</div>
|
|
74
|
+
<div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
|
|
75
|
+
Message pane — its own toolbar lives here, under the bar
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
182
79
|
),
|
|
183
80
|
};
|
|
@@ -56,6 +56,16 @@ export const briefFilterChips: FilterSheetFilter[] = briefFilterDefs.map(
|
|
|
56
56
|
({ id, label }) => ({ id, label }),
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Whether an id names one of the brief's attribute chips. A consumer holding
|
|
61
|
+
* one filter set across several views — the workbench shell, whose mailbox
|
|
62
|
+
* sheet offers chips of its own — narrows that set to the brief's own with
|
|
63
|
+
* this rather than asserting it.
|
|
64
|
+
*/
|
|
65
|
+
export function isBriefFilterId(id: string): id is BriefFilterId {
|
|
66
|
+
return briefFilterDefs.some((f) => f.id === id);
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
/**
|
|
60
70
|
* Whether a thread survives a set of attribute chips, as the brief's own list
|
|
61
71
|
* applies them. Exported so a consumer narrowing the same rows on another
|
|
@@ -77,7 +87,7 @@ export function matchesBriefFilters(
|
|
|
77
87
|
* search takeover) holds the set so both surfaces answer to one selection, and
|
|
78
88
|
* takes every control over it with the set.
|
|
79
89
|
*/
|
|
80
|
-
type BriefFilterControl =
|
|
90
|
+
export type BriefFilterControl =
|
|
81
91
|
| {
|
|
82
92
|
activeFilters: ReadonlySet<BriefFilterId>;
|
|
83
93
|
onToggleFilter: (id: BriefFilterId) => void;
|
|
@@ -89,13 +99,8 @@ type BriefFilterControl =
|
|
|
89
99
|
onClearFilters?: never;
|
|
90
100
|
};
|
|
91
101
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
briefCategory?: BriefCategoryFilter;
|
|
95
|
-
selectedThreadId?: string;
|
|
96
|
-
Row: BriefRowComponent;
|
|
97
|
-
onSelectThread?: (id: string) => void;
|
|
98
|
-
onSelectBriefCategory?: (category: BriefCategoryFilter) => void;
|
|
102
|
+
/** The accounts the aggregate is segmented by, as the FilterSheet draws them. */
|
|
103
|
+
export interface BriefSourceControl {
|
|
99
104
|
/**
|
|
100
105
|
* Account/source pills, passed straight through to the FilterSheet. Selection
|
|
101
106
|
* is encoded per source via `active`; the row only renders when more than one
|
|
@@ -106,6 +111,31 @@ interface BriefSectionsBaseProps {
|
|
|
106
111
|
sourcesNote?: string;
|
|
107
112
|
/** Called when the user selects a source/account pill. */
|
|
108
113
|
onSelectSource?: (id: string) => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The single category the sections are scoped to. */
|
|
117
|
+
export interface BriefCategoryControl {
|
|
118
|
+
briefCategory?: BriefCategoryFilter;
|
|
119
|
+
onSelectBriefCategory?: (category: BriefCategoryFilter) => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The brief's whole filter surface as a host holds it: the category scope, the
|
|
124
|
+
* account pills and the attribute chips. A host with a second surface over the
|
|
125
|
+
* same rows — the phone search takeover — holds this one set and hands it to
|
|
126
|
+
* both, so a filter set on either is set on both.
|
|
127
|
+
*/
|
|
128
|
+
export type BriefFilterSurface = BriefCategoryControl &
|
|
129
|
+
BriefSourceControl &
|
|
130
|
+
BriefFilterControl;
|
|
131
|
+
|
|
132
|
+
interface BriefSectionsBaseProps
|
|
133
|
+
extends BriefCategoryControl,
|
|
134
|
+
BriefSourceControl {
|
|
135
|
+
sections: ThreadSection[];
|
|
136
|
+
selectedThreadId?: string;
|
|
137
|
+
Row: BriefRowComponent;
|
|
138
|
+
onSelectThread?: (id: string) => void;
|
|
109
139
|
/**
|
|
110
140
|
* Drop the filter row and its panel, keeping the rows where they are. See
|
|
111
141
|
* `FilterSheetProps`.
|
|
@@ -138,19 +138,35 @@ const FilterPanelCtx = createContext<FilterPanelState | null>(null);
|
|
|
138
138
|
* body is sometimes something else — a skeleton, an empty state, an error —
|
|
139
139
|
* would otherwise inherit a caret over nothing by saying nothing. Compute it
|
|
140
140
|
* from what the body renders, in the same pass that renders it.
|
|
141
|
+
*
|
|
142
|
+
* `open`/`onOpenChange` hand the panel's state to the host, for a view whose
|
|
143
|
+
* second surface — the phone search takeover — replaces this provider while it
|
|
144
|
+
* is up. Omit both and the provider keeps the state itself.
|
|
141
145
|
*/
|
|
142
146
|
export function FilterPanelProvider({
|
|
143
147
|
children,
|
|
144
148
|
hasSheet,
|
|
149
|
+
open: openProp,
|
|
150
|
+
onOpenChange,
|
|
145
151
|
}: {
|
|
146
152
|
children?: ReactNode;
|
|
147
153
|
hasSheet: boolean;
|
|
154
|
+
open?: boolean;
|
|
155
|
+
onOpenChange?: (open: boolean) => void;
|
|
148
156
|
}) {
|
|
149
|
-
const [
|
|
157
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
150
158
|
const [active, setActive] = useState(false);
|
|
159
|
+
const open = openProp ?? internalOpen;
|
|
160
|
+
const setOpen = useCallback(
|
|
161
|
+
(next: boolean) => {
|
|
162
|
+
if (openProp === undefined) setInternalOpen(next);
|
|
163
|
+
onOpenChange?.(next);
|
|
164
|
+
},
|
|
165
|
+
[openProp, onOpenChange],
|
|
166
|
+
);
|
|
151
167
|
const value = useMemo(
|
|
152
168
|
() => ({ open, setOpen, active, setActive, hasSheet }),
|
|
153
|
-
[open, active, hasSheet],
|
|
169
|
+
[open, setOpen, active, hasSheet],
|
|
154
170
|
);
|
|
155
171
|
return (
|
|
156
172
|
<FilterPanelCtx.Provider value={value}>{children}</FilterPanelCtx.Provider>
|
|
@@ -296,11 +312,15 @@ export function FilterSheet({
|
|
|
296
312
|
);
|
|
297
313
|
|
|
298
314
|
const sourceRow = sources && sources.length > 1 && (
|
|
299
|
-
<
|
|
315
|
+
<fieldset
|
|
316
|
+
aria-label="Accounts"
|
|
317
|
+
className="min-w-0 flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-2 pt-2"
|
|
318
|
+
>
|
|
300
319
|
{sources.map((source) => (
|
|
301
320
|
<button
|
|
302
321
|
key={source.id}
|
|
303
322
|
type="button"
|
|
323
|
+
aria-pressed={Boolean(source.active)}
|
|
304
324
|
onClick={() => onSelectSource?.(source.id)}
|
|
305
325
|
className={cn(
|
|
306
326
|
"flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-2xs transition-colors",
|
|
@@ -320,17 +340,21 @@ export function FilterSheet({
|
|
|
320
340
|
{sourcesNote}
|
|
321
341
|
</span>
|
|
322
342
|
)}
|
|
323
|
-
</
|
|
343
|
+
</fieldset>
|
|
324
344
|
);
|
|
325
345
|
|
|
326
346
|
const categoryRow = (
|
|
327
|
-
<
|
|
347
|
+
<fieldset
|
|
348
|
+
aria-label="Categories"
|
|
349
|
+
className="min-w-0 flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-1.5 pt-1.5"
|
|
350
|
+
>
|
|
328
351
|
{categories.map((cat) => {
|
|
329
352
|
const selected = selectedCategory === cat.id;
|
|
330
353
|
return (
|
|
331
354
|
<button
|
|
332
355
|
key={cat.id}
|
|
333
356
|
type="button"
|
|
357
|
+
aria-pressed={selected}
|
|
334
358
|
onClick={() => onSelectCategory(cat.id)}
|
|
335
359
|
className={cn(
|
|
336
360
|
"shrink-0 rounded-full transition-opacity",
|
|
@@ -343,17 +367,21 @@ export function FilterSheet({
|
|
|
343
367
|
</button>
|
|
344
368
|
);
|
|
345
369
|
})}
|
|
346
|
-
</
|
|
370
|
+
</fieldset>
|
|
347
371
|
);
|
|
348
372
|
|
|
349
373
|
const filterRow = (
|
|
350
|
-
<
|
|
374
|
+
<fieldset
|
|
375
|
+
aria-label="Attributes"
|
|
376
|
+
className="min-w-0 flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-2 pt-1.5"
|
|
377
|
+
>
|
|
351
378
|
{filters.map((f) => {
|
|
352
379
|
const on = activeFilters.has(f.id);
|
|
353
380
|
return (
|
|
354
381
|
<button
|
|
355
382
|
key={f.id}
|
|
356
383
|
type="button"
|
|
384
|
+
aria-pressed={on}
|
|
357
385
|
onClick={() => onToggleFilter(f.id)}
|
|
358
386
|
className={cn(
|
|
359
387
|
"shrink-0 rounded-full border px-2.5 py-0.5 text-2xs transition-colors",
|
|
@@ -376,7 +404,7 @@ export function FilterSheet({
|
|
|
376
404
|
Clear
|
|
377
405
|
</button>
|
|
378
406
|
)}
|
|
379
|
-
</
|
|
407
|
+
</fieldset>
|
|
380
408
|
);
|
|
381
409
|
|
|
382
410
|
return (
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cross-account brief's account pills reach the panel through the pane that
|
|
3
|
+
* renders the brief, not only through `BriefSections` mounted on its own. The
|
|
4
|
+
* caret has to be pressed for the panel to be up, so this mounts against jsdom
|
|
5
|
+
* rather than `renderToString`.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
9
|
+
import type { JSDOM } from "jsdom";
|
|
10
|
+
import { act, createElement } from "react";
|
|
11
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
12
|
+
import type { ThreadSection } from "./app-shell-types.js";
|
|
13
|
+
import type { BriefFilterSurface } from "./brief-sections.js";
|
|
14
|
+
import { MessageListPane } from "./message-list-pane.js";
|
|
15
|
+
|
|
16
|
+
const sections: ThreadSection[] = [
|
|
17
|
+
{
|
|
18
|
+
id: "personal",
|
|
19
|
+
label: "Personal",
|
|
20
|
+
threads: [
|
|
21
|
+
{
|
|
22
|
+
id: "t1",
|
|
23
|
+
accountId: "acc_personal",
|
|
24
|
+
fromName: "Priya Nair",
|
|
25
|
+
fromEmail: "priya@example.com",
|
|
26
|
+
subject: "Design review tomorrow",
|
|
27
|
+
snippet: "Can we move it to 2pm?",
|
|
28
|
+
timeLabel: "8:15",
|
|
29
|
+
isRead: false,
|
|
30
|
+
category: "personal",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const sources = [
|
|
37
|
+
{ id: "all", label: "All", active: true },
|
|
38
|
+
{ id: "acc_personal", label: "Personal", count: 4 },
|
|
39
|
+
{ id: "acc_work", label: "Work", count: 7 },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
let dom: JSDOM;
|
|
43
|
+
let container: HTMLElement;
|
|
44
|
+
let root: Root;
|
|
45
|
+
|
|
46
|
+
before(async () => {
|
|
47
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
48
|
+
dom = new JSDOMCtor(
|
|
49
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
50
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
51
|
+
);
|
|
52
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
53
|
+
globalThis.document = dom.window.document;
|
|
54
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
55
|
+
globalThis.Element = dom.window.Element;
|
|
56
|
+
globalThis.MouseEvent = dom.window.MouseEvent;
|
|
57
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
58
|
+
value: dom.window.navigator,
|
|
59
|
+
configurable: true,
|
|
60
|
+
});
|
|
61
|
+
(
|
|
62
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
63
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
after(() => {
|
|
67
|
+
dom.window.close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
container = dom.window.document.getElementById(
|
|
72
|
+
"root",
|
|
73
|
+
) as unknown as HTMLElement;
|
|
74
|
+
container.innerHTML = "";
|
|
75
|
+
root = createRoot(container);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
act(() => {
|
|
80
|
+
root.unmount();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function mount(briefFilter?: BriefFilterSurface) {
|
|
85
|
+
act(() => {
|
|
86
|
+
root.render(
|
|
87
|
+
createElement(MessageListPane, {
|
|
88
|
+
listTitle: "Daily brief",
|
|
89
|
+
sections,
|
|
90
|
+
briefFilters: true,
|
|
91
|
+
isDesktop: true,
|
|
92
|
+
briefFilter,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function click(element: Element) {
|
|
99
|
+
act(() => {
|
|
100
|
+
element.dispatchEvent(
|
|
101
|
+
new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function openPanel() {
|
|
107
|
+
const caret = container.querySelector('[aria-label="Expand filters"]');
|
|
108
|
+
assert.ok(caret, "the pane offers a way to open the filter panel");
|
|
109
|
+
click(caret);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function labels(selector: string): string[] {
|
|
113
|
+
return Array.from(container.querySelectorAll(selector)).map((node) =>
|
|
114
|
+
(node.textContent ?? "").trim(),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("the brief's filter panel inside the message list pane", () => {
|
|
119
|
+
it("carries the account pills and the muted note it is given", () => {
|
|
120
|
+
mount({
|
|
121
|
+
sources,
|
|
122
|
+
sourcesNote: "+1 muted",
|
|
123
|
+
onSelectSource: () => undefined,
|
|
124
|
+
});
|
|
125
|
+
openPanel();
|
|
126
|
+
const pills = labels("button");
|
|
127
|
+
assert.ok(
|
|
128
|
+
pills.includes("Personal4"),
|
|
129
|
+
"a pill per account, with its count",
|
|
130
|
+
);
|
|
131
|
+
assert.ok(pills.includes("Work7"));
|
|
132
|
+
assert.match(container.textContent ?? "", /\+1 muted/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("reports the pill the user picks", () => {
|
|
136
|
+
let picked: string | undefined;
|
|
137
|
+
mount({
|
|
138
|
+
sources,
|
|
139
|
+
onSelectSource: (id) => {
|
|
140
|
+
picked = id;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
openPanel();
|
|
144
|
+
const work = Array.from(container.querySelectorAll("button")).find(
|
|
145
|
+
(node) => (node.textContent ?? "").trim() === "Work7",
|
|
146
|
+
);
|
|
147
|
+
assert.ok(work);
|
|
148
|
+
click(work);
|
|
149
|
+
assert.equal(picked, "acc_work");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("offers no source row without pills", () => {
|
|
153
|
+
mount();
|
|
154
|
+
openPanel();
|
|
155
|
+
assert.doesNotMatch(container.textContent ?? "", /Work/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("draws the chips the caller holds and reports a toggle", () => {
|
|
159
|
+
let toggled: string | undefined;
|
|
160
|
+
mount({
|
|
161
|
+
sources,
|
|
162
|
+
activeFilters: new Set(["unread"]),
|
|
163
|
+
onToggleFilter: (id) => {
|
|
164
|
+
toggled = id;
|
|
165
|
+
},
|
|
166
|
+
onClearFilters: () => undefined,
|
|
167
|
+
});
|
|
168
|
+
openPanel();
|
|
169
|
+
const unread = Array.from(container.querySelectorAll("button")).find(
|
|
170
|
+
(node) => (node.textContent ?? "").trim() === "Unread",
|
|
171
|
+
);
|
|
172
|
+
assert.ok(unread);
|
|
173
|
+
assert.equal(
|
|
174
|
+
unread.getAttribute("aria-pressed"),
|
|
175
|
+
"true",
|
|
176
|
+
"the held chip reads as active",
|
|
177
|
+
);
|
|
178
|
+
const attachment = Array.from(container.querySelectorAll("button")).find(
|
|
179
|
+
(node) => (node.textContent ?? "").trim() === "Has attachment",
|
|
180
|
+
);
|
|
181
|
+
assert.ok(attachment);
|
|
182
|
+
click(attachment);
|
|
183
|
+
assert.equal(toggled, "attachment");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
MessageListSelection,
|
|
9
9
|
TouchSeed,
|
|
10
10
|
} from "./app-shell-types.js";
|
|
11
|
-
import { BriefSections } from "./brief-sections.js";
|
|
11
|
+
import { type BriefFilterSurface, BriefSections } from "./brief-sections.js";
|
|
12
12
|
import { Button } from "./button.js";
|
|
13
13
|
import { KeyboardHintBar } from "./keyboard-hint-bar.js";
|
|
14
14
|
import {
|
|
@@ -46,11 +46,10 @@ export function MessageListPane({
|
|
|
46
46
|
errorMessage,
|
|
47
47
|
onRetry,
|
|
48
48
|
onReportError,
|
|
49
|
-
|
|
49
|
+
briefFilter,
|
|
50
50
|
selectedThreadId,
|
|
51
51
|
density = "comfortable",
|
|
52
52
|
onSelectThread,
|
|
53
|
-
onSelectBriefCategory,
|
|
54
53
|
onOpenNav,
|
|
55
54
|
isDesktop,
|
|
56
55
|
initialTouchState,
|
|
@@ -71,11 +70,9 @@ export function MessageListPane({
|
|
|
71
70
|
| "errorMessage"
|
|
72
71
|
| "onRetry"
|
|
73
72
|
| "onReportError"
|
|
74
|
-
| "briefCategory"
|
|
75
73
|
| "selectedThreadId"
|
|
76
74
|
| "density"
|
|
77
75
|
| "onSelectThread"
|
|
78
|
-
| "onSelectBriefCategory"
|
|
79
76
|
> & {
|
|
80
77
|
/**
|
|
81
78
|
* The active category filter, when the caller has one. Without it the empty
|
|
@@ -84,6 +81,14 @@ export function MessageListPane({
|
|
|
84
81
|
* unfiltered, so a surface that filters must pass this.
|
|
85
82
|
*/
|
|
86
83
|
listFilter?: MessageListFilter;
|
|
84
|
+
/**
|
|
85
|
+
* The brief's category scope, account pills and attribute chips, held by the
|
|
86
|
+
* caller. The cross-account brief is segmented from this panel, and a caller
|
|
87
|
+
* narrowing the same rows on a second surface hands both the one set it
|
|
88
|
+
* holds. Absent, the chips are the brief's own, no source row is offered and
|
|
89
|
+
* every category is in scope.
|
|
90
|
+
*/
|
|
91
|
+
briefFilter?: BriefFilterSurface;
|
|
87
92
|
/** Name of the collection, e.g. "Inbox". Passed to the empty state. */
|
|
88
93
|
listScopeLabel?: string;
|
|
89
94
|
/** When set, the list header shows a folders/menu button that opens the nav
|
|
@@ -250,12 +255,11 @@ export function MessageListPane({
|
|
|
250
255
|
/>
|
|
251
256
|
) : briefFilters ? (
|
|
252
257
|
<BriefSections
|
|
258
|
+
{...(briefFilter ?? {})}
|
|
253
259
|
sections={sections}
|
|
254
|
-
briefCategory={briefCategory}
|
|
255
260
|
selectedThreadId={selectedThreadId}
|
|
256
261
|
Row={BriefRow}
|
|
257
262
|
onSelectThread={onSelectThread}
|
|
258
|
-
onSelectBriefCategory={onSelectBriefCategory}
|
|
259
263
|
/>
|
|
260
264
|
) : listBody != null ? (
|
|
261
265
|
/* Consumer-provided body wins on every width — it owns the rows
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import {
|
|
6
|
+
ShellTopBar,
|
|
7
|
+
type ShellTopBarProps,
|
|
8
|
+
type ShellTopBarSearch,
|
|
9
|
+
} from "./shell-top-bar.js";
|
|
10
|
+
|
|
11
|
+
const search = (
|
|
12
|
+
overrides: Partial<ShellTopBarSearch> = {},
|
|
13
|
+
): ShellTopBarSearch => ({
|
|
14
|
+
value: "",
|
|
15
|
+
scope: "global",
|
|
16
|
+
onChange: () => undefined,
|
|
17
|
+
onClear: () => undefined,
|
|
18
|
+
onClearQuery: () => undefined,
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const render = (overrides: Partial<ShellTopBarProps> = {}): string =>
|
|
23
|
+
renderToString(
|
|
24
|
+
createElement(ShellTopBar, {
|
|
25
|
+
search: search(),
|
|
26
|
+
onCompose: () => undefined,
|
|
27
|
+
onReportBug: () => undefined,
|
|
28
|
+
onOpenSettings: () => undefined,
|
|
29
|
+
account: createElement("div", { "data-testid": "account" }, "account"),
|
|
30
|
+
...overrides,
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
describe("ShellTopBar", () => {
|
|
35
|
+
it("carries compose, bug report, settings and the account, in that order", () => {
|
|
36
|
+
const html = render();
|
|
37
|
+
const compose = html.indexOf('aria-label="Compose"');
|
|
38
|
+
const bug = html.indexOf('aria-label="Report a bug"');
|
|
39
|
+
const settings = html.indexOf('aria-label="Settings"');
|
|
40
|
+
const account = html.indexOf('data-testid="account"');
|
|
41
|
+
assert.ok(compose > -1 && bug > -1 && settings > -1 && account > -1);
|
|
42
|
+
assert.ok(
|
|
43
|
+
compose < bug && bug < settings && settings < account,
|
|
44
|
+
"actions render in reading order",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("carries exactly the four actions and nothing else", () => {
|
|
49
|
+
const labels = [...render().matchAll(/aria-label="([^"]+)"/g)].map(
|
|
50
|
+
(match) => match[1],
|
|
51
|
+
);
|
|
52
|
+
assert.deepEqual(labels, [
|
|
53
|
+
"Search mail",
|
|
54
|
+
"Compose",
|
|
55
|
+
"Report a bug",
|
|
56
|
+
"Settings",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("words the compose tooltip around the host's bare key", () => {
|
|
61
|
+
assert.match(render({ composeShortcut: "c" }), /title="Compose \(c\)"/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("names the action alone when the host binds no key to it", () => {
|
|
65
|
+
assert.match(render(), /title="Compose"/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("only claims to search all mail when nothing narrows it", () => {
|
|
69
|
+
assert.match(render(), /placeholder="Search all mail"/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("says which folder a scoped view searches", () => {
|
|
73
|
+
assert.match(
|
|
74
|
+
render({ search: search({ scope: "scoped" }) }),
|
|
75
|
+
/placeholder="Search this folder"/,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("carries the route's scope as a chip and drops the claim to search all mail", () => {
|
|
80
|
+
const html = render({
|
|
81
|
+
search: search({
|
|
82
|
+
scope: "scoped",
|
|
83
|
+
chips: [{ id: "in:spam", label: "in:spam", tone: "scope" }],
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
assert.match(html, /in:spam/);
|
|
87
|
+
assert.doesNotMatch(html, /Search all mail/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("falls back to neutral wording while a mailbox name is still loading", () => {
|
|
91
|
+
const html = render({ search: search({ scope: "pending" }) });
|
|
92
|
+
assert.match(html, /placeholder="Search mail"/);
|
|
93
|
+
assert.doesNotMatch(html, /Search all mail/);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Avatar } from "./avatar.js";
|
|
4
|
+
import type { SearchChip } from "./search-chip-input.js";
|
|
5
|
+
import { type ShellSearchScope, ShellTopBar } from "./shell-top-bar.js";
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof ShellTopBar> = {
|
|
8
|
+
title: "Mail/ShellTopBar",
|
|
9
|
+
component: ShellTopBar,
|
|
10
|
+
parameters: { layout: "fullscreen" },
|
|
11
|
+
};
|
|
12
|
+
export default meta;
|
|
13
|
+
|
|
14
|
+
type Story = StoryObj<typeof ShellTopBar>;
|
|
15
|
+
|
|
16
|
+
const SCOPE: SearchChip = { id: "in:spam", label: "in:spam", tone: "scope" };
|
|
17
|
+
|
|
18
|
+
const Account = () => (
|
|
19
|
+
<button type="button" aria-label="Account">
|
|
20
|
+
<Avatar name="Matthijs van Henten" email="mvh@example.com" size="sm" />
|
|
21
|
+
</button>
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const Bar = ({
|
|
25
|
+
initialChips,
|
|
26
|
+
scope = "global",
|
|
27
|
+
}: {
|
|
28
|
+
initialChips?: SearchChip[];
|
|
29
|
+
scope?: ShellSearchScope;
|
|
30
|
+
}) => {
|
|
31
|
+
const [chips, setChips] = useState<SearchChip[] | undefined>(initialChips);
|
|
32
|
+
const [value, setValue] = useState("");
|
|
33
|
+
return (
|
|
34
|
+
<ShellTopBar
|
|
35
|
+
search={{
|
|
36
|
+
value,
|
|
37
|
+
scope: chips?.length ? scope : "global",
|
|
38
|
+
chips,
|
|
39
|
+
onChange: setValue,
|
|
40
|
+
onClear: () => {
|
|
41
|
+
setValue("");
|
|
42
|
+
setChips(undefined);
|
|
43
|
+
},
|
|
44
|
+
onClearQuery: () => setValue(""),
|
|
45
|
+
onRemoveChip: () => setChips(undefined),
|
|
46
|
+
}}
|
|
47
|
+
onCompose={() => undefined}
|
|
48
|
+
onReportBug={() => undefined}
|
|
49
|
+
onOpenSettings={() => undefined}
|
|
50
|
+
composeShortcut="c"
|
|
51
|
+
account={<Account />}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The daily brief's state: search unscoped, nothing narrowing it, and the only
|
|
58
|
+
* placeholder allowed to claim it searches all mail — which it genuinely does,
|
|
59
|
+
* across every folder of every account.
|
|
60
|
+
*/
|
|
61
|
+
export const Unscoped: Story = {
|
|
62
|
+
render: () => <Bar />,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A narrowing scope in the bar, tinted to mark it as the view the user is in
|
|
67
|
+
* rather than a filter they typed. Removing it widens the search again — a
|
|
68
|
+
* navigation back to the brief, not an edit of the text, because the chip
|
|
69
|
+
* mirrors the route. The placeholder narrows with the chip.
|
|
70
|
+
*/
|
|
71
|
+
export const Scoped: Story = {
|
|
72
|
+
render: () => <Bar initialChips={[SCOPE]} scope="scoped" />,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A mailbox route whose name has not resolved yet. The list underneath is
|
|
77
|
+
* already narrowed, so the bar must not claim to search everything — and a chip
|
|
78
|
+
* reading a raw uuid is worse than no chip, so it shows none and falls back to
|
|
79
|
+
* neutral wording until the name arrives.
|
|
80
|
+
*/
|
|
81
|
+
export const ScopePending: Story = {
|
|
82
|
+
render: () => (
|
|
83
|
+
<ShellTopBar
|
|
84
|
+
search={{
|
|
85
|
+
value: "",
|
|
86
|
+
scope: "pending",
|
|
87
|
+
onChange: () => undefined,
|
|
88
|
+
onClear: () => undefined,
|
|
89
|
+
onClearQuery: () => undefined,
|
|
90
|
+
}}
|
|
91
|
+
onCompose={() => undefined}
|
|
92
|
+
onReportBug={() => undefined}
|
|
93
|
+
onOpenSettings={() => undefined}
|
|
94
|
+
composeShortcut="c"
|
|
95
|
+
account={<Account />}
|
|
96
|
+
/>
|
|
97
|
+
),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The virtual collections scope the bar too, and their chips read as whatever
|
|
102
|
+
* describes the collection. Flagged is a marker on the mail rather than a
|
|
103
|
+
* place, so it chips `is:starred`.
|
|
104
|
+
*/
|
|
105
|
+
export const ScopedToFlagged: Story = {
|
|
106
|
+
render: () => (
|
|
107
|
+
<Bar
|
|
108
|
+
initialChips={[{ id: "is:starred", label: "is:starred", tone: "scope" }]}
|
|
109
|
+
scope="scoped"
|
|
110
|
+
/>
|
|
111
|
+
),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** The arrangement: one bar across the top of the shell, over every pane. */
|
|
115
|
+
export const OverTheLayout: Story = {
|
|
116
|
+
render: () => (
|
|
117
|
+
<div className="flex h-96 flex-col bg-canvas">
|
|
118
|
+
<Bar initialChips={[SCOPE]} scope="scoped" />
|
|
119
|
+
<div className="flex min-h-0 flex-1">
|
|
120
|
+
<div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
|
|
121
|
+
Nav — under the bar, like every other pane
|
|
122
|
+
</div>
|
|
123
|
+
<div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
|
|
124
|
+
Message list
|
|
125
|
+
</div>
|
|
126
|
+
<div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
|
|
127
|
+
Message pane — its own toolbar lives here, under the bar
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
),
|
|
132
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShellTopBar — the app's one search surface and its global actions, composed.
|
|
3
|
+
*
|
|
4
|
+
* `AppTopBar` is geometry; this is what fills it. It spans the whole layout —
|
|
5
|
+
* the nav column, the list, the reading pane and the intelligence rail — and
|
|
6
|
+
* carries the actions that belong to the app rather than to whatever is
|
|
7
|
+
* currently listed or open: the nav toggle, compose, bug report, settings,
|
|
8
|
+
* account. Reply, delete, move and the rest stay on the reading pane's own
|
|
9
|
+
* toolbar, under this bar.
|
|
10
|
+
*
|
|
11
|
+
* It is the app's search, not the list's: the list header drops its own field
|
|
12
|
+
* wherever this bar is mounted, so exactly one search input exists on the page
|
|
13
|
+
* and the "/" shortcut has one target.
|
|
14
|
+
*
|
|
15
|
+
* The field carries one chip: the scope of the view the user navigated into
|
|
16
|
+
* (`in:spam` in Spam, nothing on the brief). Removing it goes to the brief and
|
|
17
|
+
* searches everything. Typed `in:`/`from:` terms are not chipped here — they
|
|
18
|
+
* are already visible as the text the user typed, and chipping them would show
|
|
19
|
+
* the same term twice in one field; they render as chips over the result
|
|
20
|
+
* sections instead, where the text is not repeated.
|
|
21
|
+
*
|
|
22
|
+
* Which actions, in what order, with what wording lives here and only here, so
|
|
23
|
+
* the bar the prototype shows is the bar the app shows.
|
|
24
|
+
*/
|
|
25
|
+
import { Bug, Settings, SquarePen } from "lucide-react";
|
|
26
|
+
import type { ReactNode } from "react";
|
|
27
|
+
import { AppTopBar } from "./app-top-bar.js";
|
|
28
|
+
import { Button } from "./button.js";
|
|
29
|
+
import { NavToggleButton } from "./nav-toggle-button.js";
|
|
30
|
+
import { SearchBar } from "./search-bar.js";
|
|
31
|
+
import type { SearchChip } from "./search-chip-input.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How narrowed the search already is. Only the unscoped brief may claim to
|
|
35
|
+
* search all mail; a mailbox route whose name has not loaded yet is already
|
|
36
|
+
* narrowed but has no chip to show, so it gets neutral wording rather than a
|
|
37
|
+
* placeholder that asserts the wrong scope.
|
|
38
|
+
*/
|
|
39
|
+
export type ShellSearchScope = "global" | "pending" | "scoped";
|
|
40
|
+
|
|
41
|
+
const SEARCH_PLACEHOLDER: Record<ShellSearchScope, string> = {
|
|
42
|
+
global: "Search all mail",
|
|
43
|
+
pending: "Search mail",
|
|
44
|
+
scoped: "Search this folder",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export interface ShellTopBarSearch {
|
|
48
|
+
value: string;
|
|
49
|
+
scope: ShellSearchScope;
|
|
50
|
+
chips?: readonly SearchChip[];
|
|
51
|
+
onChange: (value: string) => void;
|
|
52
|
+
onClear: () => void;
|
|
53
|
+
onClearQuery: () => void;
|
|
54
|
+
onRemoveChip?: (id: string) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ShellTopBarProps {
|
|
58
|
+
search: ShellTopBarSearch;
|
|
59
|
+
onCompose: () => void;
|
|
60
|
+
onReportBug: () => void;
|
|
61
|
+
onOpenSettings: () => void;
|
|
62
|
+
/**
|
|
63
|
+
* The key that composes, as the host's keymap reads it — `c`, `⌘N`,
|
|
64
|
+
* `g then b`. The tooltip's wording around it is this component's.
|
|
65
|
+
*/
|
|
66
|
+
composeShortcut?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The account control at the bar's trailing edge. An element rather than
|
|
69
|
+
* data: the app hangs a signed-in session and its sign-out off it.
|
|
70
|
+
*/
|
|
71
|
+
account: ReactNode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function ShellTopBar({
|
|
75
|
+
search,
|
|
76
|
+
onCompose,
|
|
77
|
+
onReportBug,
|
|
78
|
+
onOpenSettings,
|
|
79
|
+
composeShortcut,
|
|
80
|
+
account,
|
|
81
|
+
}: ShellTopBarProps) {
|
|
82
|
+
return (
|
|
83
|
+
<AppTopBar
|
|
84
|
+
leading={<NavToggleButton />}
|
|
85
|
+
search={
|
|
86
|
+
<SearchBar
|
|
87
|
+
value={search.value}
|
|
88
|
+
onChange={search.onChange}
|
|
89
|
+
onClear={search.onClear}
|
|
90
|
+
onClearQuery={search.onClearQuery}
|
|
91
|
+
chips={search.chips}
|
|
92
|
+
onRemoveChip={search.onRemoveChip}
|
|
93
|
+
placeholder={SEARCH_PLACEHOLDER[search.scope]}
|
|
94
|
+
/>
|
|
95
|
+
}
|
|
96
|
+
actions={
|
|
97
|
+
<>
|
|
98
|
+
<Button
|
|
99
|
+
variant="ghost"
|
|
100
|
+
size="sm"
|
|
101
|
+
icon={<SquarePen className="size-4" />}
|
|
102
|
+
title={composeShortcut ? `Compose (${composeShortcut})` : "Compose"}
|
|
103
|
+
aria-label="Compose"
|
|
104
|
+
onClick={onCompose}
|
|
105
|
+
/>
|
|
106
|
+
<Button
|
|
107
|
+
variant="ghost"
|
|
108
|
+
size="sm"
|
|
109
|
+
icon={<Bug className="size-4" />}
|
|
110
|
+
title="Report a bug"
|
|
111
|
+
aria-label="Report a bug"
|
|
112
|
+
onClick={onReportBug}
|
|
113
|
+
/>
|
|
114
|
+
<Button
|
|
115
|
+
variant="ghost"
|
|
116
|
+
size="sm"
|
|
117
|
+
icon={<Settings className="size-4" />}
|
|
118
|
+
title="Settings"
|
|
119
|
+
aria-label="Settings"
|
|
120
|
+
onClick={onOpenSettings}
|
|
121
|
+
/>
|
|
122
|
+
{account}
|
|
123
|
+
</>
|
|
124
|
+
}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export {
|
|
|
29
29
|
categoryTone,
|
|
30
30
|
type Density,
|
|
31
31
|
INTELLIGENCE_MIN_WIDTH,
|
|
32
|
+
isBriefCategory,
|
|
32
33
|
type MessageListSelection,
|
|
33
34
|
type NarrowView,
|
|
34
35
|
type NavAccount,
|
|
@@ -87,9 +88,14 @@ export {
|
|
|
87
88
|
SECTION_ROW_CAP,
|
|
88
89
|
} from "./components/brief-section.js";
|
|
89
90
|
export {
|
|
91
|
+
type BriefCategoryControl,
|
|
92
|
+
type BriefFilterControl,
|
|
90
93
|
type BriefFilterId,
|
|
94
|
+
type BriefFilterSurface,
|
|
91
95
|
BriefSections,
|
|
92
96
|
type BriefSectionsProps,
|
|
97
|
+
type BriefSourceControl,
|
|
98
|
+
isBriefFilterId,
|
|
93
99
|
matchesBriefFilters,
|
|
94
100
|
} from "./components/brief-sections.js";
|
|
95
101
|
export {
|
|
@@ -582,6 +588,12 @@ export {
|
|
|
582
588
|
SettingsShell,
|
|
583
589
|
type SettingsShellProps,
|
|
584
590
|
} from "./components/settings-screen.js";
|
|
591
|
+
export {
|
|
592
|
+
type ShellSearchScope,
|
|
593
|
+
ShellTopBar,
|
|
594
|
+
type ShellTopBarProps,
|
|
595
|
+
type ShellTopBarSearch,
|
|
596
|
+
} from "./components/shell-top-bar.js";
|
|
585
597
|
export {
|
|
586
598
|
SlidePanel,
|
|
587
599
|
type SlidePanelProps,
|