@discourser/design-system 0.3.0 → 0.4.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,707 @@
1
+ # Toast
2
+
3
+ **Purpose:** Temporary notification component that provides non-intrusive feedback to users about system events, actions, or status changes following Material Design 3 patterns.
4
+
5
+ ## Import
6
+
7
+ ```typescript
8
+ import { Toaster, toaster } from '@discourser/design-system';
9
+ ```
10
+
11
+ ## Component Structure
12
+
13
+ The Toast system consists of two parts:
14
+
15
+ 1. **Toaster**: The container component that renders toast notifications (placed once in your app layout)
16
+ 2. **toaster**: The imperative API for creating and managing toast notifications
17
+
18
+ ### Basic Setup
19
+
20
+ ```typescript
21
+ // In your root layout or App component
22
+ import { Toaster } from '@discourser/design-system';
23
+
24
+ export default function Layout({ children }) {
25
+ return (
26
+ <>
27
+ {children}
28
+ <Toaster />
29
+ </>
30
+ );
31
+ }
32
+ ```
33
+
34
+ ## Toast Types
35
+
36
+ The Toast component supports 4 types, each with specific visual indicators:
37
+
38
+ | Type | Icon | Usage | When to Use |
39
+ | --------- | ----------- | ---------------------- | -------------------------------------------------------------- |
40
+ | `success` | CheckCircle | Successful operations | Form submissions, save confirmations, successful deletions |
41
+ | `error` | CircleX | Error states | Failed operations, validation errors, system errors |
42
+ | `warning` | CircleAlert | Warning messages | Cautionary information, potential issues, confirmations needed |
43
+ | `loading` | Spinner | In-progress operations | Async operations, file uploads, data fetching |
44
+
45
+ ### Visual Characteristics
46
+
47
+ - **success**: Green checkmark icon, positive feedback
48
+ - **error**: Red X icon, critical attention
49
+ - **warning**: Yellow alert icon, caution indicator
50
+ - **loading**: Animated spinner, ongoing process
51
+
52
+ ## Toaster Configuration
53
+
54
+ The toaster is created with the following default configuration:
55
+
56
+ | Option | Default | Description |
57
+ | ----------------- | -------------- | --------------------------------- |
58
+ | `placement` | `'bottom-end'` | Position of toast notifications |
59
+ | `pauseOnPageIdle` | `true` | Pause timers when page is idle |
60
+ | `overlap` | `true` | Stack toasts on top of each other |
61
+ | `max` | `5` | Maximum number of visible toasts |
62
+
63
+ ### Placement Options
64
+
65
+ - `top-start`, `top`, `top-end`
66
+ - `bottom-start`, `bottom`, `bottom-end`
67
+
68
+ ## Toaster API
69
+
70
+ ### Creating Toasts
71
+
72
+ ```typescript
73
+ // Basic toast with title only
74
+ toaster.create({
75
+ title: 'Action completed',
76
+ type: 'success',
77
+ });
78
+
79
+ // Toast with title and description
80
+ toaster.create({
81
+ title: 'Error occurred',
82
+ description: 'Unable to save your changes. Please try again.',
83
+ type: 'error',
84
+ });
85
+
86
+ // Toast with custom duration (in milliseconds)
87
+ toaster.create({
88
+ title: 'Processing...',
89
+ type: 'loading',
90
+ duration: 5000, // 5 seconds
91
+ });
92
+
93
+ // Toast with action button
94
+ toaster.create({
95
+ title: 'Item deleted',
96
+ description: 'The item has been removed.',
97
+ type: 'success',
98
+ action: {
99
+ label: 'Undo',
100
+ onClick: () => handleUndo(),
101
+ },
102
+ });
103
+
104
+ // Toast without auto-dismiss
105
+ toaster.create({
106
+ title: 'Important message',
107
+ description: 'This will stay until closed.',
108
+ type: 'warning',
109
+ duration: Infinity,
110
+ });
111
+
112
+ // Closable toast (shows close button)
113
+ toaster.create({
114
+ title: 'Notification',
115
+ type: 'success',
116
+ closable: true,
117
+ });
118
+ ```
119
+
120
+ ### Managing Toasts
121
+
122
+ ```typescript
123
+ // Create and store toast ID for later control
124
+ const toastId = toaster.create({
125
+ title: 'Processing...',
126
+ type: 'loading',
127
+ });
128
+
129
+ // Update existing toast
130
+ toaster.update(toastId, {
131
+ title: 'Success!',
132
+ type: 'success',
133
+ duration: 3000,
134
+ });
135
+
136
+ // Dismiss specific toast
137
+ toaster.dismiss(toastId);
138
+
139
+ // Dismiss all toasts
140
+ toaster.dismissAll();
141
+
142
+ // Remove specific toast (immediate, no animation)
143
+ toaster.remove(toastId);
144
+
145
+ // Check if toast exists
146
+ const exists = toaster.isVisible(toastId);
147
+
148
+ // Get toast details
149
+ const toast = toaster.getById(toastId);
150
+ ```
151
+
152
+ ## Toast Properties
153
+
154
+ | Property | Type | Default | Description |
155
+ | ------------- | ------------------------------------------------ | -------------- | ----------------------------------------------------- |
156
+ | `title` | `string` | Required | Main toast message |
157
+ | `description` | `string` | - | Additional details or context |
158
+ | `type` | `'success' \| 'error' \| 'warning' \| 'loading'` | - | Visual style and icon |
159
+ | `duration` | `number` | `5000` | Auto-dismiss duration in ms (Infinity for persistent) |
160
+ | `closable` | `boolean` | `false` | Show close button |
161
+ | `action` | `{ label: string, onClick: () => void }` | - | Action button configuration |
162
+ | `id` | `string` | Auto-generated | Unique toast identifier |
163
+
164
+ ## Examples
165
+
166
+ ### Success Toast
167
+
168
+ ```typescript
169
+ // Simple success message
170
+ toaster.create({
171
+ title: 'Profile updated',
172
+ type: 'success',
173
+ });
174
+
175
+ // Success with details
176
+ toaster.create({
177
+ title: 'Changes saved',
178
+ description: 'Your profile has been updated successfully.',
179
+ type: 'success',
180
+ duration: 4000,
181
+ });
182
+
183
+ // Success with undo action
184
+ toaster.create({
185
+ title: 'Item deleted',
186
+ description: 'The file has been moved to trash.',
187
+ type: 'success',
188
+ action: {
189
+ label: 'Undo',
190
+ onClick: () => restoreItem(),
191
+ },
192
+ });
193
+ ```
194
+
195
+ ### Error Toast
196
+
197
+ ```typescript
198
+ // Simple error
199
+ toaster.create({
200
+ title: 'Error saving changes',
201
+ type: 'error',
202
+ });
203
+
204
+ // Error with explanation
205
+ toaster.create({
206
+ title: 'Connection failed',
207
+ description:
208
+ 'Unable to connect to the server. Please check your internet connection.',
209
+ type: 'error',
210
+ duration: 7000,
211
+ });
212
+
213
+ // Error with retry action
214
+ toaster.create({
215
+ title: 'Upload failed',
216
+ description: 'The file could not be uploaded.',
217
+ type: 'error',
218
+ action: {
219
+ label: 'Retry',
220
+ onClick: () => retryUpload(),
221
+ },
222
+ closable: true,
223
+ });
224
+ ```
225
+
226
+ ### Warning Toast
227
+
228
+ ```typescript
229
+ // Simple warning
230
+ toaster.create({
231
+ title: 'Unsaved changes',
232
+ type: 'warning',
233
+ });
234
+
235
+ // Warning with details
236
+ toaster.create({
237
+ title: 'Storage almost full',
238
+ description: 'You have used 90% of your available storage.',
239
+ type: 'warning',
240
+ duration: 10000,
241
+ });
242
+
243
+ // Persistent warning
244
+ toaster.create({
245
+ title: 'Action required',
246
+ description: 'Please verify your email address to continue.',
247
+ type: 'warning',
248
+ duration: Infinity,
249
+ closable: true,
250
+ });
251
+ ```
252
+
253
+ ### Loading Toast
254
+
255
+ ```typescript
256
+ // Simple loading
257
+ const loadingToast = toaster.create({
258
+ title: 'Processing...',
259
+ type: 'loading',
260
+ });
261
+
262
+ // Loading with description
263
+ const uploadToast = toaster.create({
264
+ title: 'Uploading file',
265
+ description: 'Please wait while we upload your file.',
266
+ type: 'loading',
267
+ });
268
+
269
+ // Update to success when complete
270
+ setTimeout(() => {
271
+ toaster.update(uploadToast, {
272
+ title: 'Upload complete',
273
+ description: 'Your file has been uploaded successfully.',
274
+ type: 'success',
275
+ duration: 3000,
276
+ });
277
+ }, 3000);
278
+ ```
279
+
280
+ ## Common Patterns
281
+
282
+ ### Form Submission Feedback
283
+
284
+ ```typescript
285
+ async function handleSubmit(data: FormData) {
286
+ const toastId = toaster.create({
287
+ title: 'Saving changes...',
288
+ type: 'loading',
289
+ });
290
+
291
+ try {
292
+ await saveData(data);
293
+
294
+ toaster.update(toastId, {
295
+ title: 'Changes saved',
296
+ description: 'Your updates have been saved successfully.',
297
+ type: 'success',
298
+ duration: 3000,
299
+ });
300
+ } catch (error) {
301
+ toaster.update(toastId, {
302
+ title: 'Save failed',
303
+ description: error.message,
304
+ type: 'error',
305
+ duration: 5000,
306
+ });
307
+ }
308
+ }
309
+ ```
310
+
311
+ ### Async Operation with Progress
312
+
313
+ ```typescript
314
+ async function uploadFile(file: File) {
315
+ const toastId = toaster.create({
316
+ title: 'Uploading file...',
317
+ description: `${file.name}`,
318
+ type: 'loading',
319
+ });
320
+
321
+ try {
322
+ const result = await upload(file);
323
+
324
+ toaster.update(toastId, {
325
+ title: 'Upload complete',
326
+ description: `${file.name} has been uploaded.`,
327
+ type: 'success',
328
+ duration: 3000,
329
+ });
330
+
331
+ return result;
332
+ } catch (error) {
333
+ toaster.update(toastId, {
334
+ title: 'Upload failed',
335
+ description: `Could not upload ${file.name}. ${error.message}`,
336
+ type: 'error',
337
+ action: {
338
+ label: 'Retry',
339
+ onClick: () => uploadFile(file),
340
+ },
341
+ closable: true,
342
+ });
343
+ }
344
+ }
345
+ ```
346
+
347
+ ### Delete with Undo
348
+
349
+ ```typescript
350
+ function handleDelete(itemId: string) {
351
+ const item = getItem(itemId);
352
+
353
+ // Soft delete
354
+ markAsDeleted(itemId);
355
+
356
+ toaster.create({
357
+ title: 'Item deleted',
358
+ description: item.name,
359
+ type: 'success',
360
+ duration: 5000,
361
+ action: {
362
+ label: 'Undo',
363
+ onClick: () => {
364
+ restoreItem(itemId);
365
+ toaster.create({
366
+ title: 'Item restored',
367
+ type: 'success',
368
+ });
369
+ },
370
+ },
371
+ });
372
+
373
+ // Permanent delete after toast duration
374
+ setTimeout(() => {
375
+ permanentlyDelete(itemId);
376
+ }, 5000);
377
+ }
378
+ ```
379
+
380
+ ### Multiple Related Operations
381
+
382
+ ```typescript
383
+ async function batchOperation(items: Item[]) {
384
+ let successCount = 0;
385
+ let failCount = 0;
386
+
387
+ const toastId = toaster.create({
388
+ title: `Processing ${items.length} items...`,
389
+ type: 'loading',
390
+ });
391
+
392
+ for (const item of items) {
393
+ try {
394
+ await processItem(item);
395
+ successCount++;
396
+ } catch {
397
+ failCount++;
398
+ }
399
+ }
400
+
401
+ toaster.update(toastId, {
402
+ title: 'Batch operation complete',
403
+ description: `${successCount} succeeded, ${failCount} failed`,
404
+ type: failCount === 0 ? 'success' : 'warning',
405
+ duration: 5000,
406
+ });
407
+ }
408
+ ```
409
+
410
+ ### Session Timeout Warning
411
+
412
+ ```typescript
413
+ function showSessionWarning(secondsRemaining: number) {
414
+ const toastId = toaster.create({
415
+ title: 'Session expiring soon',
416
+ description: `Your session will expire in ${secondsRemaining} seconds.`,
417
+ type: 'warning',
418
+ duration: Infinity,
419
+ closable: true,
420
+ action: {
421
+ label: 'Extend Session',
422
+ onClick: async () => {
423
+ await extendSession();
424
+ toaster.dismiss(toastId);
425
+ toaster.create({
426
+ title: 'Session extended',
427
+ type: 'success',
428
+ });
429
+ },
430
+ },
431
+ });
432
+ }
433
+ ```
434
+
435
+ ## DO NOT
436
+
437
+ ```typescript
438
+ // ❌ Don't use toasts for critical errors that require user action
439
+ toaster.create({
440
+ title: 'Payment failed',
441
+ type: 'error',
442
+ }); // Use a modal dialog instead
443
+
444
+ // ❌ Don't stack multiple toasts for the same operation
445
+ onClick={() => {
446
+ toaster.create({ title: 'Starting...', type: 'loading' });
447
+ toaster.create({ title: 'Processing...', type: 'loading' });
448
+ }} // Update the same toast instead
449
+
450
+ // ❌ Don't use very short durations
451
+ toaster.create({
452
+ title: 'Saved',
453
+ type: 'success',
454
+ duration: 500, // Too fast to read
455
+ });
456
+
457
+ // ❌ Don't use toasts for complex information
458
+ toaster.create({
459
+ title: 'Error',
460
+ description: 'Very long error message with multiple paragraphs...',
461
+ type: 'error',
462
+ }); // Use a dialog or dedicated error page
463
+
464
+ // ❌ Don't create toasts without type
465
+ toaster.create({
466
+ title: 'Something happened',
467
+ }); // Always specify type for proper visual feedback
468
+
469
+ // ❌ Don't use action buttons for navigation
470
+ toaster.create({
471
+ title: 'Success',
472
+ action: {
473
+ label: 'Go to Dashboard',
474
+ onClick: () => router.push('/dashboard'),
475
+ },
476
+ }); // Toasts should not be primary navigation
477
+
478
+ // ✅ Use proper duration based on content length
479
+ toaster.create({
480
+ title: 'Saved',
481
+ type: 'success',
482
+ duration: 3000, // 3-5 seconds for simple messages
483
+ });
484
+
485
+ // ✅ Use update for state changes
486
+ const id = toaster.create({ title: 'Loading...', type: 'loading' });
487
+ toaster.update(id, { title: 'Done', type: 'success' });
488
+
489
+ // ✅ Use modals for critical information
490
+ <Dialog>
491
+ <Dialog.Title>Payment Failed</Dialog.Title>
492
+ <Dialog.Description>Your payment could not be processed...</Dialog.Description>
493
+ </Dialog>
494
+ ```
495
+
496
+ ## Accessibility
497
+
498
+ The Toast component follows WCAG 2.1 Level AA standards:
499
+
500
+ - **ARIA Role**: Uses `role="status"` for non-critical notifications
501
+ - **Live Regions**: Automatically announces toast content to screen readers
502
+ - **Keyboard Navigation**:
503
+ - Tab focuses close button and action button
504
+ - Enter/Space activates buttons
505
+ - Escape dismisses closable toasts
506
+ - **Focus Management**: Does not steal focus from current interaction
507
+ - **Color Independence**: Uses icons in addition to color for type indication
508
+ - **Timing**: Respects `prefers-reduced-motion` for animations
509
+
510
+ ### Accessibility Best Practices
511
+
512
+ ```typescript
513
+ // ✅ Provide clear, concise messages
514
+ toaster.create({
515
+ title: 'Profile updated successfully',
516
+ type: 'success',
517
+ });
518
+
519
+ // ✅ Include helpful descriptions for errors
520
+ toaster.create({
521
+ title: 'Connection error',
522
+ description: 'Please check your internet connection and try again.',
523
+ type: 'error',
524
+ });
525
+
526
+ // ✅ Use appropriate durations for content length
527
+ toaster.create({
528
+ title: 'Short message',
529
+ type: 'success',
530
+ duration: 3000, // 3 seconds
531
+ });
532
+
533
+ toaster.create({
534
+ title: 'Longer message',
535
+ description: 'Additional context that takes more time to read.',
536
+ type: 'warning',
537
+ duration: 7000, // 7 seconds
538
+ });
539
+
540
+ // ✅ Make important toasts closable
541
+ toaster.create({
542
+ title: 'Important update',
543
+ description: 'System maintenance scheduled for tonight.',
544
+ type: 'warning',
545
+ duration: Infinity,
546
+ closable: true,
547
+ });
548
+
549
+ // ✅ Provide meaningful action labels
550
+ toaster.create({
551
+ title: 'Item deleted',
552
+ type: 'success',
553
+ action: {
554
+ label: 'Undo deletion', // Descriptive, not just "Undo"
555
+ onClick: handleUndo,
556
+ },
557
+ });
558
+ ```
559
+
560
+ ## Duration Guidelines
561
+
562
+ | Content Type | Recommended Duration | Reasoning |
563
+ | ------------------------ | -------------------- | ------------------------------------------------ |
564
+ | Simple success message | 3-4 seconds | Quick confirmation, doesn't need long visibility |
565
+ | Error with description | 5-7 seconds | User needs time to read and understand |
566
+ | Warning message | 7-10 seconds | Important information, needs attention |
567
+ | Loading state | Infinity | Should be dismissed programmatically |
568
+ | Message with action | 5-10 seconds | User needs time to read and decide |
569
+ | Critical persistent info | Infinity + closable | Stays until user acknowledges |
570
+
571
+ **Formula**: Base 3 seconds + 1 second per 10 words in description
572
+
573
+ ## Type Selection Guide
574
+
575
+ | Scenario | Recommended Type | Reasoning |
576
+ | -------------------- | ---------------- | ----------------------------------------- |
577
+ | Form saved | `success` | Positive confirmation of completed action |
578
+ | Item created | `success` | New resource successfully created |
579
+ | Item deleted | `success` | Destructive action completed (with undo) |
580
+ | Validation error | `error` | User input needs correction |
581
+ | Network error | `error` | Operation failed due to connectivity |
582
+ | Server error | `error` | Backend operation failed |
583
+ | Unsaved changes | `warning` | User might lose data |
584
+ | Low storage | `warning` | Approaching limit, action recommended |
585
+ | Permission issue | `warning` | Limited functionality available |
586
+ | File uploading | `loading` | Async operation in progress |
587
+ | Data fetching | `loading` | Loading state for async operation |
588
+ | API call in progress | `loading` | Waiting for server response |
589
+
590
+ ## Responsive Considerations
591
+
592
+ ```typescript
593
+ // The Toaster component automatically adjusts for mobile
594
+ // It uses `insetInline={{ mdDown: '4' }}` for proper spacing
595
+
596
+ // Desktop: Toasts appear in bottom-end corner
597
+ // Mobile: Toasts have 4-unit horizontal padding for better visibility
598
+
599
+ // Ensure touch-friendly action buttons
600
+ toaster.create({
601
+ title: 'Action required',
602
+ type: 'warning',
603
+ action: {
604
+ label: 'Dismiss', // Short label for mobile
605
+ onClick: handleDismiss,
606
+ },
607
+ closable: true, // Provides alternative way to dismiss
608
+ });
609
+ ```
610
+
611
+ ## Testing
612
+
613
+ When testing Toast components:
614
+
615
+ ```typescript
616
+ import { render, screen, waitFor } from '@testing-library/react';
617
+ import userEvent from '@testing-library/user-event';
618
+ import { Toaster, toaster } from '@discourser/design-system';
619
+
620
+ test('creates and displays success toast', async () => {
621
+ render(<Toaster />);
622
+
623
+ toaster.create({
624
+ title: 'Success message',
625
+ type: 'success',
626
+ });
627
+
628
+ expect(await screen.findByText('Success message')).toBeInTheDocument();
629
+ });
630
+
631
+ test('updates loading toast to success', async () => {
632
+ render(<Toaster />);
633
+
634
+ const id = toaster.create({
635
+ title: 'Loading...',
636
+ type: 'loading',
637
+ });
638
+
639
+ expect(await screen.findByText('Loading...')).toBeInTheDocument();
640
+
641
+ toaster.update(id, {
642
+ title: 'Complete',
643
+ type: 'success',
644
+ });
645
+
646
+ expect(await screen.findByText('Complete')).toBeInTheDocument();
647
+ });
648
+
649
+ test('action button triggers callback', async () => {
650
+ const handleAction = vi.fn();
651
+ render(<Toaster />);
652
+
653
+ toaster.create({
654
+ title: 'Action needed',
655
+ type: 'warning',
656
+ action: {
657
+ label: 'Confirm',
658
+ onClick: handleAction,
659
+ },
660
+ });
661
+
662
+ const button = await screen.findByRole('button', { name: 'Confirm' });
663
+ await userEvent.click(button);
664
+
665
+ expect(handleAction).toHaveBeenCalledOnce();
666
+ });
667
+
668
+ test('dismisses toast when close button clicked', async () => {
669
+ render(<Toaster />);
670
+
671
+ toaster.create({
672
+ title: 'Dismissible',
673
+ type: 'success',
674
+ closable: true,
675
+ });
676
+
677
+ const closeButton = await screen.findByRole('button', { name: /close/i });
678
+ await userEvent.click(closeButton);
679
+
680
+ await waitFor(() => {
681
+ expect(screen.queryByText('Dismissible')).not.toBeInTheDocument();
682
+ });
683
+ });
684
+
685
+ test('automatically dismisses after duration', async () => {
686
+ render(<Toaster />);
687
+
688
+ toaster.create({
689
+ title: 'Auto dismiss',
690
+ type: 'success',
691
+ duration: 1000,
692
+ });
693
+
694
+ expect(await screen.findByText('Auto dismiss')).toBeInTheDocument();
695
+
696
+ await waitFor(() => {
697
+ expect(screen.queryByText('Auto dismiss')).not.toBeInTheDocument();
698
+ }, { timeout: 2000 });
699
+ });
700
+ ```
701
+
702
+ ## Related Components
703
+
704
+ - **Dialog**: For critical messages requiring explicit user acknowledgment
705
+ - **Alert**: For persistent, non-dismissible contextual messages within page content
706
+ - **Banner**: For system-wide announcements or important information
707
+ - **Snackbar**: Alternative term for Toast in some design systems