@mdxui/zero 6.0.0
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/.storybook/preview.ts +20 -0
- package/.turbo/turbo-typecheck.log +5 -0
- package/ARCHITECTURE.md +415 -0
- package/CHANGELOG.md +80 -0
- package/README.md +205 -0
- package/package.json +43 -0
- package/playwright.config.ts +55 -0
- package/src/components/index.ts +20 -0
- package/src/compose/email-composer.stories.tsx +219 -0
- package/src/compose/email-composer.tsx +619 -0
- package/src/compose/index.ts +14 -0
- package/src/dashboard/index.ts +14 -0
- package/src/dashboard/mail-shell.stories.tsx +272 -0
- package/src/dashboard/mail-shell.tsx +199 -0
- package/src/dashboard/mail-sidebar.stories.tsx +158 -0
- package/src/dashboard/mail-sidebar.tsx +388 -0
- package/src/index.ts +24 -0
- package/src/landing/index.ts +24 -0
- package/src/mail/index.ts +15 -0
- package/src/mail/mail-item.stories.tsx +422 -0
- package/src/mail/mail-item.tsx +229 -0
- package/src/mail/mail-list.stories.tsx +320 -0
- package/src/mail/mail-list.tsx +262 -0
- package/src/mail/message-view.stories.tsx +459 -0
- package/src/mail/message-view.tsx +378 -0
- package/src/mail/thread-display.stories.tsx +260 -0
- package/src/mail/thread-display.tsx +392 -0
- package/src/pages/index.ts +9 -0
- package/src/pages/mail-zero-page.stories.tsx +251 -0
- package/src/pages/mail-zero-page.tsx +334 -0
- package/tests/visual/report/index.html +85 -0
- package/tests/visual/snapshots/zero-components.spec.ts/mail-shell-default.png +0 -0
- package/tests/visual/zero-components.spec.ts +321 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import type { MailThread as Thread } from "mdxui";
|
|
3
|
+
import { MailItem } from "./mail-item";
|
|
4
|
+
import { MailList, MailSkeleton } from "./mail-list";
|
|
5
|
+
|
|
6
|
+
// Sample thread data matching the original Zero email client
|
|
7
|
+
const sampleThreads: Thread[] = [
|
|
8
|
+
{
|
|
9
|
+
id: "1",
|
|
10
|
+
subject: "Weekly Team Sync - Q4 Planning",
|
|
11
|
+
messages: [
|
|
12
|
+
{
|
|
13
|
+
id: "m1",
|
|
14
|
+
threadId: "1",
|
|
15
|
+
subject: "Weekly Team Sync - Q4 Planning",
|
|
16
|
+
from: { address: "sarah@company.com", name: "Sarah Chen" },
|
|
17
|
+
to: [{ address: "team@company.com", name: "Engineering Team" }],
|
|
18
|
+
date: new Date(Date.now() - 3600000).toISOString(),
|
|
19
|
+
snippet: "Hey team, let's discuss our Q4 roadmap priorities...",
|
|
20
|
+
isRead: false,
|
|
21
|
+
isStarred: true,
|
|
22
|
+
isImportant: true,
|
|
23
|
+
isDraft: false,
|
|
24
|
+
attachments: [],
|
|
25
|
+
labels: [],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
latestDate: new Date(Date.now() - 3600000).toISOString(),
|
|
29
|
+
participants: [
|
|
30
|
+
{ address: "sarah@company.com", name: "Sarah Chen" },
|
|
31
|
+
{ address: "team@company.com", name: "Engineering Team" },
|
|
32
|
+
],
|
|
33
|
+
unreadCount: 1,
|
|
34
|
+
labels: [{ id: "l1", name: "Work", color: "#3b82f6", type: "user" }],
|
|
35
|
+
isStarred: true,
|
|
36
|
+
isImportant: true,
|
|
37
|
+
hasAttachments: false,
|
|
38
|
+
snippet: "Hey team, let's discuss our Q4 roadmap priorities...",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "2",
|
|
42
|
+
subject: "Re: Design Review - Homepage Redesign",
|
|
43
|
+
messages: [
|
|
44
|
+
{
|
|
45
|
+
id: "m2",
|
|
46
|
+
threadId: "2",
|
|
47
|
+
subject: "Re: Design Review - Homepage Redesign",
|
|
48
|
+
from: { address: "mike@design.co", name: "Mike Johnson" },
|
|
49
|
+
to: [{ address: "me@company.com", name: "Me" }],
|
|
50
|
+
date: new Date(Date.now() - 7200000).toISOString(),
|
|
51
|
+
snippet:
|
|
52
|
+
"I've updated the mockups based on your feedback. The new hero section...",
|
|
53
|
+
isRead: true,
|
|
54
|
+
isStarred: false,
|
|
55
|
+
isImportant: false,
|
|
56
|
+
isDraft: false,
|
|
57
|
+
attachments: [
|
|
58
|
+
{
|
|
59
|
+
id: "a1",
|
|
60
|
+
filename: "homepage-v3.fig",
|
|
61
|
+
mimeType: "application/figma",
|
|
62
|
+
size: 2400000,
|
|
63
|
+
inline: false,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
labels: [],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
latestDate: new Date(Date.now() - 7200000).toISOString(),
|
|
70
|
+
participants: [
|
|
71
|
+
{ address: "mike@design.co", name: "Mike Johnson" },
|
|
72
|
+
{ address: "me@company.com", name: "Me" },
|
|
73
|
+
],
|
|
74
|
+
unreadCount: 0,
|
|
75
|
+
labels: [{ id: "l2", name: "Design", color: "#10b981", type: "user" }],
|
|
76
|
+
isStarred: false,
|
|
77
|
+
isImportant: false,
|
|
78
|
+
hasAttachments: true,
|
|
79
|
+
snippet:
|
|
80
|
+
"I've updated the mockups based on your feedback. The new hero section...",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "3",
|
|
84
|
+
subject: "Invoice #12345 - Payment Confirmation",
|
|
85
|
+
messages: [
|
|
86
|
+
{
|
|
87
|
+
id: "m3",
|
|
88
|
+
threadId: "3",
|
|
89
|
+
subject: "Invoice #12345 - Payment Confirmation",
|
|
90
|
+
from: { address: "billing@stripe.com", name: "Stripe" },
|
|
91
|
+
to: [{ address: "me@company.com", name: "Me" }],
|
|
92
|
+
date: new Date(Date.now() - 86400000).toISOString(),
|
|
93
|
+
snippet: "Your payment of $299.00 has been successfully processed...",
|
|
94
|
+
isRead: true,
|
|
95
|
+
isStarred: false,
|
|
96
|
+
isImportant: false,
|
|
97
|
+
isDraft: false,
|
|
98
|
+
attachments: [
|
|
99
|
+
{
|
|
100
|
+
id: "a2",
|
|
101
|
+
filename: "invoice-12345.pdf",
|
|
102
|
+
mimeType: "application/pdf",
|
|
103
|
+
size: 45000,
|
|
104
|
+
inline: false,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
labels: [],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
latestDate: new Date(Date.now() - 86400000).toISOString(),
|
|
111
|
+
participants: [{ address: "billing@stripe.com", name: "Stripe" }],
|
|
112
|
+
unreadCount: 0,
|
|
113
|
+
labels: [{ id: "l3", name: "Finance", color: "#f59e0b", type: "user" }],
|
|
114
|
+
isStarred: false,
|
|
115
|
+
isImportant: false,
|
|
116
|
+
hasAttachments: true,
|
|
117
|
+
snippet: "Your payment of $299.00 has been successfully processed...",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "4",
|
|
121
|
+
subject: "GitHub: [org/repo] Pull request #42 merged",
|
|
122
|
+
messages: [
|
|
123
|
+
{
|
|
124
|
+
id: "m4",
|
|
125
|
+
threadId: "4",
|
|
126
|
+
subject: "GitHub: [org/repo] Pull request #42 merged",
|
|
127
|
+
from: { address: "notifications@github.com", name: "GitHub" },
|
|
128
|
+
to: [{ address: "me@company.com", name: "Me" }],
|
|
129
|
+
date: new Date(Date.now() - 172800000).toISOString(),
|
|
130
|
+
snippet:
|
|
131
|
+
'Your pull request "Fix authentication flow" has been merged into main...',
|
|
132
|
+
isRead: true,
|
|
133
|
+
isStarred: false,
|
|
134
|
+
isImportant: false,
|
|
135
|
+
isDraft: false,
|
|
136
|
+
attachments: [],
|
|
137
|
+
labels: [],
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
latestDate: new Date(Date.now() - 172800000).toISOString(),
|
|
141
|
+
participants: [{ address: "notifications@github.com", name: "GitHub" }],
|
|
142
|
+
unreadCount: 0,
|
|
143
|
+
labels: [],
|
|
144
|
+
isStarred: false,
|
|
145
|
+
isImportant: false,
|
|
146
|
+
hasAttachments: false,
|
|
147
|
+
snippet:
|
|
148
|
+
'Your pull request "Fix authentication flow" has been merged into main...',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "5",
|
|
152
|
+
subject: "Welcome to Zero Mail - AI-Powered Email",
|
|
153
|
+
messages: [
|
|
154
|
+
{
|
|
155
|
+
id: "m5",
|
|
156
|
+
threadId: "5",
|
|
157
|
+
subject: "Welcome to Zero Mail - AI-Powered Email",
|
|
158
|
+
from: { address: "hello@0.email", name: "Zero Mail" },
|
|
159
|
+
to: [{ address: "me@company.com", name: "Me" }],
|
|
160
|
+
date: new Date(Date.now() - 259200000).toISOString(),
|
|
161
|
+
snippet: "Welcome! Get started with AI-powered email management...",
|
|
162
|
+
isRead: false,
|
|
163
|
+
isStarred: false,
|
|
164
|
+
isImportant: false,
|
|
165
|
+
isDraft: false,
|
|
166
|
+
attachments: [],
|
|
167
|
+
labels: [],
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
latestDate: new Date(Date.now() - 259200000).toISOString(),
|
|
171
|
+
participants: [{ address: "hello@0.email", name: "Zero Mail" }],
|
|
172
|
+
unreadCount: 1,
|
|
173
|
+
labels: [],
|
|
174
|
+
isStarred: false,
|
|
175
|
+
isImportant: false,
|
|
176
|
+
hasAttachments: false,
|
|
177
|
+
snippet: "Welcome! Get started with AI-powered email management...",
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
const meta: Meta<typeof MailList> = {
|
|
182
|
+
title: "Zero/Mail/MailList",
|
|
183
|
+
component: MailList,
|
|
184
|
+
parameters: {
|
|
185
|
+
layout: "fullscreen",
|
|
186
|
+
},
|
|
187
|
+
tags: ["autodocs"],
|
|
188
|
+
argTypes: {
|
|
189
|
+
selectionMode: {
|
|
190
|
+
control: "select",
|
|
191
|
+
options: ["none", "single", "multiple"],
|
|
192
|
+
},
|
|
193
|
+
displayMode: {
|
|
194
|
+
control: "select",
|
|
195
|
+
options: ["comfortable", "compact", "condensed"],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
decorators: [
|
|
199
|
+
(Story) => (
|
|
200
|
+
<div className="h-screen w-full max-w-2xl border-r bg-background">
|
|
201
|
+
<Story />
|
|
202
|
+
</div>
|
|
203
|
+
),
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export default meta;
|
|
208
|
+
type Story = StoryObj<typeof meta>;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Default mail list with comfortable display mode.
|
|
212
|
+
* Matches the original Zero mail inbox view.
|
|
213
|
+
*/
|
|
214
|
+
export const Default: Story = {
|
|
215
|
+
args: {
|
|
216
|
+
threads: sampleThreads,
|
|
217
|
+
selectionMode: "single",
|
|
218
|
+
displayMode: "comfortable",
|
|
219
|
+
showAvatars: true,
|
|
220
|
+
showSnippets: true,
|
|
221
|
+
enableKeyboardNav: true,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Compact display mode for more messages on screen.
|
|
227
|
+
*/
|
|
228
|
+
export const Compact: Story = {
|
|
229
|
+
args: {
|
|
230
|
+
threads: sampleThreads,
|
|
231
|
+
selectionMode: "single",
|
|
232
|
+
displayMode: "compact",
|
|
233
|
+
showAvatars: true,
|
|
234
|
+
showSnippets: true,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Condensed display mode - minimal height.
|
|
240
|
+
*/
|
|
241
|
+
export const Condensed: Story = {
|
|
242
|
+
args: {
|
|
243
|
+
threads: sampleThreads,
|
|
244
|
+
selectionMode: "single",
|
|
245
|
+
displayMode: "condensed",
|
|
246
|
+
showAvatars: false,
|
|
247
|
+
showSnippets: false,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Multiple selection mode with checkboxes.
|
|
253
|
+
*/
|
|
254
|
+
export const MultiSelect: Story = {
|
|
255
|
+
args: {
|
|
256
|
+
threads: sampleThreads,
|
|
257
|
+
selectionMode: "multiple",
|
|
258
|
+
displayMode: "comfortable",
|
|
259
|
+
selectedIds: ["1", "3"],
|
|
260
|
+
showAvatars: true,
|
|
261
|
+
showSnippets: true,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Loading state with skeleton items.
|
|
267
|
+
*/
|
|
268
|
+
export const Loading: Story = {
|
|
269
|
+
args: {
|
|
270
|
+
threads: [],
|
|
271
|
+
isLoading: true,
|
|
272
|
+
displayMode: "comfortable",
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Empty state when no messages.
|
|
278
|
+
*/
|
|
279
|
+
export const Empty: Story = {
|
|
280
|
+
args: {
|
|
281
|
+
threads: [],
|
|
282
|
+
emptyMessage: "Your inbox is empty",
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* With search term highlighting.
|
|
288
|
+
*/
|
|
289
|
+
export const WithHighlight: Story = {
|
|
290
|
+
args: {
|
|
291
|
+
threads: sampleThreads,
|
|
292
|
+
highlightTerms: ["Q4", "design"],
|
|
293
|
+
displayMode: "comfortable",
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// MailItem story
|
|
298
|
+
export const SingleItem: StoryObj<typeof MailItem> = {
|
|
299
|
+
render: () => (
|
|
300
|
+
<div className="w-full max-w-2xl border bg-background">
|
|
301
|
+
<MailItem
|
|
302
|
+
thread={sampleThreads[0]}
|
|
303
|
+
isSelected={false}
|
|
304
|
+
isFocused={false}
|
|
305
|
+
showCheckbox={true}
|
|
306
|
+
showAvatar={true}
|
|
307
|
+
compact={false}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
),
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// MailSkeleton story
|
|
314
|
+
export const Skeleton: StoryObj<typeof MailSkeleton> = {
|
|
315
|
+
render: () => (
|
|
316
|
+
<div className="w-full max-w-2xl border bg-background">
|
|
317
|
+
<MailSkeleton count={5} displayMode="comfortable" />
|
|
318
|
+
</div>
|
|
319
|
+
),
|
|
320
|
+
};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { Checkbox } from "@mdxui/primitives/checkbox";
|
|
2
|
+
import { cn } from "@mdxui/primitives/lib/utils";
|
|
3
|
+
import { ScrollArea } from "@mdxui/primitives/scroll-area";
|
|
4
|
+
import { Skeleton } from "@mdxui/primitives/skeleton";
|
|
5
|
+
import { Inbox, Loader2 } from "lucide-react";
|
|
6
|
+
import type {
|
|
7
|
+
MailListProps,
|
|
8
|
+
MailSkeletonProps,
|
|
9
|
+
MailThread as Thread,
|
|
10
|
+
} from "mdxui";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
import { MailItem } from "./mail-item";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* MailList - Virtualized list of email threads.
|
|
16
|
+
*
|
|
17
|
+
* Features:
|
|
18
|
+
* - Multiple selection modes (none, single, multiple)
|
|
19
|
+
* - Keyboard navigation
|
|
20
|
+
* - Infinite scroll loading
|
|
21
|
+
* - Empty states
|
|
22
|
+
* - Loading skeletons
|
|
23
|
+
*/
|
|
24
|
+
function MailList({
|
|
25
|
+
threads,
|
|
26
|
+
selectedIds = [],
|
|
27
|
+
activeId: _activeId,
|
|
28
|
+
selectionMode = "single",
|
|
29
|
+
displayMode = "comfortable",
|
|
30
|
+
isLoading = false,
|
|
31
|
+
hasMore = false,
|
|
32
|
+
onThreadClick,
|
|
33
|
+
onSelectionChange,
|
|
34
|
+
onLoadMore,
|
|
35
|
+
onStar,
|
|
36
|
+
onArchive: _onArchive,
|
|
37
|
+
onDelete: _onDelete,
|
|
38
|
+
enableKeyboardNav = true,
|
|
39
|
+
showAvatars = true,
|
|
40
|
+
showSnippets: _showSnippets = true,
|
|
41
|
+
virtualized: _virtualized = true,
|
|
42
|
+
highlightTerms,
|
|
43
|
+
emptyMessage = "No messages",
|
|
44
|
+
emptyIcon: _emptyIcon,
|
|
45
|
+
className,
|
|
46
|
+
}: MailListProps) {
|
|
47
|
+
const listRef = React.useRef<HTMLUListElement>(null);
|
|
48
|
+
const [focusedIndex, setFocusedIndex] = React.useState(-1);
|
|
49
|
+
|
|
50
|
+
const compact = displayMode === "compact" || displayMode === "condensed";
|
|
51
|
+
|
|
52
|
+
// Handle thread selection
|
|
53
|
+
const handleSelect = React.useCallback(
|
|
54
|
+
(thread: Thread) => {
|
|
55
|
+
if (selectionMode === "none") return;
|
|
56
|
+
|
|
57
|
+
let newSelected: string[];
|
|
58
|
+
|
|
59
|
+
if (selectionMode === "single") {
|
|
60
|
+
newSelected = selectedIds.includes(thread.id) ? [] : [thread.id];
|
|
61
|
+
} else {
|
|
62
|
+
newSelected = selectedIds.includes(thread.id)
|
|
63
|
+
? selectedIds.filter((id) => id !== thread.id)
|
|
64
|
+
: [...selectedIds, thread.id];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
onSelectionChange?.(newSelected);
|
|
68
|
+
},
|
|
69
|
+
[selectionMode, selectedIds, onSelectionChange],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Handle select all
|
|
73
|
+
const handleSelectAll = React.useCallback(() => {
|
|
74
|
+
if (selectionMode !== "multiple") return;
|
|
75
|
+
|
|
76
|
+
if (selectedIds.length === threads.length) {
|
|
77
|
+
onSelectionChange?.([]);
|
|
78
|
+
} else {
|
|
79
|
+
onSelectionChange?.(threads.map((t) => t.id));
|
|
80
|
+
}
|
|
81
|
+
}, [selectionMode, selectedIds, threads, onSelectionChange]);
|
|
82
|
+
|
|
83
|
+
const handleKeyDown = React.useCallback(
|
|
84
|
+
(e: KeyboardEvent) => {
|
|
85
|
+
if (!threads.length) return;
|
|
86
|
+
|
|
87
|
+
switch (e.key) {
|
|
88
|
+
case "ArrowDown":
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
setFocusedIndex((prev) => Math.min(prev + 1, threads.length - 1));
|
|
91
|
+
break;
|
|
92
|
+
case "ArrowUp":
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
setFocusedIndex((prev) => Math.max(prev - 1, 0));
|
|
95
|
+
break;
|
|
96
|
+
case "Enter":
|
|
97
|
+
if (focusedIndex >= 0 && focusedIndex < threads.length) {
|
|
98
|
+
onThreadClick?.(threads[focusedIndex]);
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
case " ":
|
|
102
|
+
if (selectionMode === "multiple" && focusedIndex >= 0) {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
handleSelect(threads[focusedIndex]);
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
case "Escape":
|
|
108
|
+
setFocusedIndex(-1);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
[threads, focusedIndex, selectionMode, onThreadClick, handleSelect],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Handle keyboard navigation
|
|
116
|
+
React.useEffect(() => {
|
|
117
|
+
if (!enableKeyboardNav || !listRef.current) return;
|
|
118
|
+
|
|
119
|
+
const list = listRef.current;
|
|
120
|
+
list.addEventListener("keydown", handleKeyDown);
|
|
121
|
+
return () => list.removeEventListener("keydown", handleKeyDown);
|
|
122
|
+
}, [enableKeyboardNav, handleKeyDown]);
|
|
123
|
+
|
|
124
|
+
// Scroll to load more
|
|
125
|
+
const handleScroll = React.useCallback(
|
|
126
|
+
(e: React.UIEvent<HTMLDivElement>) => {
|
|
127
|
+
if (!hasMore || isLoading || !onLoadMore) return;
|
|
128
|
+
|
|
129
|
+
const target = e.target as HTMLDivElement;
|
|
130
|
+
const scrollBottom =
|
|
131
|
+
target.scrollHeight - target.scrollTop - target.clientHeight;
|
|
132
|
+
|
|
133
|
+
if (scrollBottom < 200) {
|
|
134
|
+
onLoadMore();
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
[hasMore, isLoading, onLoadMore],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Check if all are selected
|
|
141
|
+
const allSelected =
|
|
142
|
+
threads.length > 0 && selectedIds.length === threads.length;
|
|
143
|
+
const someSelected = selectedIds.length > 0 && !allSelected;
|
|
144
|
+
|
|
145
|
+
// Empty state
|
|
146
|
+
if (!isLoading && threads.length === 0) {
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
data-slot="mail-list-empty"
|
|
150
|
+
className={cn(
|
|
151
|
+
"flex h-full flex-col items-center justify-center gap-4 p-8 text-center",
|
|
152
|
+
className,
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
<div className="bg-muted rounded-full p-4">
|
|
156
|
+
<Inbox className="text-muted-foreground size-8" />
|
|
157
|
+
</div>
|
|
158
|
+
<p className="text-muted-foreground text-lg">{emptyMessage}</p>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<ul
|
|
165
|
+
ref={listRef}
|
|
166
|
+
data-slot="mail-list"
|
|
167
|
+
tabIndex={enableKeyboardNav ? 0 : undefined}
|
|
168
|
+
className={cn("flex h-full flex-col", className)}
|
|
169
|
+
>
|
|
170
|
+
{/* Bulk Selection Header */}
|
|
171
|
+
{selectionMode === "multiple" && (
|
|
172
|
+
<div className="border-b bg-muted/30 flex items-center gap-3 px-4 py-2">
|
|
173
|
+
<Checkbox
|
|
174
|
+
checked={someSelected ? "indeterminate" : allSelected}
|
|
175
|
+
onCheckedChange={handleSelectAll}
|
|
176
|
+
aria-label="Select all"
|
|
177
|
+
/>
|
|
178
|
+
<span className="text-muted-foreground text-sm">
|
|
179
|
+
{selectedIds.length > 0
|
|
180
|
+
? `${selectedIds.length} selected`
|
|
181
|
+
: "Select all"}
|
|
182
|
+
</span>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{/* Thread List */}
|
|
187
|
+
<ScrollArea className="flex-1" onScroll={handleScroll}>
|
|
188
|
+
<div className="divide-y">
|
|
189
|
+
{threads.map((thread, index) => (
|
|
190
|
+
<MailItem
|
|
191
|
+
key={thread.id}
|
|
192
|
+
thread={thread}
|
|
193
|
+
isSelected={selectedIds.includes(thread.id)}
|
|
194
|
+
isFocused={focusedIndex === index}
|
|
195
|
+
onClick={() => {
|
|
196
|
+
setFocusedIndex(index);
|
|
197
|
+
onThreadClick?.(thread);
|
|
198
|
+
}}
|
|
199
|
+
onSelect={() => handleSelect(thread)}
|
|
200
|
+
onStar={() => onStar?.(thread.id)}
|
|
201
|
+
compact={compact}
|
|
202
|
+
showCheckbox={selectionMode === "multiple"}
|
|
203
|
+
showAvatar={showAvatars}
|
|
204
|
+
highlightTerms={highlightTerms}
|
|
205
|
+
/>
|
|
206
|
+
))}
|
|
207
|
+
|
|
208
|
+
{/* Loading More Indicator */}
|
|
209
|
+
{isLoading && threads.length > 0 && (
|
|
210
|
+
<div className="flex items-center justify-center py-4">
|
|
211
|
+
<Loader2 className="text-muted-foreground size-5 animate-spin" />
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
</ScrollArea>
|
|
216
|
+
|
|
217
|
+
{/* Initial Loading State */}
|
|
218
|
+
{isLoading && threads.length === 0 && (
|
|
219
|
+
<MailSkeleton count={8} displayMode={displayMode} />
|
|
220
|
+
)}
|
|
221
|
+
</ul>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* MailSkeleton - Loading skeleton for mail list.
|
|
227
|
+
*/
|
|
228
|
+
function MailSkeleton({
|
|
229
|
+
count = 5,
|
|
230
|
+
displayMode = "comfortable",
|
|
231
|
+
className,
|
|
232
|
+
}: MailSkeletonProps) {
|
|
233
|
+
const compact = displayMode === "compact" || displayMode === "condensed";
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div data-slot="mail-skeleton" className={cn("divide-y", className)}>
|
|
237
|
+
{Array.from({ length: count }).map((_, i) => (
|
|
238
|
+
<div
|
|
239
|
+
key={i.toString()}
|
|
240
|
+
className={cn(
|
|
241
|
+
"flex items-start gap-3 px-4",
|
|
242
|
+
compact ? "py-2" : "py-3",
|
|
243
|
+
)}
|
|
244
|
+
>
|
|
245
|
+
<Skeleton
|
|
246
|
+
className={cn("rounded-full", compact ? "size-8" : "size-10")}
|
|
247
|
+
/>
|
|
248
|
+
<div className="flex-1 space-y-2">
|
|
249
|
+
<div className="flex items-center justify-between">
|
|
250
|
+
<Skeleton className="h-4 w-32" />
|
|
251
|
+
<Skeleton className="h-3 w-16" />
|
|
252
|
+
</div>
|
|
253
|
+
<Skeleton className="h-4 w-48" />
|
|
254
|
+
{!compact && <Skeleton className="h-3 w-full" />}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export { MailList, MailSkeleton };
|