@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.
@@ -0,0 +1,717 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useState } from 'react';
3
+ import NotificationBell, { NotificationItem } from './NotificationBell';
4
+ import Stack from './Stack';
5
+ import Text from './Text';
6
+ import Box from './Box';
7
+
8
+ const meta = {
9
+ title: 'Components/NotificationBell',
10
+ component: NotificationBell,
11
+ parameters: {
12
+ layout: 'centered',
13
+ docs: {
14
+ description: {
15
+ component: `
16
+ Notification bell component with badge and dropdown panel for displaying notifications.
17
+
18
+ ## Features
19
+ - **Bell icon** with unread count badge
20
+ - **Two visual variants**: compact (dot indicator) and detailed (labeled badge)
21
+ - **Flexible positioning**: bottom-left, bottom-right, top-left, top-right
22
+ - **Notification items** with type badge, title, message, and time ago
23
+ - **Mark as read** actions (single and all)
24
+ - **View all** link to navigate to full notifications page
25
+ - **Loading state** with skeleton placeholders
26
+ - **Empty state** when no notifications
27
+ - **Keyboard accessible** (Escape to close, Enter to select)
28
+
29
+ ## Variants
30
+
31
+ ### Compact (default)
32
+ - Dot indicator for notification type
33
+ - Stacked layout: title, message, time
34
+ - Minimal footprint
35
+
36
+ ### Detailed
37
+ - Labeled badge for notification type (e.g., "Alert", "Info")
38
+ - Time aligned right of title
39
+ - Shows "(X unread)" in header
40
+
41
+ ## Usage
42
+
43
+ \`\`\`tsx
44
+ import { NotificationBell } from 'notebook-ui';
45
+
46
+ // Compact variant (default)
47
+ <NotificationBell
48
+ notifications={notifications}
49
+ onMarkAsRead={(id) => markAsRead(id)}
50
+ onMarkAllRead={() => markAllRead()}
51
+ onNotificationClick={(n) => navigate(n.actionUrl)}
52
+ onViewAll={() => navigate('/notifications')}
53
+ />
54
+
55
+ // Detailed variant
56
+ <NotificationBell
57
+ notifications={notifications}
58
+ variant="detailed"
59
+ showUnreadInHeader
60
+ dropdownPosition="bottom-left"
61
+ />
62
+ \`\`\`
63
+ `,
64
+ },
65
+ },
66
+ },
67
+ tags: ['autodocs'],
68
+ argTypes: {
69
+ variant: {
70
+ control: 'select',
71
+ options: ['compact', 'detailed'],
72
+ description: 'Visual variant - compact (dot) or detailed (labeled badge)',
73
+ table: {
74
+ type: { summary: 'compact | detailed' },
75
+ defaultValue: { summary: 'compact' },
76
+ },
77
+ },
78
+ dropdownPosition: {
79
+ control: 'select',
80
+ options: ['bottom-left', 'bottom-right', 'top-left', 'top-right'],
81
+ description: 'Position of dropdown relative to bell',
82
+ table: {
83
+ type: { summary: 'bottom-left | bottom-right | top-left | top-right' },
84
+ defaultValue: { summary: 'bottom-right' },
85
+ },
86
+ },
87
+ showUnreadInHeader: {
88
+ control: 'boolean',
89
+ description: 'Show unread count in header (e.g., "Notifications (2 unread)")',
90
+ table: {
91
+ type: { summary: 'boolean' },
92
+ defaultValue: { summary: 'false' },
93
+ },
94
+ },
95
+ size: {
96
+ control: 'select',
97
+ options: ['sm', 'md', 'lg'],
98
+ description: 'Size of the bell button',
99
+ table: {
100
+ type: { summary: 'sm | md | lg' },
101
+ defaultValue: { summary: 'md' },
102
+ },
103
+ },
104
+ maxHeight: {
105
+ control: 'text',
106
+ description: 'Maximum height of notification list before scrolling',
107
+ table: {
108
+ type: { summary: 'string' },
109
+ defaultValue: { summary: '400px' },
110
+ },
111
+ },
112
+ loading: {
113
+ control: 'boolean',
114
+ description: 'Show loading skeleton state',
115
+ },
116
+ disabled: {
117
+ control: 'boolean',
118
+ description: 'Disable the bell button',
119
+ },
120
+ },
121
+ } satisfies Meta<typeof NotificationBell>;
122
+
123
+ export default meta;
124
+ type Story = StoryObj<typeof meta>;
125
+
126
+ // Sample notifications for stories
127
+ const sampleNotifications: NotificationItem[] = [
128
+ {
129
+ id: '1',
130
+ title: 'Bill payment due',
131
+ message: 'Your electricity bill of $142.50 is due in 3 days',
132
+ type: 'warning',
133
+ priority: 'high',
134
+ createdAt: new Date(Date.now() - 5 * 60 * 1000),
135
+ isRead: false,
136
+ actionUrl: '/bills/electricity',
137
+ },
138
+ {
139
+ id: '2',
140
+ title: 'Transfer completed',
141
+ message: 'Your transfer of $500 to John Smith has been completed successfully',
142
+ type: 'success',
143
+ priority: 'normal',
144
+ createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
145
+ isRead: false,
146
+ actionUrl: '/transactions/123',
147
+ },
148
+ {
149
+ id: '3',
150
+ title: 'Security alert',
151
+ message: 'New login detected from Chrome on Windows. If this was not you, please secure your account immediately.',
152
+ type: 'error',
153
+ priority: 'urgent',
154
+ createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
155
+ isRead: false,
156
+ actionUrl: '/security',
157
+ },
158
+ {
159
+ id: '4',
160
+ title: 'Monthly summary ready',
161
+ message: 'Your January spending summary is now available',
162
+ type: 'info',
163
+ priority: 'low',
164
+ createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
165
+ isRead: true,
166
+ actionUrl: '/reports/january',
167
+ },
168
+ {
169
+ id: '5',
170
+ title: 'Budget alert',
171
+ message: 'You have reached 80% of your monthly dining budget',
172
+ type: 'warning',
173
+ priority: 'normal',
174
+ createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000),
175
+ isRead: true,
176
+ actionUrl: '/budgets/dining',
177
+ },
178
+ ];
179
+
180
+ // Banking-style notifications for detailed variant
181
+ const bankingNotifications: NotificationItem[] = [
182
+ {
183
+ id: '1',
184
+ title: 'Low balance in SAVINGS 9878',
185
+ message: 'Your SAVINGS 9878 account balance ($6.62) is below your threshold of $100.00.',
186
+ type: 'error',
187
+ typeLabel: 'Alert',
188
+ priority: 'high',
189
+ createdAt: new Date(Date.now() - 35 * 60 * 1000),
190
+ isRead: false,
191
+ },
192
+ {
193
+ id: '2',
194
+ title: 'Low balance in CHECKING 4364',
195
+ message: 'Your CHECKING 4364 account balance ($56.54) is below your threshold of $100.00.',
196
+ type: 'error',
197
+ typeLabel: 'Alert',
198
+ priority: 'high',
199
+ createdAt: new Date(Date.now() - 35 * 60 * 1000),
200
+ isRead: false,
201
+ },
202
+ ];
203
+
204
+ export const Default: Story = {
205
+ render: () => {
206
+ const [notifications, setNotifications] = useState(sampleNotifications);
207
+
208
+ const handleMarkAsRead = (id: string) => {
209
+ setNotifications((prev) =>
210
+ prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
211
+ );
212
+ };
213
+
214
+ const handleMarkAllRead = () => {
215
+ setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
216
+ };
217
+
218
+ return (
219
+ <NotificationBell
220
+ notifications={notifications}
221
+ onMarkAsRead={handleMarkAsRead}
222
+ onMarkAllRead={handleMarkAllRead}
223
+ onNotificationClick={(n) => alert(`Navigate to: ${n.actionUrl}`)}
224
+ onViewAll={() => alert('View all notifications')}
225
+ />
226
+ );
227
+ },
228
+ };
229
+
230
+ export const DetailedVariant: Story = {
231
+ render: () => {
232
+ const [notifications, setNotifications] = useState(bankingNotifications);
233
+
234
+ const handleMarkAsRead = (id: string) => {
235
+ setNotifications((prev) =>
236
+ prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
237
+ );
238
+ };
239
+
240
+ const handleMarkAllRead = () => {
241
+ setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
242
+ };
243
+
244
+ return (
245
+ <NotificationBell
246
+ notifications={notifications}
247
+ variant="detailed"
248
+ showUnreadInHeader
249
+ onMarkAsRead={handleMarkAsRead}
250
+ onMarkAllRead={handleMarkAllRead}
251
+ onNotificationClick={(n) => alert(`Clicked: ${n.title}`)}
252
+ onViewAll={() => alert('View all notifications')}
253
+ />
254
+ );
255
+ },
256
+ };
257
+
258
+ export const CompactVsDetailed: Story = {
259
+ render: () => {
260
+ const notifications = sampleNotifications.slice(0, 3);
261
+
262
+ return (
263
+ <Stack direction="horizontal" spacing="xl" align="start">
264
+ <Stack align="center" spacing="sm">
265
+ <NotificationBell
266
+ notifications={notifications}
267
+ variant="compact"
268
+ onViewAll={() => {}}
269
+ />
270
+ <Text size="xs" color="muted">Compact</Text>
271
+ </Stack>
272
+ <Stack align="center" spacing="sm">
273
+ <NotificationBell
274
+ notifications={notifications}
275
+ variant="detailed"
276
+ showUnreadInHeader
277
+ onViewAll={() => {}}
278
+ />
279
+ <Text size="xs" color="muted">Detailed</Text>
280
+ </Stack>
281
+ </Stack>
282
+ );
283
+ },
284
+ };
285
+
286
+ export const DropdownPositions: Story = {
287
+ render: () => (
288
+ <Box padding="xl">
289
+ <div style={{
290
+ display: 'grid',
291
+ gridTemplateColumns: '1fr 1fr',
292
+ gap: '200px',
293
+ padding: '100px',
294
+ }}>
295
+ <Stack align="start" spacing="xs">
296
+ <NotificationBell
297
+ notifications={sampleNotifications.slice(0, 2)}
298
+ dropdownPosition="bottom-right"
299
+ onViewAll={() => {}}
300
+ />
301
+ <Text size="xs" color="muted">bottom-right</Text>
302
+ </Stack>
303
+ <Stack align="end" spacing="xs">
304
+ <NotificationBell
305
+ notifications={sampleNotifications.slice(0, 2)}
306
+ dropdownPosition="bottom-left"
307
+ onViewAll={() => {}}
308
+ />
309
+ <Text size="xs" color="muted">bottom-left</Text>
310
+ </Stack>
311
+ <Stack align="start" spacing="xs">
312
+ <Text size="xs" color="muted">top-right</Text>
313
+ <NotificationBell
314
+ notifications={sampleNotifications.slice(0, 2)}
315
+ dropdownPosition="top-right"
316
+ onViewAll={() => {}}
317
+ />
318
+ </Stack>
319
+ <Stack align="end" spacing="xs">
320
+ <Text size="xs" color="muted">top-left</Text>
321
+ <NotificationBell
322
+ notifications={sampleNotifications.slice(0, 2)}
323
+ dropdownPosition="top-left"
324
+ onViewAll={() => {}}
325
+ />
326
+ </Stack>
327
+ </div>
328
+ </Box>
329
+ ),
330
+ };
331
+
332
+ export const WithUnreadBadge: Story = {
333
+ render: () => (
334
+ <Stack direction="horizontal" spacing="lg" align="center">
335
+ <Stack align="center" spacing="xs">
336
+ <NotificationBell
337
+ notifications={sampleNotifications}
338
+ unreadCount={3}
339
+ />
340
+ <Text size="xs" color="muted">3 unread</Text>
341
+ </Stack>
342
+ <Stack align="center" spacing="xs">
343
+ <NotificationBell
344
+ notifications={sampleNotifications}
345
+ unreadCount={99}
346
+ />
347
+ <Text size="xs" color="muted">99 unread</Text>
348
+ </Stack>
349
+ <Stack align="center" spacing="xs">
350
+ <NotificationBell
351
+ notifications={sampleNotifications}
352
+ unreadCount={150}
353
+ />
354
+ <Text size="xs" color="muted">99+ unread</Text>
355
+ </Stack>
356
+ </Stack>
357
+ ),
358
+ };
359
+
360
+ export const Sizes: Story = {
361
+ render: () => (
362
+ <Stack direction="horizontal" spacing="lg" align="center">
363
+ <Stack align="center" spacing="xs">
364
+ <NotificationBell
365
+ notifications={sampleNotifications}
366
+ unreadCount={5}
367
+ size="sm"
368
+ />
369
+ <Text size="xs" color="muted">Small</Text>
370
+ </Stack>
371
+ <Stack align="center" spacing="xs">
372
+ <NotificationBell
373
+ notifications={sampleNotifications}
374
+ unreadCount={5}
375
+ size="md"
376
+ />
377
+ <Text size="xs" color="muted">Medium</Text>
378
+ </Stack>
379
+ <Stack align="center" spacing="xs">
380
+ <NotificationBell
381
+ notifications={sampleNotifications}
382
+ unreadCount={5}
383
+ size="lg"
384
+ />
385
+ <Text size="xs" color="muted">Large</Text>
386
+ </Stack>
387
+ </Stack>
388
+ ),
389
+ };
390
+
391
+ export const EmptyState: Story = {
392
+ render: () => (
393
+ <Stack direction="horizontal" spacing="xl">
394
+ <Stack align="center" spacing="xs">
395
+ <NotificationBell
396
+ notifications={[]}
397
+ variant="compact"
398
+ onViewAll={() => {}}
399
+ />
400
+ <Text size="xs" color="muted">Compact</Text>
401
+ </Stack>
402
+ <Stack align="center" spacing="xs">
403
+ <NotificationBell
404
+ notifications={[]}
405
+ variant="detailed"
406
+ onViewAll={() => {}}
407
+ />
408
+ <Text size="xs" color="muted">Detailed</Text>
409
+ </Stack>
410
+ </Stack>
411
+ ),
412
+ };
413
+
414
+ export const LoadingState: Story = {
415
+ render: () => (
416
+ <Stack direction="horizontal" spacing="xl">
417
+ <Stack align="center" spacing="xs">
418
+ <NotificationBell
419
+ notifications={[]}
420
+ loading
421
+ variant="compact"
422
+ />
423
+ <Text size="xs" color="muted">Compact</Text>
424
+ </Stack>
425
+ <Stack align="center" spacing="xs">
426
+ <NotificationBell
427
+ notifications={[]}
428
+ loading
429
+ variant="detailed"
430
+ />
431
+ <Text size="xs" color="muted">Detailed</Text>
432
+ </Stack>
433
+ </Stack>
434
+ ),
435
+ };
436
+
437
+ export const NotificationTypes: Story = {
438
+ render: () => {
439
+ const typeNotifications: NotificationItem[] = [
440
+ {
441
+ id: '1',
442
+ title: 'Info notification',
443
+ message: 'This is an informational message',
444
+ type: 'info',
445
+ priority: 'normal',
446
+ createdAt: new Date(),
447
+ isRead: false,
448
+ },
449
+ {
450
+ id: '2',
451
+ title: 'Success notification',
452
+ message: 'Operation completed successfully',
453
+ type: 'success',
454
+ priority: 'normal',
455
+ createdAt: new Date(),
456
+ isRead: false,
457
+ },
458
+ {
459
+ id: '3',
460
+ title: 'Warning notification',
461
+ message: 'Please review this item',
462
+ type: 'warning',
463
+ priority: 'high',
464
+ createdAt: new Date(),
465
+ isRead: false,
466
+ },
467
+ {
468
+ id: '4',
469
+ title: 'Error notification',
470
+ message: 'Something went wrong',
471
+ type: 'error',
472
+ priority: 'urgent',
473
+ createdAt: new Date(),
474
+ isRead: false,
475
+ },
476
+ ];
477
+
478
+ return (
479
+ <Stack direction="horizontal" spacing="xl">
480
+ <Stack align="center" spacing="xs">
481
+ <NotificationBell
482
+ notifications={typeNotifications}
483
+ variant="compact"
484
+ />
485
+ <Text size="xs" color="muted">Compact</Text>
486
+ </Stack>
487
+ <Stack align="center" spacing="xs">
488
+ <NotificationBell
489
+ notifications={typeNotifications}
490
+ variant="detailed"
491
+ showUnreadInHeader
492
+ />
493
+ <Text size="xs" color="muted">Detailed</Text>
494
+ </Stack>
495
+ </Stack>
496
+ );
497
+ },
498
+ };
499
+
500
+ export const CustomTypeLabels: Story = {
501
+ render: () => {
502
+ const customNotifications: NotificationItem[] = [
503
+ {
504
+ id: '1',
505
+ title: 'Account Update',
506
+ message: 'Your profile has been updated',
507
+ type: 'info',
508
+ typeLabel: 'Update',
509
+ priority: 'normal',
510
+ createdAt: new Date(),
511
+ isRead: false,
512
+ },
513
+ {
514
+ id: '2',
515
+ title: 'Payment Received',
516
+ message: 'You received $500 from John',
517
+ type: 'success',
518
+ typeLabel: 'Payment',
519
+ priority: 'normal',
520
+ createdAt: new Date(),
521
+ isRead: false,
522
+ },
523
+ {
524
+ id: '3',
525
+ title: 'Low Balance',
526
+ message: 'Your checking account is below $100',
527
+ type: 'error',
528
+ typeLabel: 'Alert',
529
+ priority: 'high',
530
+ createdAt: new Date(),
531
+ isRead: false,
532
+ },
533
+ ];
534
+
535
+ return (
536
+ <NotificationBell
537
+ notifications={customNotifications}
538
+ variant="detailed"
539
+ showUnreadInHeader
540
+ onViewAll={() => {}}
541
+ />
542
+ );
543
+ },
544
+ };
545
+
546
+ export const ManyNotifications: Story = {
547
+ render: () => {
548
+ const manyNotifications: NotificationItem[] = Array.from(
549
+ { length: 20 },
550
+ (_, i) => ({
551
+ id: String(i + 1),
552
+ title: `Notification ${i + 1}`,
553
+ message: `This is the message for notification ${i + 1}`,
554
+ type: (['info', 'success', 'warning', 'error'] as const)[i % 4],
555
+ priority: (['low', 'normal', 'high', 'urgent'] as const)[i % 4],
556
+ createdAt: new Date(Date.now() - i * 60 * 60 * 1000),
557
+ isRead: i > 5,
558
+ })
559
+ );
560
+
561
+ return (
562
+ <NotificationBell
563
+ notifications={manyNotifications}
564
+ maxHeight="300px"
565
+ onViewAll={() => alert('View all 20 notifications')}
566
+ />
567
+ );
568
+ },
569
+ };
570
+
571
+ export const InHeader: Story = {
572
+ render: () => {
573
+ const [notifications, setNotifications] = useState(bankingNotifications);
574
+
575
+ return (
576
+ <div
577
+ style={{
578
+ display: 'flex',
579
+ alignItems: 'center',
580
+ justifyContent: 'space-between',
581
+ padding: '0.75rem 1.5rem',
582
+ backgroundColor: 'white',
583
+ borderBottom: '1px solid #e5e7eb',
584
+ width: '500px',
585
+ }}
586
+ >
587
+ <Text weight="semibold">Prylance</Text>
588
+ <NotificationBell
589
+ notifications={notifications}
590
+ variant="detailed"
591
+ showUnreadInHeader
592
+ dropdownPosition="bottom-left"
593
+ onMarkAsRead={(id) =>
594
+ setNotifications((prev) =>
595
+ prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
596
+ )
597
+ }
598
+ onMarkAllRead={() =>
599
+ setNotifications((prev) =>
600
+ prev.map((n) => ({ ...n, isRead: true }))
601
+ )
602
+ }
603
+ onViewAll={() => alert('View all')}
604
+ />
605
+ </div>
606
+ );
607
+ },
608
+ };
609
+
610
+ export const Disabled: Story = {
611
+ render: () => (
612
+ <NotificationBell
613
+ notifications={sampleNotifications}
614
+ unreadCount={5}
615
+ disabled
616
+ />
617
+ ),
618
+ };
619
+
620
+ export const BellStyles: Story = {
621
+ render: () => (
622
+ <Stack direction="horizontal" spacing="xl" align="center">
623
+ <Stack align="center" spacing="sm">
624
+ <NotificationBell
625
+ notifications={sampleNotifications}
626
+ unreadCount={2}
627
+ bellStyle="ghost"
628
+ onViewAll={() => {}}
629
+ />
630
+ <Text size="xs" color="muted">Ghost (default)</Text>
631
+ </Stack>
632
+ <Stack align="center" spacing="sm">
633
+ <NotificationBell
634
+ notifications={sampleNotifications}
635
+ unreadCount={2}
636
+ bellStyle="outlined"
637
+ onViewAll={() => {}}
638
+ />
639
+ <Text size="xs" color="muted">Outlined</Text>
640
+ </Stack>
641
+ </Stack>
642
+ ),
643
+ };
644
+
645
+ export const OutlinedSizes: Story = {
646
+ render: () => (
647
+ <Stack direction="horizontal" spacing="xl" align="center">
648
+ <Stack align="center" spacing="sm">
649
+ <NotificationBell
650
+ notifications={sampleNotifications}
651
+ unreadCount={3}
652
+ bellStyle="outlined"
653
+ size="sm"
654
+ />
655
+ <Text size="xs" color="muted">Small</Text>
656
+ </Stack>
657
+ <Stack align="center" spacing="sm">
658
+ <NotificationBell
659
+ notifications={sampleNotifications}
660
+ unreadCount={3}
661
+ bellStyle="outlined"
662
+ size="md"
663
+ />
664
+ <Text size="xs" color="muted">Medium</Text>
665
+ </Stack>
666
+ <Stack align="center" spacing="sm">
667
+ <NotificationBell
668
+ notifications={sampleNotifications}
669
+ unreadCount={3}
670
+ bellStyle="outlined"
671
+ size="lg"
672
+ />
673
+ <Text size="xs" color="muted">Large</Text>
674
+ </Stack>
675
+ </Stack>
676
+ ),
677
+ };
678
+
679
+ export const OutlinedInHeader: Story = {
680
+ render: () => {
681
+ const [notifications, setNotifications] = useState(bankingNotifications);
682
+
683
+ return (
684
+ <div
685
+ style={{
686
+ display: 'flex',
687
+ alignItems: 'center',
688
+ justifyContent: 'space-between',
689
+ padding: '0.75rem 1.5rem',
690
+ backgroundColor: '#fafaf9',
691
+ borderBottom: '1px solid #e5e7eb',
692
+ width: '500px',
693
+ }}
694
+ >
695
+ <Text weight="semibold">Prylance</Text>
696
+ <NotificationBell
697
+ notifications={notifications}
698
+ bellStyle="outlined"
699
+ variant="detailed"
700
+ showUnreadInHeader
701
+ dropdownPosition="bottom-left"
702
+ onMarkAsRead={(id) =>
703
+ setNotifications((prev) =>
704
+ prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
705
+ )
706
+ }
707
+ onMarkAllRead={() =>
708
+ setNotifications((prev) =>
709
+ prev.map((n) => ({ ...n, isRead: true }))
710
+ )
711
+ }
712
+ onViewAll={() => alert('View all')}
713
+ />
714
+ </div>
715
+ );
716
+ },
717
+ };