@remit/ui 0.0.1
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 +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- package/src/tokens.css +217 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { inboxFilterConfig } from "../filter-presets.js";
|
|
4
|
+
import type { ThreadSection } from "./app-shell-types.js";
|
|
5
|
+
import { FilterSheet } from "./filter-sheet.js";
|
|
6
|
+
import { MailHeader } from "./mail-header.js";
|
|
7
|
+
import { MessageListPane } from "./message-list-pane.js";
|
|
8
|
+
import { SelectionTopBar } from "./selection-top-bar.js";
|
|
9
|
+
|
|
10
|
+
const sections: ThreadSection[] = [
|
|
11
|
+
{
|
|
12
|
+
id: "today",
|
|
13
|
+
label: "Today",
|
|
14
|
+
threads: [
|
|
15
|
+
{
|
|
16
|
+
id: "t1",
|
|
17
|
+
accountId: "a1",
|
|
18
|
+
fromName: "Alex Rivera",
|
|
19
|
+
fromEmail: "alex@example.com",
|
|
20
|
+
subject: "Q3 planning notes",
|
|
21
|
+
snippet: "Here are the notes from today's planning session.",
|
|
22
|
+
timeLabel: "9:42",
|
|
23
|
+
category: "personal",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "t2",
|
|
27
|
+
accountId: "a1",
|
|
28
|
+
fromName: "Acme Billing",
|
|
29
|
+
fromEmail: "billing@acme.com",
|
|
30
|
+
subject: "Your invoice is ready",
|
|
31
|
+
snippet: "Invoice #1042 is available to view.",
|
|
32
|
+
timeLabel: "8:15",
|
|
33
|
+
isRead: true,
|
|
34
|
+
category: "transactional",
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "earlier",
|
|
40
|
+
label: "Earlier",
|
|
41
|
+
threads: [
|
|
42
|
+
{
|
|
43
|
+
id: "t3",
|
|
44
|
+
accountId: "a1",
|
|
45
|
+
fromName: "Weekly Digest",
|
|
46
|
+
fromEmail: "news@digest.com",
|
|
47
|
+
subject: "This week in tech",
|
|
48
|
+
snippet: "The top stories you might have missed.",
|
|
49
|
+
timeLabel: "Mon",
|
|
50
|
+
category: "newsletter",
|
|
51
|
+
messageCount: 3,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const meta: Meta<typeof MessageListPane> = {
|
|
58
|
+
title: "Screens/Kit/MessageListPane",
|
|
59
|
+
component: MessageListPane,
|
|
60
|
+
parameters: { layout: "centered" },
|
|
61
|
+
args: {
|
|
62
|
+
listTitle: "Inbox",
|
|
63
|
+
listMeta: "3 conversations",
|
|
64
|
+
sections,
|
|
65
|
+
onSelectThread: () => undefined,
|
|
66
|
+
onSelectBriefCategory: () => undefined,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
export default meta;
|
|
70
|
+
|
|
71
|
+
type Story = StoryObj<typeof MessageListPane>;
|
|
72
|
+
|
|
73
|
+
const desktopFrame: Decorator = (Story) => (
|
|
74
|
+
<div className="h-screen w-96 overflow-hidden border border-line">
|
|
75
|
+
<Story />
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const narrowFrame: Decorator = (Story) => (
|
|
80
|
+
<div
|
|
81
|
+
className="overflow-hidden border border-line"
|
|
82
|
+
style={{ width: 390, height: 844 }}
|
|
83
|
+
>
|
|
84
|
+
<Story />
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
export const DesktopList: Story = {
|
|
89
|
+
args: { isDesktop: true, flatList: true },
|
|
90
|
+
decorators: [desktopFrame],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const NarrowTouchList: Story = {
|
|
94
|
+
args: { isDesktop: false, flatList: true },
|
|
95
|
+
decorators: [narrowFrame],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const Brief: Story = {
|
|
99
|
+
args: { isDesktop: true, briefFilters: true, sections },
|
|
100
|
+
decorators: [desktopFrame],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Inbox behind its filter: the MailHeader top row, then the FilterSheet bar
|
|
105
|
+
* whose caret opens the inbox preset — categories + Unread/Flagged/Has
|
|
106
|
+
* attachment. No accounts group: an inbox is already scoped to one account.
|
|
107
|
+
*/
|
|
108
|
+
function InboxScreen({
|
|
109
|
+
initialExpanded = false,
|
|
110
|
+
}: {
|
|
111
|
+
initialExpanded?: boolean;
|
|
112
|
+
}) {
|
|
113
|
+
const preset = inboxFilterConfig();
|
|
114
|
+
const [searchValue, setSearchValue] = useState("");
|
|
115
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
116
|
+
const [expanded, setExpanded] = useState(initialExpanded);
|
|
117
|
+
const [category, setCategory] = useState("all");
|
|
118
|
+
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
|
|
119
|
+
const rows = sections.flatMap((s) => s.threads);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex h-full flex-col">
|
|
123
|
+
<MailHeader
|
|
124
|
+
title="Inbox"
|
|
125
|
+
unreadCount={3}
|
|
126
|
+
isDesktop={false}
|
|
127
|
+
onMenuClick={() => undefined}
|
|
128
|
+
searchValue={searchValue}
|
|
129
|
+
onSearchChange={setSearchValue}
|
|
130
|
+
searchOpen={searchOpen}
|
|
131
|
+
onSearchOpenChange={setSearchOpen}
|
|
132
|
+
/>
|
|
133
|
+
<div className="min-h-0 flex-1">
|
|
134
|
+
<FilterSheet
|
|
135
|
+
categories={preset.categories}
|
|
136
|
+
filters={preset.filters}
|
|
137
|
+
sources={preset.sources}
|
|
138
|
+
selectedCategory={category}
|
|
139
|
+
activeFilters={activeFilters}
|
|
140
|
+
expanded={expanded}
|
|
141
|
+
onExpandedChange={setExpanded}
|
|
142
|
+
onSelectCategory={setCategory}
|
|
143
|
+
onToggleFilter={(id) =>
|
|
144
|
+
setActiveFilters((prev) => {
|
|
145
|
+
const next = new Set(prev);
|
|
146
|
+
if (next.has(id)) next.delete(id);
|
|
147
|
+
else next.add(id);
|
|
148
|
+
return next;
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
onClear={() => {
|
|
152
|
+
setCategory("all");
|
|
153
|
+
setActiveFilters(new Set());
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<ul className="divide-y divide-line">
|
|
157
|
+
{rows.map((thread) => (
|
|
158
|
+
<li key={thread.id} className="px-row-inset py-2.5">
|
|
159
|
+
<div className="text-sm font-medium text-fg">
|
|
160
|
+
{thread.fromName}
|
|
161
|
+
</div>
|
|
162
|
+
<div className="truncate text-xs text-fg-muted">
|
|
163
|
+
{thread.subject}
|
|
164
|
+
</div>
|
|
165
|
+
</li>
|
|
166
|
+
))}
|
|
167
|
+
</ul>
|
|
168
|
+
</FilterSheet>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Inbox filter collapsed: header + the FilterSheet bar over the inbox list. */
|
|
175
|
+
export const InboxWithFilter: Story = {
|
|
176
|
+
render: () => <InboxScreen />,
|
|
177
|
+
decorators: [narrowFrame],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Inbox filter expanded: categories + Unread/Flagged/Has attachment. */
|
|
181
|
+
export const InboxWithFilterExpanded: Story = {
|
|
182
|
+
render: () => <InboxScreen initialExpanded />,
|
|
183
|
+
decorators: [narrowFrame],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Consumer-supplied `listBody` slot — the pane renders the chrome (header,
|
|
187
|
+
* keyboard hints) while the caller owns the scrollable rows. This models
|
|
188
|
+
* the web-client's virtualized inbox path. */
|
|
189
|
+
export const CustomListBody: Story = {
|
|
190
|
+
args: {
|
|
191
|
+
isDesktop: true,
|
|
192
|
+
flatList: true,
|
|
193
|
+
listBody: (
|
|
194
|
+
<div className="flex-1 overflow-y-auto divide-y divide-line">
|
|
195
|
+
{sections.flatMap((s) =>
|
|
196
|
+
s.threads.map((t) => (
|
|
197
|
+
<a
|
|
198
|
+
key={t.id}
|
|
199
|
+
href={`?selectedMessageId=${t.id}`}
|
|
200
|
+
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-sunken"
|
|
201
|
+
>
|
|
202
|
+
<span className="font-medium text-sm">{t.fromName}</span>
|
|
203
|
+
<span className="text-sm text-fg-muted truncate">
|
|
204
|
+
{t.subject}
|
|
205
|
+
</span>
|
|
206
|
+
</a>
|
|
207
|
+
)),
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
),
|
|
211
|
+
},
|
|
212
|
+
decorators: [desktopFrame],
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** External `selectionBar` slot — the pane delegates the header to the caller
|
|
216
|
+
* when a selection is active. */
|
|
217
|
+
export const ExternalSelectionBar: Story = {
|
|
218
|
+
args: {
|
|
219
|
+
isDesktop: true,
|
|
220
|
+
flatList: true,
|
|
221
|
+
selectionBar: (
|
|
222
|
+
<SelectionTopBar
|
|
223
|
+
count={2}
|
|
224
|
+
onCancel={() => undefined}
|
|
225
|
+
onMarkRead={() => undefined}
|
|
226
|
+
onDelete={() => undefined}
|
|
227
|
+
/>
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
decorators: [desktopFrame],
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** Fail-loud error state — the specific failure detail is surfaced under the
|
|
234
|
+
* headline (not a bare "something went wrong"), with a way back (Retry) and a
|
|
235
|
+
* place for the failure to go (Report a problem). */
|
|
236
|
+
export const ErrorState: Story = {
|
|
237
|
+
args: {
|
|
238
|
+
isDesktop: true,
|
|
239
|
+
flatList: true,
|
|
240
|
+
listState: "error",
|
|
241
|
+
errorMessage: "Request timed out while loading this mailbox.",
|
|
242
|
+
onRetry: () => undefined,
|
|
243
|
+
onReportError: () => undefined,
|
|
244
|
+
},
|
|
245
|
+
decorators: [desktopFrame],
|
|
246
|
+
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { Menu } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { AppShellProps, TouchSeed } from "./app-shell-types.js";
|
|
5
|
+
import { BriefSections } from "./brief-sections.js";
|
|
6
|
+
import { Button } from "./button.js";
|
|
7
|
+
import { KeyboardHintBar } from "./keyboard-hint-bar.js";
|
|
8
|
+
import {
|
|
9
|
+
MessageListEmpty,
|
|
10
|
+
MessageListError,
|
|
11
|
+
MessageListLoading,
|
|
12
|
+
} from "./message-list-state.js";
|
|
13
|
+
import { ComfortableRow, CompactRow } from "./message-row.js";
|
|
14
|
+
import { SelectionTopBar } from "./selection-top-bar.js";
|
|
15
|
+
import type { SwipePeek } from "./swipeable-row.js";
|
|
16
|
+
import { TouchListBody } from "./touch-list.js";
|
|
17
|
+
|
|
18
|
+
/* ------------------------------------------------------------------ */
|
|
19
|
+
/* Pane 2: message list (sectioned, dense, density toggle) */
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
|
|
22
|
+
export function MessageListPane({
|
|
23
|
+
listTitle,
|
|
24
|
+
listMeta,
|
|
25
|
+
sections,
|
|
26
|
+
briefFilters,
|
|
27
|
+
flatList,
|
|
28
|
+
listState = "ready",
|
|
29
|
+
searchQuery,
|
|
30
|
+
errorMessage,
|
|
31
|
+
onRetry,
|
|
32
|
+
onReportError,
|
|
33
|
+
briefCategory,
|
|
34
|
+
selectedThreadId,
|
|
35
|
+
density = "comfortable",
|
|
36
|
+
onSelectThread,
|
|
37
|
+
onSelectBriefCategory,
|
|
38
|
+
onOpenNav,
|
|
39
|
+
isDesktop,
|
|
40
|
+
initialTouchState,
|
|
41
|
+
selectionBar,
|
|
42
|
+
listBody,
|
|
43
|
+
hideHeader = false,
|
|
44
|
+
}: Pick<
|
|
45
|
+
AppShellProps,
|
|
46
|
+
| "listTitle"
|
|
47
|
+
| "listMeta"
|
|
48
|
+
| "sections"
|
|
49
|
+
| "briefFilters"
|
|
50
|
+
| "flatList"
|
|
51
|
+
| "listState"
|
|
52
|
+
| "searchQuery"
|
|
53
|
+
| "errorMessage"
|
|
54
|
+
| "onRetry"
|
|
55
|
+
| "onReportError"
|
|
56
|
+
| "briefCategory"
|
|
57
|
+
| "selectedThreadId"
|
|
58
|
+
| "density"
|
|
59
|
+
| "onSelectThread"
|
|
60
|
+
| "onSelectBriefCategory"
|
|
61
|
+
> & {
|
|
62
|
+
/** When set, the list header shows a folders/menu button that opens the nav
|
|
63
|
+
* slide-over (list-only widths, where the nav is not a persistent pane). */
|
|
64
|
+
onOpenNav?: () => void;
|
|
65
|
+
/** Container-derived ≥1024; below it the touch triage chrome takes over. */
|
|
66
|
+
isDesktop: boolean;
|
|
67
|
+
initialTouchState?: TouchSeed;
|
|
68
|
+
/**
|
|
69
|
+
* Replaces the pane header when a selection is active. The caller controls
|
|
70
|
+
* the selection state and toolbar actions (mark-read, move, delete, cancel).
|
|
71
|
+
* When omitted the pane's built-in touch-triage selection bar is used.
|
|
72
|
+
*/
|
|
73
|
+
selectionBar?: ReactNode;
|
|
74
|
+
/**
|
|
75
|
+
* Overrides the row-rendering section of the non-brief list — the whole
|
|
76
|
+
* scrollable body including virtualization, swipe-triage and any load-more
|
|
77
|
+
* indicator. Wins on every width (desktop and touch): the consumer owns the
|
|
78
|
+
* rows, so they stay real `<a href>` anchors and keep their own gestures
|
|
79
|
+
* instead of the kit substituting the mock `TouchListBody`. Brief paths are
|
|
80
|
+
* unaffected. When set, `sections` / `selectedThreadId` / `onSelectThread`
|
|
81
|
+
* are still used by the pane chrome; the rows themselves come from this slot.
|
|
82
|
+
*/
|
|
83
|
+
listBody?: ReactNode;
|
|
84
|
+
/**
|
|
85
|
+
* Suppress the built-in title header. The consumer owns the header (e.g. the
|
|
86
|
+
* shared `MailHeader` rendered above the pane). The selection bar still
|
|
87
|
+
* replaces the (now absent) header while a selection is active.
|
|
88
|
+
*/
|
|
89
|
+
hideHeader?: boolean;
|
|
90
|
+
}) {
|
|
91
|
+
const Row = density === "compact" ? CompactRow : ComfortableRow;
|
|
92
|
+
|
|
93
|
+
const touchTriage = !isDesktop && !briefFilters && listState === "ready";
|
|
94
|
+
const seededRows = sections.flatMap((section) => section.threads);
|
|
95
|
+
const [selectionMode, setSelectionMode] = useState(
|
|
96
|
+
initialTouchState === "selection",
|
|
97
|
+
);
|
|
98
|
+
const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(() =>
|
|
99
|
+
initialTouchState === "selection"
|
|
100
|
+
? new Set(seededRows.slice(0, 2).map((t) => t.id))
|
|
101
|
+
: new Set(),
|
|
102
|
+
);
|
|
103
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
104
|
+
const initialPeek: SwipePeek | undefined =
|
|
105
|
+
initialTouchState === "peek-trailing"
|
|
106
|
+
? "trailing"
|
|
107
|
+
: initialTouchState === "peek-leading"
|
|
108
|
+
? "leading"
|
|
109
|
+
: undefined;
|
|
110
|
+
|
|
111
|
+
const toggleCheck = (id: string) => {
|
|
112
|
+
setCheckedIds((prev) => {
|
|
113
|
+
const next = new Set(prev);
|
|
114
|
+
if (next.has(id)) next.delete(id);
|
|
115
|
+
else next.add(id);
|
|
116
|
+
if (next.size === 0) setSelectionMode(false);
|
|
117
|
+
return next;
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
const enterSelection = (id: string) => {
|
|
121
|
+
setSelectionMode(true);
|
|
122
|
+
setCheckedIds(new Set([id]));
|
|
123
|
+
};
|
|
124
|
+
const cancelSelection = () => {
|
|
125
|
+
setSelectionMode(false);
|
|
126
|
+
setCheckedIds(new Set());
|
|
127
|
+
};
|
|
128
|
+
const refresh = () => {
|
|
129
|
+
setRefreshing(true);
|
|
130
|
+
setTimeout(() => setRefreshing(false), 1400);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// When the caller supplies a selectionBar slot, it owns selection state.
|
|
134
|
+
// Fall back to the built-in touch-triage bar only when no external bar is given.
|
|
135
|
+
const inBuiltinSelection =
|
|
136
|
+
!selectionBar && touchTriage && selectionMode && checkedIds.size > 0;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<section className="flex h-full w-full flex-col bg-surface">
|
|
140
|
+
{selectionBar ??
|
|
141
|
+
(inBuiltinSelection ? (
|
|
142
|
+
<SelectionTopBar
|
|
143
|
+
count={checkedIds.size}
|
|
144
|
+
onCancel={cancelSelection}
|
|
145
|
+
onMarkRead={cancelSelection}
|
|
146
|
+
onDelete={cancelSelection}
|
|
147
|
+
/>
|
|
148
|
+
) : hideHeader ? null : (
|
|
149
|
+
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
|
|
150
|
+
{onOpenNav && (
|
|
151
|
+
<Button
|
|
152
|
+
variant="ghost"
|
|
153
|
+
size="sm"
|
|
154
|
+
icon={<Menu className="size-4" />}
|
|
155
|
+
onClick={onOpenNav}
|
|
156
|
+
aria-label="Open folders"
|
|
157
|
+
className="-ml-1 shrink-0"
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
160
|
+
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
161
|
+
{listTitle}
|
|
162
|
+
</h1>
|
|
163
|
+
{listMeta && (
|
|
164
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
165
|
+
{listMeta}
|
|
166
|
+
</span>
|
|
167
|
+
)}
|
|
168
|
+
</header>
|
|
169
|
+
))}
|
|
170
|
+
|
|
171
|
+
{/* List body. Non-ready states replace the rows entirely (the live
|
|
172
|
+
MessageList does the same): skeleton on cold load, empty on a clean
|
|
173
|
+
mailbox / search, fail-hard error with a way back + report. */}
|
|
174
|
+
{listState === "loading" ? (
|
|
175
|
+
<div className="flex-1 overflow-y-auto">
|
|
176
|
+
<MessageListLoading />
|
|
177
|
+
</div>
|
|
178
|
+
) : listState === "empty" ? (
|
|
179
|
+
<MessageListEmpty searchQuery={searchQuery} />
|
|
180
|
+
) : listState === "error" ? (
|
|
181
|
+
<MessageListError
|
|
182
|
+
message={errorMessage}
|
|
183
|
+
onRetry={onRetry}
|
|
184
|
+
onReport={onReportError}
|
|
185
|
+
/>
|
|
186
|
+
) : briefFilters ? (
|
|
187
|
+
<BriefSections
|
|
188
|
+
sections={sections}
|
|
189
|
+
briefCategory={briefCategory}
|
|
190
|
+
selectedThreadId={selectedThreadId}
|
|
191
|
+
Row={Row}
|
|
192
|
+
onSelectThread={onSelectThread}
|
|
193
|
+
onSelectBriefCategory={onSelectBriefCategory}
|
|
194
|
+
/>
|
|
195
|
+
) : listBody != null ? (
|
|
196
|
+
/* Consumer-provided body wins on every width — it owns the rows
|
|
197
|
+
(real <a href> anchors, virtualization, infinite scroll) and its
|
|
198
|
+
own swipe-triage. The built-in TouchListBody below is only the
|
|
199
|
+
mock fallback for callers that don't supply a body. */
|
|
200
|
+
listBody
|
|
201
|
+
) : touchTriage ? (
|
|
202
|
+
<TouchListBody
|
|
203
|
+
sections={sections}
|
|
204
|
+
selectedThreadId={selectedThreadId}
|
|
205
|
+
selectionMode={selectionMode}
|
|
206
|
+
checkedIds={checkedIds}
|
|
207
|
+
initialPeek={initialPeek}
|
|
208
|
+
onToggleCheck={toggleCheck}
|
|
209
|
+
onEnterSelection={enterSelection}
|
|
210
|
+
onOpenThread={(id) => onSelectThread?.(id)}
|
|
211
|
+
onRefresh={refresh}
|
|
212
|
+
refreshing={refreshing}
|
|
213
|
+
/>
|
|
214
|
+
) : (
|
|
215
|
+
<div className="flex-1 overflow-y-auto">
|
|
216
|
+
{sections.map((section) => (
|
|
217
|
+
<div key={section.id}>
|
|
218
|
+
{/* The plain flat mailbox suppresses section labels — it is one
|
|
219
|
+
continuous list, like the live $mailboxId MessageList. */}
|
|
220
|
+
{!flatList && section.label && (
|
|
221
|
+
<div className="sticky top-0 flex h-section-row items-center justify-between border-b border-line bg-surface-sunken px-row-inset">
|
|
222
|
+
<span className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
|
|
223
|
+
{section.label}
|
|
224
|
+
</span>
|
|
225
|
+
<span className="text-2xs text-fg-subtle tabular-nums">
|
|
226
|
+
{section.threads.length}
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
<div className="divide-y divide-line">
|
|
231
|
+
{section.threads.map((thread) => (
|
|
232
|
+
<Row
|
|
233
|
+
key={thread.id}
|
|
234
|
+
thread={thread}
|
|
235
|
+
active={thread.id === selectedThreadId}
|
|
236
|
+
onClick={() => onSelectThread?.(thread.id)}
|
|
237
|
+
/>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
|
|
245
|
+
{isDesktop && <KeyboardHintBar />}
|
|
246
|
+
</section>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
MessageListEmpty,
|
|
7
|
+
MessageListError,
|
|
8
|
+
MessageListLoading,
|
|
9
|
+
} from "./message-list-state.js";
|
|
10
|
+
|
|
11
|
+
describe("MessageListLoading", () => {
|
|
12
|
+
const html = renderToString(createElement(MessageListLoading));
|
|
13
|
+
|
|
14
|
+
it("renders eight skeleton rows", () => {
|
|
15
|
+
const rows = html.match(/animate-pulse/g) ?? [];
|
|
16
|
+
assert.equal(rows.length, 8, "eight pulse rows, like the live skeleton");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("marks the region busy for assistive tech", () => {
|
|
20
|
+
assert.match(html, /aria-busy="true"/);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("MessageListEmpty", () => {
|
|
25
|
+
it("uses the plain mailbox copy with no query", () => {
|
|
26
|
+
const html = renderToString(createElement(MessageListEmpty, {}));
|
|
27
|
+
assert.match(html, /No messages in this mailbox/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("switches to the search copy when a query is active", () => {
|
|
31
|
+
const html = renderToString(
|
|
32
|
+
createElement(MessageListEmpty, { searchQuery: "invoice" }),
|
|
33
|
+
);
|
|
34
|
+
assert.match(html, /No messages match your search/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("treats a whitespace-only query as no search", () => {
|
|
38
|
+
const html = renderToString(
|
|
39
|
+
createElement(MessageListEmpty, { searchQuery: " " }),
|
|
40
|
+
);
|
|
41
|
+
assert.match(html, /No messages in this mailbox/);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("MessageListError", () => {
|
|
46
|
+
it("fails hard: alert role, plain failure line, and the detail message", () => {
|
|
47
|
+
const html = renderToString(
|
|
48
|
+
createElement(MessageListError, { message: "Network unreachable" }),
|
|
49
|
+
);
|
|
50
|
+
assert.match(html, /role="alert"/, "blocking alert, never a toast");
|
|
51
|
+
assert.match(html, /Couldn't load messages/, "plain failure line");
|
|
52
|
+
assert.match(html, /Network unreachable/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("offers a way back and a report path when handlers are given", () => {
|
|
56
|
+
const html = renderToString(
|
|
57
|
+
createElement(MessageListError, {
|
|
58
|
+
onRetry: () => {},
|
|
59
|
+
onReport: () => {},
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
assert.match(html, /Retry/);
|
|
63
|
+
assert.match(html, /Report a problem/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("never renders a disabled control", () => {
|
|
67
|
+
const html = renderToString(
|
|
68
|
+
createElement(MessageListError, { onRetry: () => {} }),
|
|
69
|
+
);
|
|
70
|
+
assert.doesNotMatch(html, /\sdisabled[\s=>]/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { AlertCircle } from "lucide-react";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
|
|
4
|
+
/** Non-ready states the flat mailbox list can be in. "ready" renders rows. */
|
|
5
|
+
export type ListState = "ready" | "loading" | "empty" | "error";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Skeleton mirror of the live web-client MessageList LoadingSkeleton: eight
|
|
9
|
+
* pulse rows shaped like a comfortable message row (sender / time, subject,
|
|
10
|
+
* snippet). Kept visually identical so the cold-load mock matches production.
|
|
11
|
+
*/
|
|
12
|
+
export function MessageListLoading() {
|
|
13
|
+
return (
|
|
14
|
+
// biome-ignore lint/a11y/useSemanticElements: <div> with role="status" preserves block layout; <output> is inline
|
|
15
|
+
<div
|
|
16
|
+
className="space-y-0"
|
|
17
|
+
role="status"
|
|
18
|
+
aria-busy="true"
|
|
19
|
+
aria-label="Loading messages"
|
|
20
|
+
>
|
|
21
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
22
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
|
|
23
|
+
<div key={i} className="animate-pulse border-b border-line px-3 py-2">
|
|
24
|
+
<div className="mb-2 flex items-center justify-between">
|
|
25
|
+
<div className="h-4 w-32 rounded bg-surface-sunken" />
|
|
26
|
+
<div className="h-3 w-16 rounded bg-surface-sunken" />
|
|
27
|
+
</div>
|
|
28
|
+
<div className="mb-2 h-4 w-48 rounded bg-surface-sunken" />
|
|
29
|
+
<div className="h-3 w-full rounded bg-surface-sunken" />
|
|
30
|
+
</div>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Empty mailbox / empty-search state. Copy mirrors the live MessageList
|
|
38
|
+
* EmptyState: a plain "No messages…" line, switching to the search variant
|
|
39
|
+
* when a query is active.
|
|
40
|
+
*/
|
|
41
|
+
export function MessageListEmpty({ searchQuery }: { searchQuery?: string }) {
|
|
42
|
+
const isSearching = Boolean(searchQuery?.trim());
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex h-full flex-1 items-center justify-center">
|
|
45
|
+
<div className="flex flex-col items-center justify-center p-8 text-center">
|
|
46
|
+
<p className="text-fg-muted">
|
|
47
|
+
{isSearching
|
|
48
|
+
? "No messages match your search"
|
|
49
|
+
: "No messages in this mailbox"}
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fail-hard list error (ux.md): a centered, blocking message that stops the
|
|
58
|
+
* list — never a toast, never a control left looking healthy. States plainly
|
|
59
|
+
* what failed, surfaces the underlying message, and offers a way back (Retry)
|
|
60
|
+
* plus a way for the failure to go somewhere (Report).
|
|
61
|
+
*/
|
|
62
|
+
export function MessageListError({
|
|
63
|
+
message = "Something went wrong loading this mailbox.",
|
|
64
|
+
onRetry,
|
|
65
|
+
onReport,
|
|
66
|
+
}: {
|
|
67
|
+
message?: string;
|
|
68
|
+
onRetry?: () => void;
|
|
69
|
+
onReport?: () => void;
|
|
70
|
+
}) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex h-full flex-1 items-center justify-center p-4">
|
|
73
|
+
<div
|
|
74
|
+
role="alert"
|
|
75
|
+
className="flex max-w-sm flex-col items-center justify-center gap-3 text-center"
|
|
76
|
+
>
|
|
77
|
+
<AlertCircle className="size-8 text-danger" aria-hidden="true" />
|
|
78
|
+
<div>
|
|
79
|
+
<p className="font-medium text-danger">Couldn't load messages</p>
|
|
80
|
+
<p className="mt-1 break-words text-sm text-fg-muted">{message}</p>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="flex items-center gap-2">
|
|
83
|
+
{onRetry && (
|
|
84
|
+
<Button variant="secondary" size="sm" onClick={onRetry}>
|
|
85
|
+
Retry
|
|
86
|
+
</Button>
|
|
87
|
+
)}
|
|
88
|
+
{onReport && (
|
|
89
|
+
<Button variant="ghost" size="sm" onClick={onReport}>
|
|
90
|
+
Report a problem
|
|
91
|
+
</Button>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|