@papernote/ui 1.8.2 → 1.9.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/dist/components/NotificationBell.d.ts +104 -0
- package/dist/components/NotificationBell.d.ts.map +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +106 -2
- package/dist/index.esm.js +236 -47
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +235 -45
- package/dist/index.js.map +1 -1
- package/dist/styles.css +21 -0
- package/package.json +1 -1
- package/src/components/Layout.tsx +1 -1
- package/src/components/NotificationBell.stories.tsx +717 -0
- package/src/components/NotificationBell.tsx +556 -0
- package/src/components/index.ts +4 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import React, { useState, useCallback, useMemo } from 'react';
|
|
2
|
+
import { Bell, Check, ExternalLink } from 'lucide-react';
|
|
3
|
+
import Popover from './Popover';
|
|
4
|
+
import Button from './Button';
|
|
5
|
+
import Stack from './Stack';
|
|
6
|
+
import Text from './Text';
|
|
7
|
+
import Badge from './Badge';
|
|
8
|
+
import { Skeleton } from './Loading';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Represents a single notification item
|
|
12
|
+
*/
|
|
13
|
+
export interface NotificationItem {
|
|
14
|
+
/** Unique identifier for the notification */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Title of the notification */
|
|
17
|
+
title: string;
|
|
18
|
+
/** Message body of the notification */
|
|
19
|
+
message: string;
|
|
20
|
+
/** Type determines the color coding */
|
|
21
|
+
type: 'info' | 'success' | 'warning' | 'error';
|
|
22
|
+
/** Priority affects visual treatment */
|
|
23
|
+
priority: 'low' | 'normal' | 'high' | 'urgent';
|
|
24
|
+
/** When the notification was created */
|
|
25
|
+
createdAt: string | Date;
|
|
26
|
+
/** Whether the notification has been read */
|
|
27
|
+
isRead: boolean;
|
|
28
|
+
/** Optional URL to navigate to when clicked */
|
|
29
|
+
actionUrl?: string;
|
|
30
|
+
/** Optional custom label for the type badge (used in detailed variant) */
|
|
31
|
+
typeLabel?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Dropdown position options */
|
|
35
|
+
export type NotificationBellPosition =
|
|
36
|
+
| 'bottom-left'
|
|
37
|
+
| 'bottom-right'
|
|
38
|
+
| 'top-left'
|
|
39
|
+
| 'top-right'
|
|
40
|
+
| 'left' // Alias for bottom-left (legacy)
|
|
41
|
+
| 'right'; // Alias for bottom-right (legacy)
|
|
42
|
+
|
|
43
|
+
/** Bell button style options */
|
|
44
|
+
export type NotificationBellStyle = 'ghost' | 'outlined';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* NotificationBell component props
|
|
48
|
+
*/
|
|
49
|
+
export interface NotificationBellProps {
|
|
50
|
+
/** List of notifications to display */
|
|
51
|
+
notifications: NotificationItem[];
|
|
52
|
+
/** Number of unread notifications (shown in badge). If not provided, calculated from notifications */
|
|
53
|
+
unreadCount?: number;
|
|
54
|
+
/** Callback when marking a single notification as read */
|
|
55
|
+
onMarkAsRead?: (id: string) => void;
|
|
56
|
+
/** Callback when marking all notifications as read */
|
|
57
|
+
onMarkAllRead?: () => void;
|
|
58
|
+
/** Callback when clicking a notification */
|
|
59
|
+
onNotificationClick?: (notification: NotificationItem) => void;
|
|
60
|
+
/** Callback when clicking "View All" */
|
|
61
|
+
onViewAll?: () => void;
|
|
62
|
+
/** Show loading state */
|
|
63
|
+
loading?: boolean;
|
|
64
|
+
/** Position of the dropdown relative to the bell */
|
|
65
|
+
dropdownPosition?: NotificationBellPosition;
|
|
66
|
+
/** Maximum height of notification list before scrolling */
|
|
67
|
+
maxHeight?: string;
|
|
68
|
+
/** Size of the bell button */
|
|
69
|
+
size?: 'sm' | 'md' | 'lg';
|
|
70
|
+
/** Empty state message */
|
|
71
|
+
emptyMessage?: string;
|
|
72
|
+
/** "View All" button text */
|
|
73
|
+
viewAllText?: string;
|
|
74
|
+
/** Disabled state */
|
|
75
|
+
disabled?: boolean;
|
|
76
|
+
/** Additional class name for the bell button */
|
|
77
|
+
className?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Visual variant for notification items
|
|
80
|
+
* - 'compact': Dot indicator, stacked layout (default)
|
|
81
|
+
* - 'detailed': Labeled type badge, time aligned right
|
|
82
|
+
*/
|
|
83
|
+
variant?: 'compact' | 'detailed';
|
|
84
|
+
/** Show unread count in header (e.g., "Notifications (2 unread)") */
|
|
85
|
+
showUnreadInHeader?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Style of the bell button
|
|
88
|
+
* - 'ghost': Transparent background, just the icon (default)
|
|
89
|
+
* - 'outlined': Visible border/container with subtle background
|
|
90
|
+
*/
|
|
91
|
+
bellStyle?: NotificationBellStyle;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Format a date to a relative time string
|
|
96
|
+
*/
|
|
97
|
+
function formatTimeAgo(date: string | Date): string {
|
|
98
|
+
const now = new Date();
|
|
99
|
+
const then = new Date(date);
|
|
100
|
+
const diffMs = now.getTime() - then.getTime();
|
|
101
|
+
const diffSeconds = Math.floor(diffMs / 1000);
|
|
102
|
+
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
103
|
+
const diffHours = Math.floor(diffMinutes / 60);
|
|
104
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
105
|
+
|
|
106
|
+
if (diffSeconds < 60) {
|
|
107
|
+
return 'Just now';
|
|
108
|
+
} else if (diffMinutes < 60) {
|
|
109
|
+
return `${diffMinutes}m ago`;
|
|
110
|
+
} else if (diffHours < 24) {
|
|
111
|
+
return `${diffHours}h ago`;
|
|
112
|
+
} else if (diffDays < 7) {
|
|
113
|
+
return `${diffDays}d ago`;
|
|
114
|
+
} else {
|
|
115
|
+
return then.toLocaleDateString();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Map notification type to Badge variant
|
|
121
|
+
*/
|
|
122
|
+
const typeToBadgeVariant: Record<NotificationItem['type'], 'info' | 'success' | 'warning' | 'error'> = {
|
|
123
|
+
info: 'info',
|
|
124
|
+
success: 'success',
|
|
125
|
+
warning: 'warning',
|
|
126
|
+
error: 'error',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Default labels for notification types
|
|
131
|
+
*/
|
|
132
|
+
const defaultTypeLabels: Record<NotificationItem['type'], string> = {
|
|
133
|
+
info: 'Info',
|
|
134
|
+
success: 'Success',
|
|
135
|
+
warning: 'Warning',
|
|
136
|
+
error: 'Alert',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Map dropdown position to Popover placement
|
|
141
|
+
*/
|
|
142
|
+
function getPopoverPlacement(position: NotificationBellPosition): 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' {
|
|
143
|
+
switch (position) {
|
|
144
|
+
case 'bottom-right':
|
|
145
|
+
case 'right':
|
|
146
|
+
return 'bottom-start';
|
|
147
|
+
case 'bottom-left':
|
|
148
|
+
case 'left':
|
|
149
|
+
return 'bottom-end';
|
|
150
|
+
case 'top-right':
|
|
151
|
+
return 'top-start';
|
|
152
|
+
case 'top-left':
|
|
153
|
+
return 'top-end';
|
|
154
|
+
default:
|
|
155
|
+
return 'bottom-start';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* NotificationBell - A bell icon with badge and dropdown for displaying notifications
|
|
161
|
+
*
|
|
162
|
+
* Displays a bell icon with an optional unread count badge. When clicked, shows a
|
|
163
|
+
* dropdown panel with recent notifications, mark as read actions, and a link to
|
|
164
|
+
* view all notifications.
|
|
165
|
+
*
|
|
166
|
+
* @example Basic usage (compact variant)
|
|
167
|
+
* ```tsx
|
|
168
|
+
* <NotificationBell
|
|
169
|
+
* notifications={notifications}
|
|
170
|
+
* onMarkAsRead={(id) => markRead(id)}
|
|
171
|
+
* onMarkAllRead={() => markAllRead()}
|
|
172
|
+
* onNotificationClick={(n) => navigate(n.actionUrl)}
|
|
173
|
+
* onViewAll={() => navigate('/notifications')}
|
|
174
|
+
* />
|
|
175
|
+
* ```
|
|
176
|
+
*
|
|
177
|
+
* @example Detailed variant with labeled badges
|
|
178
|
+
* ```tsx
|
|
179
|
+
* <NotificationBell
|
|
180
|
+
* notifications={notifications}
|
|
181
|
+
* variant="detailed"
|
|
182
|
+
* showUnreadInHeader
|
|
183
|
+
* dropdownPosition="bottom-left"
|
|
184
|
+
* />
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export default function NotificationBell({
|
|
188
|
+
notifications,
|
|
189
|
+
unreadCount: providedUnreadCount,
|
|
190
|
+
onMarkAsRead,
|
|
191
|
+
onMarkAllRead,
|
|
192
|
+
onNotificationClick,
|
|
193
|
+
onViewAll,
|
|
194
|
+
loading = false,
|
|
195
|
+
dropdownPosition = 'bottom-right',
|
|
196
|
+
maxHeight = '400px',
|
|
197
|
+
size = 'md',
|
|
198
|
+
emptyMessage = 'No notifications',
|
|
199
|
+
viewAllText = 'View all notifications',
|
|
200
|
+
disabled = false,
|
|
201
|
+
className = '',
|
|
202
|
+
variant = 'compact',
|
|
203
|
+
showUnreadInHeader = false,
|
|
204
|
+
bellStyle = 'ghost',
|
|
205
|
+
}: NotificationBellProps) {
|
|
206
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
207
|
+
|
|
208
|
+
// Calculate unread count if not provided
|
|
209
|
+
const unreadCount = useMemo(() => {
|
|
210
|
+
if (providedUnreadCount !== undefined) {
|
|
211
|
+
return providedUnreadCount;
|
|
212
|
+
}
|
|
213
|
+
return notifications.filter((n) => !n.isRead).length;
|
|
214
|
+
}, [providedUnreadCount, notifications]);
|
|
215
|
+
|
|
216
|
+
// Handle notification click
|
|
217
|
+
const handleNotificationClick = useCallback(
|
|
218
|
+
(notification: NotificationItem) => {
|
|
219
|
+
onNotificationClick?.(notification);
|
|
220
|
+
},
|
|
221
|
+
[onNotificationClick]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Handle mark as read
|
|
225
|
+
const handleMarkAsRead = useCallback(
|
|
226
|
+
(e: React.MouseEvent, id: string) => {
|
|
227
|
+
e.stopPropagation();
|
|
228
|
+
onMarkAsRead?.(id);
|
|
229
|
+
},
|
|
230
|
+
[onMarkAsRead]
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Handle mark all as read
|
|
234
|
+
const handleMarkAllRead = useCallback(() => {
|
|
235
|
+
onMarkAllRead?.();
|
|
236
|
+
}, [onMarkAllRead]);
|
|
237
|
+
|
|
238
|
+
// Handle view all
|
|
239
|
+
const handleViewAll = useCallback(() => {
|
|
240
|
+
onViewAll?.();
|
|
241
|
+
setIsOpen(false);
|
|
242
|
+
}, [onViewAll]);
|
|
243
|
+
|
|
244
|
+
// Icon sizes based on button size
|
|
245
|
+
const iconSizes = {
|
|
246
|
+
sm: 'h-4 w-4',
|
|
247
|
+
md: 'h-5 w-5',
|
|
248
|
+
lg: 'h-6 w-6',
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Dropdown width based on size
|
|
252
|
+
const dropdownWidths = {
|
|
253
|
+
sm: 'w-72',
|
|
254
|
+
md: 'w-80',
|
|
255
|
+
lg: 'w-96',
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Outlined bell style classes
|
|
259
|
+
const outlinedSizeClasses = {
|
|
260
|
+
sm: 'p-2',
|
|
261
|
+
md: 'p-3',
|
|
262
|
+
lg: 'p-4',
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Trigger button
|
|
266
|
+
const triggerButton = bellStyle === 'outlined' ? (
|
|
267
|
+
<div className="relative inline-block">
|
|
268
|
+
<button
|
|
269
|
+
className={`
|
|
270
|
+
${outlinedSizeClasses[size]}
|
|
271
|
+
bg-white border-2 border-paper-300 rounded-xl
|
|
272
|
+
hover:bg-paper-50 hover:border-paper-400
|
|
273
|
+
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-400
|
|
274
|
+
transition-all duration-200
|
|
275
|
+
disabled:opacity-40 disabled:cursor-not-allowed
|
|
276
|
+
${className}
|
|
277
|
+
`}
|
|
278
|
+
disabled={disabled}
|
|
279
|
+
aria-label={
|
|
280
|
+
unreadCount > 0
|
|
281
|
+
? `Notifications - ${unreadCount} unread`
|
|
282
|
+
: 'Notifications'
|
|
283
|
+
}
|
|
284
|
+
>
|
|
285
|
+
<Bell className={`${iconSizes[size]} text-ink-600`} />
|
|
286
|
+
</button>
|
|
287
|
+
{unreadCount > 0 && (
|
|
288
|
+
<span
|
|
289
|
+
className={`
|
|
290
|
+
absolute -top-1 -right-1
|
|
291
|
+
flex items-center justify-center
|
|
292
|
+
min-w-[18px] h-[18px] px-1.5
|
|
293
|
+
rounded-full text-white font-semibold text-[11px]
|
|
294
|
+
bg-error-500 shadow-sm
|
|
295
|
+
pointer-events-none
|
|
296
|
+
`}
|
|
297
|
+
aria-label={`${unreadCount > 99 ? '99+' : unreadCount} notifications`}
|
|
298
|
+
>
|
|
299
|
+
{unreadCount > 99 ? '99+' : unreadCount}
|
|
300
|
+
</span>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
) : (
|
|
304
|
+
<Button
|
|
305
|
+
variant="ghost"
|
|
306
|
+
iconOnly
|
|
307
|
+
size={size}
|
|
308
|
+
disabled={disabled}
|
|
309
|
+
badge={unreadCount > 0 ? unreadCount : undefined}
|
|
310
|
+
badgeVariant="error"
|
|
311
|
+
aria-label={
|
|
312
|
+
unreadCount > 0
|
|
313
|
+
? `Notifications - ${unreadCount} unread`
|
|
314
|
+
: 'Notifications'
|
|
315
|
+
}
|
|
316
|
+
className={className}
|
|
317
|
+
>
|
|
318
|
+
<Bell className={iconSizes[size]} />
|
|
319
|
+
</Button>
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// Header title with optional unread count
|
|
323
|
+
const headerTitle = showUnreadInHeader && unreadCount > 0
|
|
324
|
+
? `Notifications (${unreadCount} unread)`
|
|
325
|
+
: 'Notifications';
|
|
326
|
+
|
|
327
|
+
// Render compact notification item
|
|
328
|
+
const renderCompactItem = (notification: NotificationItem) => (
|
|
329
|
+
<div className="flex gap-3">
|
|
330
|
+
{/* Type indicator */}
|
|
331
|
+
<div className="flex-shrink-0 pt-1">
|
|
332
|
+
<Badge
|
|
333
|
+
dot
|
|
334
|
+
variant={typeToBadgeVariant[notification.type]}
|
|
335
|
+
size="sm"
|
|
336
|
+
/>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
{/* Content */}
|
|
340
|
+
<div className="flex-1 min-w-0">
|
|
341
|
+
<Text
|
|
342
|
+
size="sm"
|
|
343
|
+
weight={
|
|
344
|
+
notification.priority === 'high' ||
|
|
345
|
+
notification.priority === 'urgent' ||
|
|
346
|
+
!notification.isRead
|
|
347
|
+
? 'medium'
|
|
348
|
+
: 'normal'
|
|
349
|
+
}
|
|
350
|
+
truncate
|
|
351
|
+
>
|
|
352
|
+
{notification.title}
|
|
353
|
+
</Text>
|
|
354
|
+
<Text size="xs" color="muted" lineClamp={2} className="mt-0.5">
|
|
355
|
+
{notification.message}
|
|
356
|
+
</Text>
|
|
357
|
+
<Text size="xs" color="muted" className="mt-1">
|
|
358
|
+
{formatTimeAgo(notification.createdAt)}
|
|
359
|
+
</Text>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{/* Mark as read button */}
|
|
363
|
+
{!notification.isRead && onMarkAsRead && (
|
|
364
|
+
<button
|
|
365
|
+
className="flex-shrink-0 p-1 text-ink-400 hover:text-ink-600 hover:bg-paper-100 rounded transition-colors"
|
|
366
|
+
onClick={(e) => handleMarkAsRead(e, notification.id)}
|
|
367
|
+
aria-label="Mark as read"
|
|
368
|
+
title="Mark as read"
|
|
369
|
+
>
|
|
370
|
+
<Check className="h-4 w-4" />
|
|
371
|
+
</button>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
// Render detailed notification item
|
|
377
|
+
const renderDetailedItem = (notification: NotificationItem) => (
|
|
378
|
+
<div className="flex gap-3">
|
|
379
|
+
{/* Content */}
|
|
380
|
+
<div className="flex-1 min-w-0">
|
|
381
|
+
{/* Title row with time */}
|
|
382
|
+
<div className="flex items-start justify-between gap-2">
|
|
383
|
+
<Text
|
|
384
|
+
size="sm"
|
|
385
|
+
weight={
|
|
386
|
+
notification.priority === 'high' ||
|
|
387
|
+
notification.priority === 'urgent' ||
|
|
388
|
+
!notification.isRead
|
|
389
|
+
? 'semibold'
|
|
390
|
+
: 'medium'
|
|
391
|
+
}
|
|
392
|
+
className="flex-1"
|
|
393
|
+
>
|
|
394
|
+
{notification.title}
|
|
395
|
+
</Text>
|
|
396
|
+
<Text size="xs" color="muted" className="flex-shrink-0 whitespace-nowrap">
|
|
397
|
+
{formatTimeAgo(notification.createdAt)}
|
|
398
|
+
</Text>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
{/* Message */}
|
|
402
|
+
<Text size="xs" color="muted" lineClamp={2} className="mt-1">
|
|
403
|
+
{notification.message}
|
|
404
|
+
</Text>
|
|
405
|
+
|
|
406
|
+
{/* Type badge */}
|
|
407
|
+
<div className="mt-2">
|
|
408
|
+
<Badge
|
|
409
|
+
variant={typeToBadgeVariant[notification.type]}
|
|
410
|
+
size="sm"
|
|
411
|
+
>
|
|
412
|
+
{notification.typeLabel || defaultTypeLabels[notification.type]}
|
|
413
|
+
</Badge>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
{/* Mark as read button */}
|
|
418
|
+
{!notification.isRead && onMarkAsRead && (
|
|
419
|
+
<button
|
|
420
|
+
className="flex-shrink-0 p-1 text-ink-400 hover:text-ink-600 hover:bg-paper-100 rounded transition-colors self-center"
|
|
421
|
+
onClick={(e) => handleMarkAsRead(e, notification.id)}
|
|
422
|
+
aria-label="Mark as read"
|
|
423
|
+
title="Mark as read"
|
|
424
|
+
>
|
|
425
|
+
<Check className="h-4 w-4" />
|
|
426
|
+
</button>
|
|
427
|
+
)}
|
|
428
|
+
</div>
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
// Dropdown content
|
|
432
|
+
const dropdownContent = (
|
|
433
|
+
<div className={`${dropdownWidths[size]} bg-white`}>
|
|
434
|
+
{/* Header */}
|
|
435
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-paper-200">
|
|
436
|
+
<Text weight="semibold" size="sm">
|
|
437
|
+
{headerTitle}
|
|
438
|
+
</Text>
|
|
439
|
+
{unreadCount > 0 && onMarkAllRead && (
|
|
440
|
+
<button
|
|
441
|
+
className="flex items-center gap-1.5 text-xs text-ink-600 hover:text-ink-800 transition-colors"
|
|
442
|
+
onClick={handleMarkAllRead}
|
|
443
|
+
>
|
|
444
|
+
<Check className="h-3.5 w-3.5" />
|
|
445
|
+
Mark all read
|
|
446
|
+
</button>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
{/* Notification List */}
|
|
451
|
+
<div
|
|
452
|
+
className="overflow-y-auto"
|
|
453
|
+
style={{ maxHeight }}
|
|
454
|
+
role="list"
|
|
455
|
+
aria-label="Notifications"
|
|
456
|
+
>
|
|
457
|
+
{loading ? (
|
|
458
|
+
// Loading state
|
|
459
|
+
<div className="p-4">
|
|
460
|
+
<Stack spacing="sm">
|
|
461
|
+
{[1, 2, 3].map((i) => (
|
|
462
|
+
<div key={i} className="flex gap-3">
|
|
463
|
+
{variant === 'compact' && (
|
|
464
|
+
<Skeleton className="h-4 w-4 rounded-full flex-shrink-0" />
|
|
465
|
+
)}
|
|
466
|
+
<div className="flex-1">
|
|
467
|
+
<div className="flex justify-between">
|
|
468
|
+
<Skeleton className="h-4 w-3/4 mb-2" />
|
|
469
|
+
{variant === 'detailed' && (
|
|
470
|
+
<Skeleton className="h-3 w-12" />
|
|
471
|
+
)}
|
|
472
|
+
</div>
|
|
473
|
+
<Skeleton className="h-3 w-full mb-1" />
|
|
474
|
+
{variant === 'compact' ? (
|
|
475
|
+
<Skeleton className="h-3 w-1/4" />
|
|
476
|
+
) : (
|
|
477
|
+
<Skeleton className="h-5 w-16 mt-2" />
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
))}
|
|
482
|
+
</Stack>
|
|
483
|
+
</div>
|
|
484
|
+
) : notifications.length === 0 ? (
|
|
485
|
+
// Empty state
|
|
486
|
+
<div className="py-8 px-4 text-center">
|
|
487
|
+
<Bell className="h-10 w-10 text-ink-300 mx-auto mb-3" />
|
|
488
|
+
<Text color="muted" size="sm">
|
|
489
|
+
{emptyMessage}
|
|
490
|
+
</Text>
|
|
491
|
+
</div>
|
|
492
|
+
) : (
|
|
493
|
+
// Notification items
|
|
494
|
+
notifications.map((notification) => (
|
|
495
|
+
<div
|
|
496
|
+
key={notification.id}
|
|
497
|
+
role="listitem"
|
|
498
|
+
className={`
|
|
499
|
+
px-4 py-3 border-b border-paper-100 last:border-b-0
|
|
500
|
+
hover:bg-paper-50 transition-colors cursor-pointer
|
|
501
|
+
${!notification.isRead ? 'bg-primary-50/30' : ''}
|
|
502
|
+
${notification.priority === 'urgent' ? 'border-l-2 border-l-error-500' : ''}
|
|
503
|
+
`}
|
|
504
|
+
onClick={() => handleNotificationClick(notification)}
|
|
505
|
+
onKeyDown={(e) => {
|
|
506
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
handleNotificationClick(notification);
|
|
509
|
+
}
|
|
510
|
+
}}
|
|
511
|
+
tabIndex={0}
|
|
512
|
+
>
|
|
513
|
+
{variant === 'compact'
|
|
514
|
+
? renderCompactItem(notification)
|
|
515
|
+
: renderDetailedItem(notification)}
|
|
516
|
+
</div>
|
|
517
|
+
))
|
|
518
|
+
)}
|
|
519
|
+
</div>
|
|
520
|
+
|
|
521
|
+
{/* Footer */}
|
|
522
|
+
{onViewAll && notifications.length > 0 && (
|
|
523
|
+
<div className="px-4 py-3 border-t border-paper-200">
|
|
524
|
+
<Button
|
|
525
|
+
variant="ghost"
|
|
526
|
+
size="sm"
|
|
527
|
+
fullWidth
|
|
528
|
+
onClick={handleViewAll}
|
|
529
|
+
icon={<ExternalLink className="h-3.5 w-3.5" />}
|
|
530
|
+
iconPosition="right"
|
|
531
|
+
>
|
|
532
|
+
{viewAllText}
|
|
533
|
+
</Button>
|
|
534
|
+
</div>
|
|
535
|
+
)}
|
|
536
|
+
</div>
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
return (
|
|
540
|
+
<Popover
|
|
541
|
+
trigger={triggerButton}
|
|
542
|
+
placement={getPopoverPlacement(dropdownPosition)}
|
|
543
|
+
triggerMode="click"
|
|
544
|
+
showArrow={false}
|
|
545
|
+
offset={4}
|
|
546
|
+
open={isOpen}
|
|
547
|
+
onOpenChange={setIsOpen}
|
|
548
|
+
closeOnClickOutside
|
|
549
|
+
closeOnEscape
|
|
550
|
+
disabled={disabled}
|
|
551
|
+
className="p-0 overflow-hidden"
|
|
552
|
+
>
|
|
553
|
+
{dropdownContent}
|
|
554
|
+
</Popover>
|
|
555
|
+
);
|
|
556
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -316,6 +316,10 @@ export type { SearchBarProps } from './SearchBar';
|
|
|
316
316
|
export { default as NotificationIndicator } from './NotificationIndicator';
|
|
317
317
|
export type { NotificationIndicatorProps } from './NotificationIndicator';
|
|
318
318
|
|
|
319
|
+
// Notification Bell (dropdown with notification list)
|
|
320
|
+
export { default as NotificationBell } from './NotificationBell';
|
|
321
|
+
export type { NotificationBellProps, NotificationItem, NotificationBellPosition, NotificationBellStyle } from './NotificationBell';
|
|
322
|
+
|
|
319
323
|
// Data Table
|
|
320
324
|
export { default as DataTable } from './DataTable';
|
|
321
325
|
export type {
|