@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.
@@ -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 };