@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,422 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { MailThread as Thread } from "mdxui";
3
+ import { MailItem } from "./mail-item";
4
+
5
+ // Sample thread data for testing different MailItem states
6
+ const unreadThread: Thread = {
7
+ id: "1",
8
+ subject: "Weekly Team Sync - Q4 Planning",
9
+ messages: [
10
+ {
11
+ id: "m1",
12
+ threadId: "1",
13
+ subject: "Weekly Team Sync - Q4 Planning",
14
+ from: { address: "sarah@company.com", name: "Sarah Chen" },
15
+ to: [{ address: "team@company.com", name: "Engineering Team" }],
16
+ date: new Date(Date.now() - 3600000).toISOString(),
17
+ snippet: "Hey team, let's discuss our Q4 roadmap priorities...",
18
+ isRead: false,
19
+ isStarred: true,
20
+ isImportant: true,
21
+ isDraft: false,
22
+ attachments: [],
23
+ labels: [],
24
+ },
25
+ ],
26
+ latestDate: new Date(Date.now() - 3600000).toISOString(),
27
+ participants: [
28
+ { address: "sarah@company.com", name: "Sarah Chen" },
29
+ { address: "team@company.com", name: "Engineering Team" },
30
+ ],
31
+ unreadCount: 1,
32
+ labels: [{ id: "l1", name: "Work", color: "#3b82f6", type: "user" }],
33
+ isStarred: true,
34
+ isImportant: true,
35
+ hasAttachments: false,
36
+ snippet: "Hey team, let's discuss our Q4 roadmap priorities...",
37
+ };
38
+
39
+ const readThread: Thread = {
40
+ id: "2",
41
+ subject: "Re: Design Review - Homepage Redesign",
42
+ messages: [
43
+ {
44
+ id: "m2",
45
+ threadId: "2",
46
+ subject: "Re: Design Review - Homepage Redesign",
47
+ from: { address: "mike@design.co", name: "Mike Johnson" },
48
+ to: [{ address: "me@company.com", name: "Me" }],
49
+ date: new Date(Date.now() - 7200000).toISOString(),
50
+ snippet:
51
+ "I've updated the mockups based on your feedback. The new hero section...",
52
+ isRead: true,
53
+ isStarred: false,
54
+ isImportant: false,
55
+ isDraft: false,
56
+ attachments: [
57
+ {
58
+ id: "a1",
59
+ filename: "homepage-v3.fig",
60
+ mimeType: "application/figma",
61
+ size: 2400000,
62
+ inline: false,
63
+ },
64
+ ],
65
+ labels: [],
66
+ },
67
+ ],
68
+ latestDate: new Date(Date.now() - 7200000).toISOString(),
69
+ participants: [
70
+ { address: "mike@design.co", name: "Mike Johnson" },
71
+ { address: "me@company.com", name: "Me" },
72
+ ],
73
+ unreadCount: 0,
74
+ labels: [{ id: "l2", name: "Design", color: "#10b981", type: "user" }],
75
+ isStarred: false,
76
+ isImportant: false,
77
+ hasAttachments: true,
78
+ snippet:
79
+ "I've updated the mockups based on your feedback. The new hero section...",
80
+ };
81
+
82
+ const conversationThread: Thread = {
83
+ id: "3",
84
+ subject: "Project Timeline Discussion",
85
+ messages: [
86
+ {
87
+ id: "m3a",
88
+ threadId: "3",
89
+ subject: "Project Timeline Discussion",
90
+ from: { address: "alex@startup.io", name: "Alex Rivera" },
91
+ to: [{ address: "me@company.com", name: "Me" }],
92
+ date: new Date(Date.now() - 172800000).toISOString(),
93
+ snippet: "Initial message...",
94
+ isRead: true,
95
+ isStarred: false,
96
+ isImportant: false,
97
+ isDraft: false,
98
+ attachments: [],
99
+ labels: [],
100
+ },
101
+ {
102
+ id: "m3b",
103
+ threadId: "3",
104
+ subject: "Re: Project Timeline Discussion",
105
+ from: { address: "me@company.com", name: "Me" },
106
+ to: [{ address: "alex@startup.io", name: "Alex Rivera" }],
107
+ date: new Date(Date.now() - 86400000).toISOString(),
108
+ snippet: "My reply...",
109
+ isRead: true,
110
+ isStarred: false,
111
+ isImportant: false,
112
+ isDraft: false,
113
+ attachments: [],
114
+ labels: [],
115
+ },
116
+ {
117
+ id: "m3c",
118
+ threadId: "3",
119
+ subject: "Re: Project Timeline Discussion",
120
+ from: { address: "alex@startup.io", name: "Alex Rivera" },
121
+ to: [{ address: "me@company.com", name: "Me" }],
122
+ date: new Date(Date.now() - 43200000).toISOString(),
123
+ snippet: "Thanks for the update! I agree with your approach.",
124
+ isRead: false,
125
+ isStarred: false,
126
+ isImportant: true,
127
+ isDraft: false,
128
+ attachments: [],
129
+ labels: [],
130
+ },
131
+ ],
132
+ latestDate: new Date(Date.now() - 43200000).toISOString(),
133
+ participants: [
134
+ { address: "alex@startup.io", name: "Alex Rivera" },
135
+ { address: "me@company.com", name: "Me" },
136
+ ],
137
+ unreadCount: 1,
138
+ labels: [
139
+ { id: "l3", name: "Project", color: "#8b5cf6", type: "user" },
140
+ { id: "l4", name: "Urgent", color: "#ef4444", type: "user" },
141
+ ],
142
+ isStarred: false,
143
+ isImportant: true,
144
+ hasAttachments: false,
145
+ snippet: "Thanks for the update! I agree with your approach.",
146
+ };
147
+
148
+ const multiLabelThread: Thread = {
149
+ id: "4",
150
+ subject: "Invoice #12345 - Payment Confirmation",
151
+ messages: [
152
+ {
153
+ id: "m4",
154
+ threadId: "4",
155
+ subject: "Invoice #12345 - Payment Confirmation",
156
+ from: { address: "billing@stripe.com", name: "Stripe" },
157
+ to: [{ address: "me@company.com", name: "Me" }],
158
+ date: new Date(Date.now() - 86400000).toISOString(),
159
+ snippet: "Your payment of $299.00 has been successfully processed...",
160
+ isRead: true,
161
+ isStarred: false,
162
+ isImportant: false,
163
+ isDraft: false,
164
+ attachments: [
165
+ {
166
+ id: "a2",
167
+ filename: "invoice-12345.pdf",
168
+ mimeType: "application/pdf",
169
+ size: 45000,
170
+ inline: false,
171
+ },
172
+ ],
173
+ labels: [],
174
+ },
175
+ ],
176
+ latestDate: new Date(Date.now() - 86400000).toISOString(),
177
+ participants: [{ address: "billing@stripe.com", name: "Stripe" }],
178
+ unreadCount: 0,
179
+ labels: [
180
+ { id: "l5", name: "Finance", color: "#f59e0b", type: "user" },
181
+ { id: "l6", name: "Important", color: "#ef4444", type: "user" },
182
+ { id: "l7", name: "Archive", color: "#6b7280", type: "user" },
183
+ { id: "l8", name: "Tax", color: "#10b981", type: "user" },
184
+ { id: "l9", name: "Receipts", color: "#3b82f6", type: "user" },
185
+ ],
186
+ isStarred: false,
187
+ isImportant: false,
188
+ hasAttachments: true,
189
+ snippet: "Your payment of $299.00 has been successfully processed...",
190
+ };
191
+
192
+ const meta: Meta<typeof MailItem> = {
193
+ title: "Zero/Mail/MailItem",
194
+ component: MailItem,
195
+ parameters: {
196
+ layout: "centered",
197
+ },
198
+ tags: ["autodocs"],
199
+ argTypes: {
200
+ compact: {
201
+ control: "boolean",
202
+ },
203
+ showCheckbox: {
204
+ control: "boolean",
205
+ },
206
+ showAvatar: {
207
+ control: "boolean",
208
+ },
209
+ isSelected: {
210
+ control: "boolean",
211
+ },
212
+ isFocused: {
213
+ control: "boolean",
214
+ },
215
+ },
216
+ decorators: [
217
+ (Story) => (
218
+ <div className="w-full max-w-2xl border bg-background">
219
+ <Story />
220
+ </div>
221
+ ),
222
+ ],
223
+ };
224
+
225
+ export default meta;
226
+ type Story = StoryObj<typeof meta>;
227
+
228
+ /**
229
+ * Default unread mail item with star and label.
230
+ * Shows checkbox on hover, avatar, subject, snippet, and timestamp.
231
+ */
232
+ export const Default: Story = {
233
+ args: {
234
+ thread: unreadThread,
235
+ isSelected: false,
236
+ isFocused: false,
237
+ showCheckbox: true,
238
+ showAvatar: true,
239
+ compact: false,
240
+ },
241
+ };
242
+
243
+ /**
244
+ * Read mail item without star.
245
+ * Shows muted appearance for read messages.
246
+ */
247
+ export const Read: Story = {
248
+ args: {
249
+ thread: readThread,
250
+ isSelected: false,
251
+ isFocused: false,
252
+ showCheckbox: true,
253
+ showAvatar: true,
254
+ compact: false,
255
+ },
256
+ };
257
+
258
+ /**
259
+ * Selected mail item with checkbox visible.
260
+ * Highlights the item with accent background.
261
+ */
262
+ export const Selected: Story = {
263
+ args: {
264
+ thread: unreadThread,
265
+ isSelected: true,
266
+ isFocused: false,
267
+ showCheckbox: true,
268
+ showAvatar: true,
269
+ compact: false,
270
+ },
271
+ };
272
+
273
+ /**
274
+ * Focused mail item with keyboard navigation.
275
+ * Shows ring outline for accessibility.
276
+ */
277
+ export const Focused: Story = {
278
+ args: {
279
+ thread: unreadThread,
280
+ isSelected: false,
281
+ isFocused: true,
282
+ showCheckbox: true,
283
+ showAvatar: true,
284
+ compact: false,
285
+ },
286
+ };
287
+
288
+ /**
289
+ * Compact display mode for higher density.
290
+ * Reduced padding and no snippet shown.
291
+ */
292
+ export const Compact: Story = {
293
+ args: {
294
+ thread: unreadThread,
295
+ isSelected: false,
296
+ isFocused: false,
297
+ showCheckbox: true,
298
+ showAvatar: true,
299
+ compact: true,
300
+ },
301
+ };
302
+
303
+ /**
304
+ * Mail thread with multiple messages.
305
+ * Shows message count next to sender name.
306
+ */
307
+ export const Conversation: Story = {
308
+ args: {
309
+ thread: conversationThread,
310
+ isSelected: false,
311
+ isFocused: false,
312
+ showCheckbox: true,
313
+ showAvatar: true,
314
+ compact: false,
315
+ },
316
+ };
317
+
318
+ /**
319
+ * Mail item with attachment indicator.
320
+ * Shows paperclip icon.
321
+ */
322
+ export const WithAttachment: Story = {
323
+ args: {
324
+ thread: readThread,
325
+ isSelected: false,
326
+ isFocused: false,
327
+ showCheckbox: true,
328
+ showAvatar: true,
329
+ compact: false,
330
+ },
331
+ };
332
+
333
+ /**
334
+ * Mail item with multiple labels.
335
+ * Shows first 3 labels with "+N more" badge.
336
+ */
337
+ export const MultipleLabels: Story = {
338
+ args: {
339
+ thread: multiLabelThread,
340
+ isSelected: false,
341
+ isFocused: false,
342
+ showCheckbox: true,
343
+ showAvatar: true,
344
+ compact: false,
345
+ },
346
+ };
347
+
348
+ /**
349
+ * Without checkbox control.
350
+ * Simpler view for read-only lists.
351
+ */
352
+ export const NoCheckbox: Story = {
353
+ args: {
354
+ thread: unreadThread,
355
+ isSelected: false,
356
+ isFocused: false,
357
+ showCheckbox: false,
358
+ showAvatar: true,
359
+ compact: false,
360
+ },
361
+ };
362
+
363
+ /**
364
+ * Without avatar display.
365
+ * More compact horizontal layout.
366
+ */
367
+ export const NoAvatar: Story = {
368
+ args: {
369
+ thread: unreadThread,
370
+ isSelected: false,
371
+ isFocused: false,
372
+ showCheckbox: true,
373
+ showAvatar: false,
374
+ compact: false,
375
+ },
376
+ };
377
+
378
+ /**
379
+ * With search term highlighting.
380
+ * Highlights matching text in subject and snippet.
381
+ */
382
+ export const WithHighlight: Story = {
383
+ args: {
384
+ thread: unreadThread,
385
+ isSelected: false,
386
+ isFocused: false,
387
+ showCheckbox: true,
388
+ showAvatar: true,
389
+ compact: false,
390
+ highlightTerms: ["Q4", "roadmap"],
391
+ },
392
+ };
393
+
394
+ /**
395
+ * Selected and focused together.
396
+ * Common state when navigating with keyboard.
397
+ */
398
+ export const SelectedAndFocused: Story = {
399
+ args: {
400
+ thread: unreadThread,
401
+ isSelected: true,
402
+ isFocused: true,
403
+ showCheckbox: true,
404
+ showAvatar: true,
405
+ compact: false,
406
+ },
407
+ };
408
+
409
+ /**
410
+ * Minimal configuration.
411
+ * Compact, no checkbox, no avatar.
412
+ */
413
+ export const Minimal: Story = {
414
+ args: {
415
+ thread: readThread,
416
+ isSelected: false,
417
+ isFocused: false,
418
+ showCheckbox: false,
419
+ showAvatar: false,
420
+ compact: true,
421
+ },
422
+ };
@@ -0,0 +1,229 @@
1
+ import { Avatar, AvatarFallback, AvatarImage } from "@mdxui/primitives/avatar";
2
+ import { Badge } from "@mdxui/primitives/badge";
3
+ import { Checkbox } from "@mdxui/primitives/checkbox";
4
+ import { cn } from "@mdxui/primitives/lib/utils";
5
+ import { Paperclip, Star } from "lucide-react";
6
+ import type { MailItemProps } from "mdxui";
7
+ import type * as React from "react";
8
+
9
+ /**
10
+ * MailItem - Individual email thread item in the mail list.
11
+ *
12
+ * Displays:
13
+ * - Selection checkbox
14
+ * - Sender avatar
15
+ * - Subject and snippet
16
+ * - Labels/tags
17
+ * - Star indicator
18
+ * - Attachment indicator
19
+ * - Timestamp
20
+ */
21
+ function MailItem({
22
+ thread,
23
+ isSelected = false,
24
+ isFocused = false,
25
+ onClick,
26
+ onSelect,
27
+ onStar,
28
+ compact = false,
29
+ showCheckbox = true,
30
+ showAvatar = true,
31
+ highlightTerms,
32
+ className,
33
+ }: MailItemProps) {
34
+ const latestMessage = thread.messages[thread.messages.length - 1];
35
+ const sender = latestMessage?.from;
36
+ const isUnread = thread.unreadCount > 0;
37
+
38
+ // Format date relative to now
39
+ const formatDate = (dateStr: string) => {
40
+ const date = new Date(dateStr);
41
+ const now = new Date();
42
+ const diffMs = now.getTime() - date.getTime();
43
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
44
+
45
+ if (diffDays === 0) {
46
+ return date.toLocaleTimeString([], {
47
+ hour: "numeric",
48
+ minute: "2-digit",
49
+ });
50
+ } else if (diffDays === 1) {
51
+ return "Yesterday";
52
+ } else if (diffDays < 7) {
53
+ return date.toLocaleDateString([], { weekday: "short" });
54
+ } else {
55
+ return date.toLocaleDateString([], { month: "short", day: "numeric" });
56
+ }
57
+ };
58
+
59
+ // Get initials for avatar fallback
60
+ const getInitials = (name?: string, email?: string) => {
61
+ if (name) {
62
+ return name
63
+ .split(" ")
64
+ .map((n) => n[0])
65
+ .join("")
66
+ .toUpperCase()
67
+ .slice(0, 2);
68
+ }
69
+ return email?.charAt(0).toUpperCase() || "?";
70
+ };
71
+
72
+ // Highlight search terms in text
73
+ const highlightText = (text: string) => {
74
+ if (!highlightTerms?.length) return text;
75
+ const regex = new RegExp(`(${highlightTerms.join("|")})`, "gi");
76
+ return text.replace(
77
+ regex,
78
+ '<mark class="bg-yellow-200 dark:bg-yellow-800">$1</mark>',
79
+ );
80
+ };
81
+
82
+ const handleKeyDown = (e: React.KeyboardEvent) => {
83
+ if (e.key === "Enter" || e.key === " ") {
84
+ e.preventDefault();
85
+ onClick?.();
86
+ }
87
+ };
88
+
89
+ return (
90
+ // biome-ignore lint/a11y/useSemanticElements: MailItem contains nested interactive elements (checkbox, star button) which can't be inside a <button>
91
+ <div
92
+ role="button"
93
+ data-slot="mail-item"
94
+ data-selected={isSelected || undefined}
95
+ tabIndex={0}
96
+ onClick={() => onClick?.()}
97
+ onKeyDown={handleKeyDown}
98
+ className={cn(
99
+ "group relative flex cursor-pointer items-start gap-3 border-b px-4 transition-colors",
100
+ compact ? "py-2" : "py-3",
101
+ isUnread && "bg-muted/50",
102
+ isSelected && "bg-accent",
103
+ isFocused && "ring-2 ring-ring ring-inset",
104
+ "hover:bg-accent/50",
105
+ className,
106
+ )}
107
+ >
108
+ {/* Selection Checkbox */}
109
+ {showCheckbox && (
110
+ <div className="flex items-center pt-1">
111
+ <Checkbox
112
+ checked={isSelected}
113
+ onCheckedChange={() => onSelect?.()}
114
+ onClick={(e) => e.stopPropagation()}
115
+ className="opacity-0 group-hover:opacity-100 data-[state=checked]:opacity-100"
116
+ />
117
+ </div>
118
+ )}
119
+
120
+ {/* Avatar */}
121
+ {showAvatar && (
122
+ <Avatar className={cn("shrink-0", compact ? "size-8" : "size-10")}>
123
+ <AvatarImage src={undefined} alt={sender?.name || sender?.address} />
124
+ <AvatarFallback className={cn(isUnread && "font-semibold")}>
125
+ {getInitials(sender?.name, sender?.address)}
126
+ </AvatarFallback>
127
+ </Avatar>
128
+ )}
129
+
130
+ {/* Content */}
131
+ <div className="min-w-0 flex-1">
132
+ {/* Header Row */}
133
+ <div className="flex items-center justify-between gap-2">
134
+ <span
135
+ className={cn(
136
+ "truncate text-sm",
137
+ isUnread ? "font-semibold" : "text-muted-foreground",
138
+ )}
139
+ >
140
+ {sender?.name || sender?.address}
141
+ {thread.messages.length > 1 && (
142
+ <span className="text-muted-foreground ml-1 font-normal">
143
+ ({thread.messages.length})
144
+ </span>
145
+ )}
146
+ </span>
147
+ <span className="text-muted-foreground shrink-0 text-xs">
148
+ {formatDate(thread.latestDate)}
149
+ </span>
150
+ </div>
151
+
152
+ {/* Subject */}
153
+ <div
154
+ className={cn(
155
+ "truncate text-sm",
156
+ isUnread ? "font-medium" : "text-muted-foreground",
157
+ )}
158
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: MailItem is a trusted component
159
+ dangerouslySetInnerHTML={{
160
+ __html: highlightText(thread.subject || "(no subject)"),
161
+ }}
162
+ />
163
+
164
+ {/* Snippet */}
165
+ {!compact && thread.snippet && (
166
+ <div
167
+ className="text-muted-foreground line-clamp-1 text-xs"
168
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: MailItem is a trusted component
169
+ dangerouslySetInnerHTML={{ __html: highlightText(thread.snippet) }}
170
+ />
171
+ )}
172
+
173
+ {/* Labels */}
174
+ {thread.labels.length > 0 && (
175
+ <div className="mt-1 flex flex-wrap gap-1">
176
+ {thread.labels.slice(0, 3).map((label) => (
177
+ <Badge
178
+ key={label.id}
179
+ variant="secondary"
180
+ className="text-xs"
181
+ style={
182
+ label.color ? { backgroundColor: label.color } : undefined
183
+ }
184
+ >
185
+ {label.name}
186
+ </Badge>
187
+ ))}
188
+ {thread.labels.length > 3 && (
189
+ <Badge variant="outline" className="text-xs">
190
+ +{thread.labels.length - 3}
191
+ </Badge>
192
+ )}
193
+ </div>
194
+ )}
195
+ </div>
196
+
197
+ {/* Action Indicators */}
198
+ <div className="flex shrink-0 items-center gap-1 pt-1">
199
+ {/* Attachment indicator */}
200
+ {thread.hasAttachments && (
201
+ <Paperclip className="text-muted-foreground size-4" />
202
+ )}
203
+
204
+ {/* Star button */}
205
+ <button
206
+ type="button"
207
+ onClick={(e) => {
208
+ e.stopPropagation();
209
+ onStar?.();
210
+ }}
211
+ className={cn(
212
+ "rounded p-1 transition-colors hover:bg-accent",
213
+ thread.isStarred
214
+ ? "text-yellow-500"
215
+ : "text-muted-foreground opacity-0 group-hover:opacity-100",
216
+ )}
217
+ aria-label={thread.isStarred ? "Unstar" : "Star"}
218
+ >
219
+ <Star
220
+ className="size-4"
221
+ fill={thread.isStarred ? "currentColor" : "none"}
222
+ />
223
+ </button>
224
+ </div>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ export { MailItem };