@justin_evo/evo-ui 1.1.0 → 1.2.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.
Files changed (80) hide show
  1. package/README.md +3 -3
  2. package/dist/TopNav/TopNav.d.ts +19 -0
  3. package/dist/declarations.d.ts +6 -6
  4. package/dist/evo-ui.css +1 -1
  5. package/dist/index.cjs.js +1 -1
  6. package/dist/index.es.js +3301 -3197
  7. package/package.json +52 -52
  8. package/src/Alert/Alert.tsx +49 -49
  9. package/src/AutoComplete/AutoComplete.tsx +810 -810
  10. package/src/Badge/Badge.tsx +53 -53
  11. package/src/Breadcrumb/Breadcrumb.tsx +53 -53
  12. package/src/Button/Button.tsx +125 -125
  13. package/src/Card/Card.tsx +257 -257
  14. package/src/Checkbox/Checkbox.tsx +59 -59
  15. package/src/CommandPalette/CommandPalette.tsx +185 -185
  16. package/src/Container/Container.tsx +31 -31
  17. package/src/Divider/Divider.tsx +31 -31
  18. package/src/Form/Form.tsx +185 -185
  19. package/src/Grid/Grid.tsx +66 -66
  20. package/src/ImageCropper/ImageCropper.tsx +911 -911
  21. package/src/Input/Input.tsx +74 -74
  22. package/src/Modal/Modal.tsx +77 -77
  23. package/src/Nav/Nav.tsx +708 -708
  24. package/src/Notification/Notification.tsx +1503 -1503
  25. package/src/Pagination/Pagination.tsx +76 -76
  26. package/src/Radio/Radio.tsx +69 -69
  27. package/src/RichTextArea/RichTextArea.tsx +886 -869
  28. package/src/Select/Select.tsx +515 -515
  29. package/src/Skeleton/Skeleton.tsx +70 -70
  30. package/src/Stack/Stack.tsx +52 -52
  31. package/src/Table/Table.tsx +335 -335
  32. package/src/Tabs/Tabs.tsx +90 -90
  33. package/src/Theme/ThemeProvider.tsx +253 -253
  34. package/src/Theme/ThemeToggle.tsx +79 -79
  35. package/src/Toggle/Toggle.tsx +48 -48
  36. package/src/Tooltip/Tooltip.tsx +38 -38
  37. package/src/TopNav/TopNav.tsx +1163 -994
  38. package/src/TreeSelect/TreeSelect.tsx +825 -825
  39. package/src/css/alert.module.scss +93 -93
  40. package/src/css/autocomplete.module.scss +416 -416
  41. package/src/css/badge.module.scss +82 -82
  42. package/src/css/base/_color.scss +159 -159
  43. package/src/css/base/_theme.scss +237 -237
  44. package/src/css/base/_variables.scss +161 -161
  45. package/src/css/breadcrumb.module.scss +50 -50
  46. package/src/css/button.module.scss +385 -385
  47. package/src/css/card.module.scss +217 -217
  48. package/src/css/checkbox.module.scss +123 -120
  49. package/src/css/commandpalette.module.scss +211 -211
  50. package/src/css/container.module.scss +18 -18
  51. package/src/css/divider.module.scss +41 -41
  52. package/src/css/form.module.scss +245 -245
  53. package/src/css/imagecropper.module.scss +397 -397
  54. package/src/css/input.module.scss +89 -89
  55. package/src/css/modal.module.scss +105 -105
  56. package/src/css/nav.module.scss +494 -494
  57. package/src/css/notification.module.scss +691 -691
  58. package/src/css/pagination.module.scss +63 -63
  59. package/src/css/radio.module.scss +89 -89
  60. package/src/css/richtextarea.module.scss +307 -307
  61. package/src/css/select.module.scss +525 -525
  62. package/src/css/skeleton.module.scss +30 -30
  63. package/src/css/table.module.scss +386 -386
  64. package/src/css/tabs.module.scss +63 -63
  65. package/src/css/theme-toggle.module.scss +83 -83
  66. package/src/css/toggle.module.scss +54 -54
  67. package/src/css/tooltip.module.scss +97 -97
  68. package/src/css/topnav.module.scss +568 -396
  69. package/src/css/treeselect.module.scss +558 -558
  70. package/src/css/utilities/_borders.scss +111 -111
  71. package/src/css/utilities/_colors.scss +66 -66
  72. package/src/css/utilities/_effects.scss +216 -216
  73. package/src/css/utilities/_layout.scss +181 -181
  74. package/src/css/utilities/_position.scss +75 -75
  75. package/src/css/utilities/_sizing.scss +138 -138
  76. package/src/css/utilities/_spacing.scss +99 -99
  77. package/src/css/utilities/_typography.scss +121 -121
  78. package/src/css/utilities/index.scss +24 -24
  79. package/src/declarations.d.ts +6 -6
  80. package/src/index.ts +60 -60
@@ -1,1503 +1,1503 @@
1
- import {
2
- forwardRef,
3
- useCallback,
4
- useEffect,
5
- useId,
6
- useLayoutEffect,
7
- useRef,
8
- useState,
9
- useSyncExternalStore,
10
- type ButtonHTMLAttributes,
11
- type CSSProperties,
12
- type HTMLAttributes,
13
- type ReactNode,
14
- } from 'react';
15
- import ReactDOM from 'react-dom';
16
- import styles from '../css/notification.module.scss';
17
-
18
- // ============================================================
19
- // EvoNotification — unified toast + notification-center system
20
- // ------------------------------------------------------------
21
- // Design notes (see CLAUDE.md §2 research stanza):
22
- // • API shape borrowed from Sonner — module-level singleton
23
- // so any file can `import { evoNotify }` and call it
24
- // without a hook or being inside the React tree.
25
- // • a11y model borrowed from Radix Toast — error → assertive,
26
- // everything else → polite. Hover/focus pauses timers.
27
- // • queue/limit/overflow-fold borrowed from Mantine — past
28
- // `maxVisible` toasts collapse into a "+N more" pill.
29
- // • inbox/bell/panel shape borrowed from MagicBell + Knock —
30
- // read/unread, mark-all-read, empty/loading/error slots.
31
- // • Animations are hand-rolled (zero deps): CSS keyframes for
32
- // enter/exit and a CSS transition on the stacking transform
33
- // for reorder; one global opt-out via `prefers-reduced-motion`.
34
- // • Evo-specific extras: `groupKey` coalesces repeat toasts into
35
- // a single counted card; `toast.progress()` drives a
36
- // determinate progress bar to a success/error end state.
37
- // ============================================================
38
-
39
- // ─── Types ───────────────────────────────────────────────────
40
-
41
- export type EvoNotificationSeverity = 'success' | 'error' | 'warning' | 'info';
42
-
43
- export type EvoNotificationAnchor =
44
- | 'top-left' | 'top-center' | 'top-right'
45
- | 'bottom-left' | 'bottom-center' | 'bottom-right';
46
-
47
- export interface EvoNotificationAction {
48
- label: string;
49
- onClick: (e: React.MouseEvent) => void;
50
- }
51
-
52
- export interface EvoToastOptions {
53
- id?: string;
54
- title?: ReactNode;
55
- description?: ReactNode;
56
- severity?: EvoNotificationSeverity;
57
- icon?: ReactNode;
58
- duration?: number;
59
- persistent?: boolean;
60
- anchor?: EvoNotificationAnchor;
61
- action?: EvoNotificationAction;
62
- dismissible?: boolean;
63
- onDismiss?: (id: string) => void;
64
- onAutoClose?: (id: string) => void;
65
- className?: string;
66
- inbox?: boolean | Partial<EvoInboxItemInput>;
67
- /**
68
- * Coalescing key. Toasts pushed with the same `groupKey` while an earlier
69
- * one is still on screen fold into it — the card refreshes in place and
70
- * shows an incremented count badge instead of stacking a duplicate.
71
- * Ignored when an explicit `id` is supplied (id matching takes priority).
72
- */
73
- groupKey?: string;
74
- /**
75
- * Determinate progress, 0–1. When set, the toast renders a progress bar.
76
- * Values outside the range are clamped. Usually driven via
77
- * `evoNotify.toast.progress()`, but valid on any toast.
78
- */
79
- progress?: number;
80
- }
81
-
82
- export interface EvoPromiseMessages<T> {
83
- loading: ReactNode | EvoToastOptions;
84
- success: ReactNode | ((value: T) => ReactNode | EvoToastOptions);
85
- error: ReactNode | ((err: unknown) => ReactNode | EvoToastOptions);
86
- }
87
-
88
- /**
89
- * Handle returned by `evoNotify.toast.progress()`. Drives a determinate
90
- * progress bar and resolves the toast to a success or error end state.
91
- */
92
- export interface EvoToastProgressHandle {
93
- /** The underlying toast id — usable with `evoNotify.toast.dismiss`. */
94
- readonly id: string;
95
- /** Set the bar fill, 0–1 (values outside the range are clamped). */
96
- setProgress: (value: number) => void;
97
- /** Patch any toast option (title, description, severity, …). */
98
- update: (options: EvoToastOptions) => void;
99
- /** Resolve as success: bar fills to 100%, the toast then auto-dismisses. */
100
- done: (options?: EvoToastOptions) => void;
101
- /** Resolve as error: the toast becomes dismissible and auto-dismisses. */
102
- fail: (options?: EvoToastOptions) => void;
103
- /** Dismiss the toast immediately. */
104
- dismiss: () => void;
105
- }
106
-
107
- export interface EvoInboxItemInput {
108
- id?: string;
109
- title: ReactNode;
110
- description?: ReactNode;
111
- severity?: EvoNotificationSeverity;
112
- icon?: ReactNode;
113
- avatarUrl?: string;
114
- timestamp?: number | Date;
115
- read?: boolean;
116
- action?: EvoNotificationAction;
117
- onClick?: (item: EvoInboxItem) => void;
118
- meta?: Record<string, unknown>;
119
- toast?: boolean | Partial<EvoToastOptions>;
120
- }
121
-
122
- export interface EvoInboxItem {
123
- id: string;
124
- title: ReactNode;
125
- description?: ReactNode;
126
- severity: EvoNotificationSeverity;
127
- icon?: ReactNode;
128
- avatarUrl?: string;
129
- timestamp: number;
130
- read: boolean;
131
- action?: EvoNotificationAction;
132
- onClick?: (item: EvoInboxItem) => void;
133
- meta?: Record<string, unknown>;
134
- }
135
-
136
- interface InternalToast extends EvoToastOptions {
137
- id: string;
138
- severity: EvoNotificationSeverity;
139
- createdAt: number;
140
- message: ReactNode;
141
- /** How many times this toast has been (re)pushed under its `groupKey`. */
142
- count: number;
143
- /** Bumped on every re-push / update so the row can restart its timer. */
144
- restartKey: number;
145
- /** True while the exit animation plays, before the store drops it. */
146
- exiting?: boolean;
147
- }
148
-
149
- // ─── Module-level store ──────────────────────────────────────
150
- // Singleton so `evoNotify.toast(...)` works from any file
151
- // without requiring a hook or React context.
152
-
153
- type ToastListener = (toasts: InternalToast[]) => void;
154
- type InboxListener = (state: { items: EvoInboxItem[]; unread: number }) => void;
155
-
156
- interface ProviderConfig {
157
- defaultAnchor: EvoNotificationAnchor;
158
- defaultDuration: number;
159
- maxVisible: number;
160
- pauseOnFocusLoss: boolean;
161
- persistErrors: boolean;
162
- }
163
-
164
- const DEFAULT_CONFIG: ProviderConfig = {
165
- defaultAnchor: 'top-right',
166
- defaultDuration: 4000,
167
- maxVisible: 3,
168
- pauseOnFocusLoss: true,
169
- persistErrors: false,
170
- };
171
-
172
- // Exit-animation duration. Must stay in sync with the `evoToastExit`
173
- // keyframe length in notification.module.scss.
174
- const EXIT_MS = 180;
175
-
176
- // Stable empty references for `useSyncExternalStore` server/initial snapshots.
177
- // Returning a fresh array/object from getServerSnapshot triggers an infinite
178
- // render loop, so these MUST be the same identity on every read.
179
- const EMPTY_TOASTS: ReadonlyArray<InternalToast> = Object.freeze([]) as ReadonlyArray<InternalToast>;
180
- const EMPTY_INBOX_STATE: { items: EvoInboxItem[]; unread: number } = Object.freeze({
181
- items: Object.freeze([]) as unknown as EvoInboxItem[],
182
- unread: 0,
183
- }) as { items: EvoInboxItem[]; unread: number };
184
-
185
- class NotificationStore {
186
- private toasts: InternalToast[] = [];
187
- private inboxItems: EvoInboxItem[] = [];
188
- // Cached inbox snapshot — same reference until inboxItems mutates.
189
- private inboxSnapshot: { items: EvoInboxItem[]; unread: number } = {
190
- items: [],
191
- unread: 0,
192
- };
193
- private inboxOwnedExternally = false;
194
- private inboxOnChange: ((items: EvoInboxItem[]) => void) | null = null;
195
- private toastListeners = new Set<ToastListener>();
196
- private inboxListeners = new Set<InboxListener>();
197
- private configListeners = new Set<() => void>();
198
- private config: ProviderConfig = DEFAULT_CONFIG;
199
- private counter = 0;
200
-
201
- setConfig(next: Partial<ProviderConfig>) {
202
- const merged = { ...this.config, ...next };
203
- const changed = (Object.keys(merged) as Array<keyof ProviderConfig>)
204
- .some((k) => merged[k] !== this.config[k]);
205
- if (!changed) return;
206
- this.config = merged;
207
- this.notifyConfig();
208
- }
209
-
210
- getConfig() {
211
- return this.config;
212
- }
213
-
214
- subscribeConfig(fn: () => void) {
215
- this.configListeners.add(fn);
216
- return () => {
217
- this.configListeners.delete(fn);
218
- };
219
- }
220
-
221
- private notifyConfig() {
222
- this.configListeners.forEach((fn) => fn());
223
- }
224
-
225
- // Resolves a toast's lifetime. `persistent` wins, then an explicit
226
- // `duration`, then the `persistErrors` config (errors stay until the
227
- // user dismisses them), then the global default.
228
- private resolveDuration(
229
- options: EvoToastOptions,
230
- severity: EvoNotificationSeverity,
231
- ): number {
232
- if (options.persistent === true) return Infinity;
233
- if (options.duration != null) return options.duration;
234
- if (
235
- severity === 'error' &&
236
- this.config.persistErrors &&
237
- options.persistent !== false
238
- ) {
239
- return Infinity;
240
- }
241
- return this.config.defaultDuration;
242
- }
243
-
244
- bindExternalInbox(items: EvoInboxItem[] | undefined, onChange: ((items: EvoInboxItem[]) => void) | undefined) {
245
- if (items !== undefined) {
246
- this.inboxOwnedExternally = true;
247
- this.inboxItems = items;
248
- this.inboxOnChange = onChange ?? null;
249
- this.refreshInboxSnapshot();
250
- this.notifyInbox();
251
- } else {
252
- this.inboxOwnedExternally = false;
253
- this.inboxOnChange = null;
254
- }
255
- }
256
-
257
- private refreshInboxSnapshot() {
258
- let unread = 0;
259
- for (const i of this.inboxItems) if (!i.read) unread += 1;
260
- this.inboxSnapshot = { items: this.inboxItems, unread };
261
- }
262
-
263
- private nextId() {
264
- this.counter += 1;
265
- return `evo-${Date.now().toString(36)}-${this.counter}`;
266
- }
267
-
268
- // ----- Toast methods -----
269
-
270
- pushToast(message: ReactNode, options: EvoToastOptions = {}): string {
271
- const severity = options.severity ?? 'info';
272
-
273
- // Coalescing: a toast carrying a `groupKey` (and no explicit id) folds
274
- // into any still-active toast sharing that key — the card refreshes in
275
- // place with an incremented count instead of stacking a duplicate.
276
- if (options.groupKey != null && options.id == null) {
277
- const group = this.toasts.find(
278
- (t) => t.groupKey === options.groupKey && !t.exiting,
279
- );
280
- if (group) {
281
- this.toasts = this.toasts.map((t): InternalToast =>
282
- t.id === group.id
283
- ? {
284
- ...t,
285
- ...options,
286
- id: group.id,
287
- severity,
288
- duration: this.resolveDuration(options, severity),
289
- message,
290
- count: t.count + 1,
291
- restartKey: t.restartKey + 1,
292
- }
293
- : t,
294
- );
295
- this.applyInboxSideEffect(group.id, message, options);
296
- this.notifyToasts();
297
- return group.id;
298
- }
299
- }
300
-
301
- const id = options.id ?? this.nextId();
302
- const duration = this.resolveDuration(options, severity);
303
-
304
- const existing = this.toasts.find((t) => t.id === id);
305
- if (existing) {
306
- // Re-pushing under an existing id refreshes it, restarts its timer,
307
- // and revives it if it happened to be mid-exit.
308
- this.toasts = this.toasts.map((t): InternalToast =>
309
- t.id === id
310
- ? {
311
- ...t,
312
- ...options,
313
- id,
314
- severity,
315
- duration,
316
- message,
317
- restartKey: t.restartKey + 1,
318
- exiting: false,
319
- }
320
- : t,
321
- );
322
- } else {
323
- const toast: InternalToast = {
324
- ...options,
325
- id,
326
- severity,
327
- duration,
328
- message,
329
- createdAt: Date.now(),
330
- count: 1,
331
- restartKey: 0,
332
- };
333
- this.toasts = [...this.toasts, toast];
334
- }
335
-
336
- this.applyInboxSideEffect(id, message, options);
337
- this.notifyToasts();
338
- return id;
339
- }
340
-
341
- private applyInboxSideEffect(
342
- id: string,
343
- message: ReactNode,
344
- options: EvoToastOptions,
345
- ) {
346
- if (!options.inbox) return;
347
- const inboxInput: EvoInboxItemInput = {
348
- id: `${id}-inbox`,
349
- title: options.title ?? message,
350
- description: options.description,
351
- severity: options.severity ?? 'info',
352
- icon: options.icon,
353
- action: options.action,
354
- ...(typeof options.inbox === 'object' ? options.inbox : {}),
355
- };
356
- this.pushInbox(inboxInput);
357
- }
358
-
359
- updateToast(id: string, options: EvoToastOptions) {
360
- const idx = this.toasts.findIndex((t) => t.id === id);
361
- if (idx === -1) return;
362
- const prev = this.toasts[idx];
363
- const next: InternalToast = {
364
- ...prev,
365
- ...options,
366
- id,
367
- severity: options.severity ?? prev.severity,
368
- message: 'title' in options && options.title != null ? options.title : prev.message,
369
- // Bumped so the row resets its auto-dismiss countdown for the
370
- // refreshed content (e.g. a resolved promise toast).
371
- restartKey: prev.restartKey + 1,
372
- };
373
- // Re-resolve the lifetime. Without the `persistent === false` branch a
374
- // toast that was persistent (a loading/progress toast) would keep its
375
- // `Infinity` duration and never auto-dismiss after resolving.
376
- if (options.persistent === true) {
377
- next.duration = Infinity;
378
- } else if (options.duration != null) {
379
- next.duration = options.duration;
380
- } else if (options.persistent === false) {
381
- next.duration = this.resolveDuration(
382
- { ...options, persistent: false },
383
- next.severity,
384
- );
385
- }
386
- this.toasts = this.toasts.map((t) => (t.id === id ? next : t));
387
- this.notifyToasts();
388
- }
389
-
390
- // Phase 1 of removal: flag the toast(s) as exiting and fire the close
391
- // callback. The row plays the exit animation, then calls `removeToast`.
392
- // Every removal path — close button, action, auto-close, dismissAll —
393
- // funnels through here, so exit animation is always consistent.
394
- dismissToast(id?: string, reason: 'dismiss' | 'auto' = 'dismiss') {
395
- if (id == null) {
396
- const active = this.toasts.filter((t) => !t.exiting);
397
- if (active.length === 0) return;
398
- active.forEach((t) => t.onDismiss?.(t.id));
399
- this.toasts = this.toasts.map((t): InternalToast =>
400
- t.exiting ? t : { ...t, exiting: true },
401
- );
402
- this.notifyToasts();
403
- return;
404
- }
405
- const target = this.toasts.find((t) => t.id === id);
406
- if (!target || target.exiting) return;
407
- if (reason === 'auto') target.onAutoClose?.(id);
408
- else target.onDismiss?.(id);
409
- this.toasts = this.toasts.map((t): InternalToast =>
410
- t.id === id ? { ...t, exiting: true } : t,
411
- );
412
- this.notifyToasts();
413
- }
414
-
415
- // Phase 2: drop the toast once its exit animation has finished.
416
- removeToast(id: string) {
417
- const before = this.toasts.length;
418
- this.toasts = this.toasts.filter((t) => t.id !== id);
419
- if (this.toasts.length !== before) this.notifyToasts();
420
- }
421
-
422
- getToasts() {
423
- return this.toasts;
424
- }
425
-
426
- subscribeToasts(fn: ToastListener) {
427
- this.toastListeners.add(fn);
428
- return () => {
429
- this.toastListeners.delete(fn);
430
- };
431
- }
432
-
433
- private notifyToasts() {
434
- this.toastListeners.forEach((fn) => fn(this.toasts));
435
- }
436
-
437
- // ----- Inbox methods -----
438
-
439
- pushInbox(input: EvoInboxItemInput): string {
440
- const id = input.id ?? this.nextId();
441
- const ts = input.timestamp instanceof Date
442
- ? input.timestamp.getTime()
443
- : input.timestamp ?? Date.now();
444
-
445
- const item: EvoInboxItem = {
446
- id,
447
- title: input.title,
448
- description: input.description,
449
- severity: input.severity ?? 'info',
450
- icon: input.icon,
451
- avatarUrl: input.avatarUrl,
452
- timestamp: ts,
453
- read: input.read ?? false,
454
- action: input.action,
455
- onClick: input.onClick,
456
- meta: input.meta,
457
- };
458
-
459
- const idx = this.inboxItems.findIndex((i) => i.id === id);
460
- const next = idx === -1
461
- ? [item, ...this.inboxItems]
462
- : this.inboxItems.map((i) => (i.id === id ? item : i));
463
- this.commitInbox(next);
464
-
465
- if (input.toast) {
466
- const toastInput: EvoToastOptions = {
467
- id: `${id}-toast`,
468
- title: item.title,
469
- description: item.description,
470
- severity: item.severity,
471
- icon: item.icon,
472
- action: item.action,
473
- ...(typeof input.toast === 'object' ? input.toast : {}),
474
- };
475
- this.pushToast(item.title, toastInput);
476
- }
477
-
478
- return id;
479
- }
480
-
481
- markRead(id: string) {
482
- this.commitInbox(this.inboxItems.map((i) => (i.id === id ? { ...i, read: true } : i)));
483
- }
484
-
485
- markUnread(id: string) {
486
- this.commitInbox(this.inboxItems.map((i) => (i.id === id ? { ...i, read: false } : i)));
487
- }
488
-
489
- markAllRead() {
490
- this.commitInbox(this.inboxItems.map((i) => (i.read ? i : { ...i, read: true })));
491
- }
492
-
493
- removeInbox(id: string) {
494
- this.commitInbox(this.inboxItems.filter((i) => i.id !== id));
495
- }
496
-
497
- clearInbox() {
498
- this.commitInbox([]);
499
- }
500
-
501
- setInboxItems(items: EvoInboxItem[]) {
502
- this.commitInbox(items);
503
- }
504
-
505
- getInboxState() {
506
- return this.inboxSnapshot;
507
- }
508
-
509
- subscribeInbox(fn: InboxListener) {
510
- this.inboxListeners.add(fn);
511
- return () => {
512
- this.inboxListeners.delete(fn);
513
- };
514
- }
515
-
516
- private commitInbox(next: EvoInboxItem[]) {
517
- this.inboxItems = next;
518
- this.refreshInboxSnapshot();
519
- if (this.inboxOwnedExternally) {
520
- this.inboxOnChange?.(next);
521
- }
522
- this.notifyInbox();
523
- }
524
-
525
- private notifyInbox() {
526
- const state = this.inboxSnapshot;
527
- this.inboxListeners.forEach((fn) => fn(state));
528
- }
529
- }
530
-
531
- const store = new NotificationStore();
532
-
533
- // ─── Public singleton API ────────────────────────────────────
534
-
535
- export interface EvoNotifyAPI {
536
- toast: {
537
- (message: ReactNode, options?: EvoToastOptions): string;
538
- success: (message: ReactNode, options?: EvoToastOptions) => string;
539
- error: (message: ReactNode, options?: EvoToastOptions) => string;
540
- warning: (message: ReactNode, options?: EvoToastOptions) => string;
541
- info: (message: ReactNode, options?: EvoToastOptions) => string;
542
- loading: (message: ReactNode, options?: EvoToastOptions) => string;
543
- promise: <T>(p: Promise<T> | (() => Promise<T>), msgs: EvoPromiseMessages<T>) => string;
544
- progress: (message: ReactNode, options?: EvoToastOptions) => EvoToastProgressHandle;
545
- update: (id: string, options: EvoToastOptions) => void;
546
- dismiss: (id?: string) => void;
547
- };
548
- push: (item: EvoInboxItemInput) => string;
549
- inbox: {
550
- markRead: (id: string) => void;
551
- markUnread: (id: string) => void;
552
- markAllRead: () => void;
553
- remove: (id: string) => void;
554
- clear: () => void;
555
- setItems: (items: EvoInboxItem[]) => void;
556
- getState: () => { items: EvoInboxItem[]; unread: number };
557
- subscribe: (fn: (s: { items: EvoInboxItem[]; unread: number }) => void) => () => void;
558
- };
559
- dismissAll: () => void;
560
- }
561
-
562
- const baseToast = (message: ReactNode, options?: EvoToastOptions) =>
563
- store.pushToast(message, options);
564
-
565
- const toastApi = baseToast as EvoNotifyAPI['toast'];
566
-
567
- toastApi.success = (m, o) => store.pushToast(m, { ...o, severity: 'success' });
568
- toastApi.error = (m, o) => store.pushToast(m, { ...o, severity: 'error' });
569
- toastApi.warning = (m, o) => store.pushToast(m, { ...o, severity: 'warning' });
570
- toastApi.info = (m, o) => store.pushToast(m, { ...o, severity: 'info' });
571
- toastApi.loading = (m, o) =>
572
- store.pushToast(m, { ...o, severity: 'info', persistent: true, dismissible: false });
573
- toastApi.update = (id, o) => store.updateToast(id, o);
574
- toastApi.dismiss = (id) => store.dismissToast(id);
575
- toastApi.promise = <T,>(
576
- p: Promise<T> | (() => Promise<T>),
577
- msgs: EvoPromiseMessages<T>,
578
- ) => {
579
- const id = store.pushToast(
580
- typeof msgs.loading === 'object' && msgs.loading !== null && !isReactNode(msgs.loading)
581
- ? (msgs.loading as EvoToastOptions).title ?? ''
582
- : (msgs.loading as ReactNode),
583
- {
584
- ...(typeof msgs.loading === 'object' && !isReactNode(msgs.loading)
585
- ? (msgs.loading as EvoToastOptions)
586
- : {}),
587
- severity: 'info',
588
- persistent: true,
589
- dismissible: false,
590
- },
591
- );
592
-
593
- const promise = typeof p === 'function' ? p() : p;
594
-
595
- promise.then(
596
- (value) => {
597
- const resolved = typeof msgs.success === 'function'
598
- ? (msgs.success as (v: T) => ReactNode | EvoToastOptions)(value)
599
- : msgs.success;
600
- const isOpts = resolved !== null && typeof resolved === 'object' && !isReactNode(resolved);
601
- const opts = isOpts ? (resolved as EvoToastOptions) : {};
602
- const msg = isOpts ? opts.title ?? '' : (resolved as ReactNode);
603
- store.updateToast(id, {
604
- ...opts,
605
- severity: 'success',
606
- persistent: false,
607
- dismissible: true,
608
- title: msg,
609
- });
610
- },
611
- (err) => {
612
- const resolved = typeof msgs.error === 'function'
613
- ? (msgs.error as (e: unknown) => ReactNode | EvoToastOptions)(err)
614
- : msgs.error;
615
- const isOpts = resolved !== null && typeof resolved === 'object' && !isReactNode(resolved);
616
- const opts = isOpts ? (resolved as EvoToastOptions) : {};
617
- const msg = isOpts ? opts.title ?? '' : (resolved as ReactNode);
618
- store.updateToast(id, {
619
- ...opts,
620
- severity: 'error',
621
- persistent: false,
622
- dismissible: true,
623
- title: msg,
624
- });
625
- },
626
- );
627
-
628
- return id;
629
- };
630
-
631
- toastApi.progress = (message, options) => {
632
- const id = store.pushToast(message, {
633
- ...options,
634
- severity: options?.severity ?? 'info',
635
- persistent: true,
636
- dismissible: options?.dismissible ?? false,
637
- progress: clamp01(options?.progress ?? 0),
638
- });
639
- return {
640
- id,
641
- setProgress: (value) => store.updateToast(id, { progress: clamp01(value) }),
642
- update: (opts) => store.updateToast(id, opts),
643
- done: (opts) =>
644
- store.updateToast(id, {
645
- ...opts,
646
- severity: opts?.severity ?? 'success',
647
- progress: 1,
648
- persistent: false,
649
- dismissible: true,
650
- }),
651
- fail: (opts) =>
652
- store.updateToast(id, {
653
- ...opts,
654
- severity: opts?.severity ?? 'error',
655
- persistent: false,
656
- dismissible: true,
657
- }),
658
- dismiss: () => store.dismissToast(id),
659
- };
660
- };
661
-
662
- function isReactNode(v: unknown): boolean {
663
- if (v == null) return true;
664
- const t = typeof v;
665
- if (t === 'string' || t === 'number' || t === 'boolean') return true;
666
- if (Array.isArray(v)) return true;
667
- if (t === 'object' && v !== null && '$$typeof' in (v as object)) return true;
668
- return false;
669
- }
670
-
671
- /** Clamps a number into the 0–1 range; non-numbers and NaN fall back to 0. */
672
- function clamp01(n: number): number {
673
- if (typeof n !== 'number' || Number.isNaN(n)) return 0;
674
- return n < 0 ? 0 : n > 1 ? 1 : n;
675
- }
676
-
677
- export const evoNotify: EvoNotifyAPI = {
678
- toast: toastApi,
679
- push: (item) => store.pushInbox(item),
680
- inbox: {
681
- markRead: (id) => store.markRead(id),
682
- markUnread: (id) => store.markUnread(id),
683
- markAllRead: () => store.markAllRead(),
684
- remove: (id) => store.removeInbox(id),
685
- clear: () => store.clearInbox(),
686
- setItems: (items) => store.setInboxItems(items),
687
- getState: () => store.getInboxState(),
688
- subscribe: (fn) => store.subscribeInbox(fn),
689
- },
690
- dismissAll: () => store.dismissToast(),
691
- };
692
-
693
- // ─── Helpers ─────────────────────────────────────────────────
694
-
695
- function cx(...parts: Array<string | undefined | false | null>) {
696
- return parts.filter(Boolean).join(' ');
697
- }
698
-
699
- const ICON_GLYPHS: Record<EvoNotificationSeverity, string> = {
700
- success: '✓',
701
- error: '✕',
702
- warning: '!',
703
- info: 'i',
704
- };
705
-
706
- function formatRelative(ts: number, now: number): string {
707
- const d = Math.max(0, now - ts);
708
- const s = Math.floor(d / 1000);
709
- if (s < 60) return 'just now';
710
- const m = Math.floor(s / 60);
711
- if (m < 60) return `${m}m ago`;
712
- const h = Math.floor(m / 60);
713
- if (h < 24) return `${h}h ago`;
714
- const days = Math.floor(h / 24);
715
- if (days < 7) return `${days}d ago`;
716
- return new Date(ts).toLocaleDateString();
717
- }
718
-
719
- // Extracts plain text from a toast for the screen-reader live region and the
720
- // row's `aria-label`. JSX titles/descriptions yield '' (announced silently).
721
- function toastText(t: InternalToast): string {
722
- const parts: string[] = [];
723
- const title = t.title ?? t.message;
724
- if (typeof title === 'string' || typeof title === 'number') {
725
- parts.push(String(title));
726
- }
727
- if (typeof t.description === 'string' || typeof t.description === 'number') {
728
- parts.push(String(t.description));
729
- }
730
- return parts.join('. ');
731
- }
732
-
733
- function useToasts() {
734
- return useSyncExternalStore(
735
- (cb) => store.subscribeToasts(() => cb()),
736
- () => store.getToasts(),
737
- () => EMPTY_TOASTS as InternalToast[],
738
- );
739
- }
740
-
741
- function useInbox() {
742
- return useSyncExternalStore(
743
- (cb) => store.subscribeInbox(() => cb()),
744
- () => store.getInboxState(),
745
- () => EMPTY_INBOX_STATE,
746
- );
747
- }
748
-
749
- // Subscribes to provider config so the Toaster reacts to live changes of
750
- // `maxVisible` / `pauseOnFocusLoss` / etc. instead of reading a stale value.
751
- function useConfig() {
752
- return useSyncExternalStore(
753
- (cb) => store.subscribeConfig(cb),
754
- () => store.getConfig(),
755
- () => DEFAULT_CONFIG,
756
- );
757
- }
758
-
759
- // ─── Provider ────────────────────────────────────────────────
760
-
761
- export interface EvoNotificationProviderProps {
762
- children: ReactNode;
763
- defaultAnchor?: EvoNotificationAnchor;
764
- maxVisible?: number;
765
- defaultDuration?: number;
766
- pauseOnFocusLoss?: boolean;
767
- /**
768
- * When true, `error` toasts stay until dismissed instead of auto-closing.
769
- * Recommended for accessibility — errors should not vanish on a timer.
770
- * A per-toast `duration` or `persistent` still overrides this.
771
- */
772
- persistErrors?: boolean;
773
- inboxItems?: EvoInboxItem[];
774
- onInboxChange?: (items: EvoInboxItem[]) => void;
775
- }
776
-
777
- export const EvoNotificationProvider = ({
778
- children,
779
- defaultAnchor = 'top-right',
780
- maxVisible = 3,
781
- defaultDuration = 4000,
782
- pauseOnFocusLoss = true,
783
- persistErrors = false,
784
- inboxItems,
785
- onInboxChange,
786
- }: EvoNotificationProviderProps) => {
787
- // Push config into the store on every render where it changes.
788
- useLayoutEffect(() => {
789
- store.setConfig({ defaultAnchor, maxVisible, defaultDuration, pauseOnFocusLoss, persistErrors });
790
- }, [defaultAnchor, maxVisible, defaultDuration, pauseOnFocusLoss, persistErrors]);
791
-
792
- useLayoutEffect(() => {
793
- store.bindExternalInbox(inboxItems, onInboxChange);
794
- }, [inboxItems, onInboxChange]);
795
-
796
- return <>{children}</>;
797
- };
798
- EvoNotificationProvider.displayName = 'EvoNotificationProvider';
799
-
800
- // ─── Toaster ─────────────────────────────────────────────────
801
-
802
- export interface EvoNotificationToasterProps {
803
- anchor?: EvoNotificationAnchor;
804
- className?: string;
805
- }
806
-
807
- interface ToastRowProps {
808
- toast: InternalToast;
809
- // Effective anchor for this toast (per-toast override resolved against the
810
- // toaster default). Drives which way the depth offset leans.
811
- anchor: EvoNotificationAnchor;
812
- index: number;
813
- total: number;
814
- hovered: boolean;
815
- pausedExternally: boolean;
816
- reducedMotion: boolean;
817
- }
818
-
819
- const ToastRow = ({ toast, anchor, index, total, hovered, pausedExternally, reducedMotion }: ToastRowProps) => {
820
- const elapsedRef = useRef(0);
821
- const timerStartRef = useRef<number | null>(null);
822
- const timeoutRef = useRef<number | null>(null);
823
- const restartRef = useRef(toast.restartKey);
824
-
825
- const exiting = toast.exiting ?? false;
826
- const paused = hovered || pausedExternally;
827
- const finite = Number.isFinite(toast.duration);
828
-
829
- // Phase 2 of removal: once the store flags this toast `exiting`, let the
830
- // CSS exit animation play, then drop it from the store. Close button,
831
- // action, auto-close and dismissAll all reach this path, so every removal
832
- // animates identically — and the timeout is cleaned up on unmount.
833
- useEffect(() => {
834
- if (!exiting) return;
835
- const t = window.setTimeout(
836
- () => store.removeToast(toast.id),
837
- reducedMotion ? 0 : EXIT_MS,
838
- );
839
- return () => window.clearTimeout(t);
840
- }, [exiting, reducedMotion, toast.id]);
841
-
842
- // Auto-dismiss countdown. Pauses on hover / window blur, resets when the
843
- // toast is re-pushed or updated (restartKey bump), and stops once exiting.
844
- useEffect(() => {
845
- if (restartRef.current !== toast.restartKey) {
846
- restartRef.current = toast.restartKey;
847
- elapsedRef.current = 0;
848
- }
849
- if (!finite || exiting) return;
850
- if (paused) {
851
- if (timerStartRef.current != null) {
852
- elapsedRef.current += Date.now() - timerStartRef.current;
853
- timerStartRef.current = null;
854
- }
855
- if (timeoutRef.current != null) {
856
- window.clearTimeout(timeoutRef.current);
857
- timeoutRef.current = null;
858
- }
859
- return;
860
- }
861
-
862
- const remaining = Math.max(0, (toast.duration as number) - elapsedRef.current);
863
- timerStartRef.current = Date.now();
864
- timeoutRef.current = window.setTimeout(
865
- () => store.dismissToast(toast.id, 'auto'),
866
- remaining,
867
- );
868
-
869
- return () => {
870
- if (timeoutRef.current != null) {
871
- window.clearTimeout(timeoutRef.current);
872
- timeoutRef.current = null;
873
- }
874
- if (timerStartRef.current != null) {
875
- elapsedRef.current += Date.now() - timerStartRef.current;
876
- timerStartRef.current = null;
877
- }
878
- };
879
- }, [paused, finite, exiting, toast.duration, toast.restartKey, toast.id]);
880
-
881
- // Stacked appearance: items behind the front fade and scale slightly,
882
- // expand on hover (Sonner-style).
883
- const depth = total - 1 - index;
884
- const baseScale = hovered ? 1 : Math.max(0.94, 1 - depth * 0.04);
885
- const baseTranslate = hovered ? depth * 8 : depth * 6;
886
- const baseOpacity = hovered ? 1 : Math.max(0.7, 1 - depth * 0.15);
887
-
888
- // Older cards sit behind the newest one and peek out *away* from the
889
- // anchored edge: downward (+Y) for top anchors, upward (-Y) for bottom
890
- // anchors. This must agree with the per-anchor flex-direction in the SCSS —
891
- // together they keep the newest toast flush against the anchored edge.
892
- const offsetSign = anchor.startsWith('bottom') ? 1 : -1;
893
- const style: CSSProperties = {
894
- transform: `translateY(${baseTranslate * offsetSign}px) scale(${baseScale})`,
895
- opacity: baseOpacity,
896
- zIndex: 1000 + index,
897
- };
898
-
899
- const dismissible = toast.dismissible ?? true;
900
- const icon = toast.icon ?? ICON_GLYPHS[toast.severity];
901
- const titleNode = toast.title ?? toast.message;
902
- const label = toastText(toast);
903
- const hasProgress = typeof toast.progress === 'number';
904
- const progressValue = hasProgress ? clamp01(toast.progress as number) : 0;
905
-
906
- // When the toast has plain-text content the Toaster's persistent live
907
- // region announces it, so the card itself is just a navigable `group`.
908
- // A JSX-only toast (no extractable text) keeps an in-place live role as
909
- // a fallback. The two paths are mutually exclusive — never double-announced.
910
- const announced = label !== '';
911
-
912
- return (
913
- <div
914
- className={cx(
915
- styles.toast,
916
- styles[`sev-${toast.severity}`],
917
- exiting && styles.exiting,
918
- reducedMotion && styles.noMotion,
919
- toast.className,
920
- )}
921
- style={style}
922
- role={announced ? 'group' : toast.severity === 'error' ? 'alert' : 'status'}
923
- aria-label={announced ? label : undefined}
924
- >
925
- <span className={styles.toastIcon} aria-hidden="true">{icon}</span>
926
- <div className={styles.toastBody}>
927
- {titleNode != null && (
928
- <div className={styles.toastTitle}>
929
- {titleNode}
930
- {toast.count > 1 && (
931
- <span className={styles.toastCount} aria-label={`repeated ${toast.count} times`}>
932
- ×{toast.count}
933
- </span>
934
- )}
935
- </div>
936
- )}
937
- {toast.description != null && (
938
- <div className={styles.toastDescription}>{toast.description}</div>
939
- )}
940
- </div>
941
- {toast.action && (
942
- <button
943
- type="button"
944
- className={styles.toastAction}
945
- onClick={(e) => {
946
- toast.action!.onClick(e);
947
- store.dismissToast(toast.id);
948
- }}
949
- >
950
- {toast.action.label}
951
- </button>
952
- )}
953
- {dismissible && (
954
- <button
955
- type="button"
956
- className={styles.toastClose}
957
- onClick={() => {
958
- store.dismissToast(toast.id);
959
- }}
960
- aria-label="Dismiss notification"
961
- >
962
-
963
- </button>
964
- )}
965
- {hasProgress && (
966
- <div
967
- className={styles.toastProgressTrack}
968
- role="progressbar"
969
- aria-valuemin={0}
970
- aria-valuemax={1}
971
- aria-valuenow={progressValue}
972
- >
973
- <div
974
- className={styles.toastProgressFill}
975
- style={{ transform: `scaleX(${progressValue})` }}
976
- />
977
- </div>
978
- )}
979
- </div>
980
- );
981
- };
982
-
983
- const ANCHORS: EvoNotificationAnchor[] = [
984
- 'top-left', 'top-center', 'top-right',
985
- 'bottom-left', 'bottom-center', 'bottom-right',
986
- ];
987
-
988
- // Identity of the Toaster currently allowed to render. Guards against two
989
- // <EvoNotificationToaster> instances each drawing (and announcing) every
990
- // toast — see B7.
991
- let activeToasterOwner: symbol | null = null;
992
-
993
- export const EvoNotificationToaster = ({ anchor, className }: EvoNotificationToasterProps) => {
994
- const all = useToasts();
995
- const { defaultAnchor, maxVisible, pauseOnFocusLoss } = useConfig();
996
- const fallback = anchor ?? defaultAnchor;
997
-
998
- const [hovered, setHovered] = useState<EvoNotificationAnchor | null>(null);
999
- const [windowFocused, setWindowFocused] = useState(true);
1000
- const [reducedMotion, setReducedMotion] = useState(false);
1001
- const [mounted, setMounted] = useState(false);
1002
- const [primary, setPrimary] = useState(false);
1003
-
1004
- // Persistent screen-reader live region. A polite/assertive pair lives in
1005
- // the portal at all times — injecting a fresh node that already carries
1006
- // `aria-live` is announced unreliably across screen readers (B5).
1007
- const [politeMsg, setPoliteMsg] = useState('');
1008
- const [assertiveMsg, setAssertiveMsg] = useState('');
1009
- const announcedRef = useRef<Set<string>>(new Set());
1010
-
1011
- const ownerRef = useRef<symbol | null>(null);
1012
- if (!ownerRef.current) ownerRef.current = Symbol('evo-toaster');
1013
-
1014
- // Claim the single render slot; later instances render nothing.
1015
- useEffect(() => {
1016
- const me = ownerRef.current!;
1017
- if (activeToasterOwner == null) {
1018
- activeToasterOwner = me;
1019
- setPrimary(true);
1020
- } else if (activeToasterOwner !== me) {
1021
- console.warn(
1022
- '[EvoNotification] More than one <EvoNotificationToaster> is mounted; ' +
1023
- 'only the first renders. Remove the duplicate(s).',
1024
- );
1025
- }
1026
- return () => {
1027
- if (activeToasterOwner === me) activeToasterOwner = null;
1028
- };
1029
- }, []);
1030
-
1031
- useEffect(() => {
1032
- setMounted(true);
1033
- if (typeof window === 'undefined') return;
1034
- const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
1035
- setReducedMotion(mq.matches);
1036
- const onChange = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
1037
- mq.addEventListener?.('change', onChange);
1038
- return () => mq.removeEventListener?.('change', onChange);
1039
- }, []);
1040
-
1041
- useEffect(() => {
1042
- if (!pauseOnFocusLoss) return;
1043
- const onFocus = () => setWindowFocused(true);
1044
- const onBlur = () => setWindowFocused(false);
1045
- window.addEventListener('focus', onFocus);
1046
- window.addEventListener('blur', onBlur);
1047
- return () => {
1048
- window.removeEventListener('focus', onFocus);
1049
- window.removeEventListener('blur', onBlur);
1050
- };
1051
- }, [pauseOnFocusLoss]);
1052
-
1053
- // Announce newly-arrived toasts through the persistent live region.
1054
- // Coalesced updates reuse an already-seen id, so they don't re-announce.
1055
- useEffect(() => {
1056
- const seen = announcedRef.current;
1057
- const live = new Set<string>();
1058
- for (const t of all) {
1059
- live.add(t.id);
1060
- if (seen.has(t.id) || t.exiting) continue;
1061
- seen.add(t.id);
1062
- const text = toastText(t);
1063
- if (!text) continue;
1064
- if (t.severity === 'error') setAssertiveMsg(text);
1065
- else setPoliteMsg(text);
1066
- }
1067
- for (const id of seen) {
1068
- if (!live.has(id)) seen.delete(id);
1069
- }
1070
- }, [all]);
1071
-
1072
- // Esc dismisses the newest live toast in the hovered/focused group.
1073
- useEffect(() => {
1074
- const onKey = (e: KeyboardEvent) => {
1075
- if (e.key === 'Escape' && hovered != null) {
1076
- const group = all.filter(
1077
- (t) => !t.exiting && (t.anchor ?? fallback) === hovered,
1078
- );
1079
- if (group.length > 0) store.dismissToast(group[group.length - 1].id);
1080
- }
1081
- };
1082
- window.addEventListener('keydown', onKey);
1083
- return () => window.removeEventListener('keydown', onKey);
1084
- }, [all, fallback, hovered]);
1085
-
1086
- if (!mounted || typeof document === 'undefined') return null;
1087
- if (!primary) return null;
1088
-
1089
- // Group by effective anchor.
1090
- const grouped: Record<EvoNotificationAnchor, InternalToast[]> = {
1091
- 'top-left': [], 'top-center': [], 'top-right': [],
1092
- 'bottom-left': [], 'bottom-center': [], 'bottom-right': [],
1093
- };
1094
- for (const t of all) {
1095
- const a = t.anchor ?? fallback;
1096
- grouped[a].push(t);
1097
- }
1098
-
1099
- return ReactDOM.createPortal(
1100
- <div className={cx(styles.toasterRoot, className)} aria-label="Notifications">
1101
- <div className={styles.srOnly} aria-live="polite" aria-atomic="true">
1102
- {politeMsg}
1103
- </div>
1104
- <div className={styles.srOnly} aria-live="assertive" aria-atomic="true">
1105
- {assertiveMsg}
1106
- </div>
1107
- {ANCHORS.map((a) => {
1108
- const group = grouped[a];
1109
- if (group.length === 0) return null;
1110
- const overflow = Math.max(0, group.length - maxVisible);
1111
- // Clamp the start index: a bare `group.length - maxVisible` goes
1112
- // negative when fewer than `maxVisible` toasts exist, and a negative
1113
- // slice counts from the end — dropping the oldest toasts (B1).
1114
- const visible = group.slice(Math.max(0, group.length - maxVisible));
1115
- const isHovered = hovered === a;
1116
- const pausedExt = !windowFocused;
1117
- return (
1118
- <div
1119
- key={a}
1120
- className={cx(styles.anchor, styles[`anchor-${a}`])}
1121
- onMouseEnter={() => setHovered(a)}
1122
- onMouseLeave={() => setHovered(null)}
1123
- onFocus={() => setHovered(a)}
1124
- onBlur={(e) => {
1125
- if (!e.currentTarget.contains(e.relatedTarget as Node)) setHovered(null);
1126
- }}
1127
- >
1128
- {overflow > 0 && (
1129
- <div className={styles.overflowPill} aria-hidden="true">
1130
- +{overflow} more
1131
- </div>
1132
- )}
1133
- {visible.map((t, i) => (
1134
- <ToastRow
1135
- key={t.id}
1136
- toast={t}
1137
- anchor={a}
1138
- index={i}
1139
- total={visible.length}
1140
- hovered={isHovered}
1141
- pausedExternally={pausedExt}
1142
- reducedMotion={reducedMotion}
1143
- />
1144
- ))}
1145
- </div>
1146
- );
1147
- })}
1148
- </div>,
1149
- document.body,
1150
- );
1151
- };
1152
- EvoNotificationToaster.displayName = 'EvoNotificationToaster';
1153
-
1154
- // ─── Bell ────────────────────────────────────────────────────
1155
-
1156
- export interface EvoNotificationBellProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
1157
- variant?: 'solid' | 'ghost';
1158
- size?: 'sm' | 'md' | 'lg';
1159
- hideZero?: boolean;
1160
- maxBadgeCount?: number;
1161
- panelPlacement?: 'bottom-end' | 'bottom-start' | 'bottom' | 'top-end' | 'top-start';
1162
- renderPanel?: 'popover' | 'none';
1163
- panelTitle?: ReactNode;
1164
- panelEmptyState?: ReactNode;
1165
- }
1166
-
1167
- export const EvoNotificationBell = forwardRef<HTMLButtonElement, EvoNotificationBellProps>(
1168
- function EvoNotificationBell(
1169
- {
1170
- variant = 'ghost',
1171
- size = 'md',
1172
- hideZero = true,
1173
- maxBadgeCount = 99,
1174
- panelPlacement = 'bottom-end',
1175
- renderPanel = 'popover',
1176
- panelTitle,
1177
- panelEmptyState,
1178
- className,
1179
- onClick,
1180
- ...rest
1181
- },
1182
- ref,
1183
- ) {
1184
- const { unread } = useInbox();
1185
- const [open, setOpen] = useState(false);
1186
- const wrapperRef = useRef<HTMLDivElement>(null);
1187
- const panelId = useId();
1188
-
1189
- useEffect(() => {
1190
- if (!open || renderPanel !== 'popover') return;
1191
- const onDocClick = (e: MouseEvent) => {
1192
- if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
1193
- };
1194
- const onKey = (e: KeyboardEvent) => {
1195
- if (e.key === 'Escape') setOpen(false);
1196
- };
1197
- document.addEventListener('mousedown', onDocClick);
1198
- document.addEventListener('keydown', onKey);
1199
- return () => {
1200
- document.removeEventListener('mousedown', onDocClick);
1201
- document.removeEventListener('keydown', onKey);
1202
- };
1203
- }, [open, renderPanel]);
1204
-
1205
- const badgeText = unread > maxBadgeCount ? `${maxBadgeCount}+` : String(unread);
1206
- const showBadge = unread > 0 || !hideZero;
1207
-
1208
- return (
1209
- <div ref={wrapperRef} className={styles.bellWrapper}>
1210
- <button
1211
- ref={ref}
1212
- type="button"
1213
- className={cx(
1214
- styles.bell,
1215
- styles[`bell-${variant}`],
1216
- styles[`bell-${size}`],
1217
- open && styles.bellOpen,
1218
- className,
1219
- )}
1220
- aria-label={
1221
- unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'
1222
- }
1223
- aria-haspopup={renderPanel === 'popover' ? 'dialog' : undefined}
1224
- aria-expanded={renderPanel === 'popover' ? open : undefined}
1225
- aria-controls={renderPanel === 'popover' ? panelId : undefined}
1226
- onClick={(e) => {
1227
- onClick?.(e);
1228
- if (renderPanel === 'popover') setOpen((v) => !v);
1229
- }}
1230
- {...rest}
1231
- >
1232
- <BellGlyph />
1233
- {showBadge && (
1234
- <span className={cx(styles.bellBadge, unread === 0 && styles.bellBadgeZero)}>
1235
- {badgeText}
1236
- </span>
1237
- )}
1238
- </button>
1239
- {renderPanel === 'popover' && open && (
1240
- <div
1241
- id={panelId}
1242
- className={cx(styles.bellPanelHost, styles[`place-${panelPlacement}`])}
1243
- >
1244
- <EvoNotificationPanel
1245
- open
1246
- onClose={() => setOpen(false)}
1247
- title={panelTitle}
1248
- emptyState={panelEmptyState}
1249
- />
1250
- </div>
1251
- )}
1252
- </div>
1253
- );
1254
- },
1255
- );
1256
- EvoNotificationBell.displayName = 'EvoNotificationBell';
1257
-
1258
- const BellGlyph = () => (
1259
- <svg
1260
- width="18"
1261
- height="18"
1262
- viewBox="0 0 24 24"
1263
- fill="none"
1264
- stroke="currentColor"
1265
- strokeWidth="1.8"
1266
- strokeLinecap="round"
1267
- strokeLinejoin="round"
1268
- aria-hidden="true"
1269
- >
1270
- <path d="M6 8a6 6 0 0 1 12 0c0 7 3 8 3 8H3s3-1 3-8" />
1271
- <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
1272
- </svg>
1273
- );
1274
-
1275
- // ─── Panel ───────────────────────────────────────────────────
1276
-
1277
- export interface EvoNotificationPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
1278
- open?: boolean;
1279
- onClose?: () => void;
1280
- title?: ReactNode;
1281
- emptyState?: ReactNode;
1282
- loading?: boolean;
1283
- error?: ReactNode;
1284
- showMarkAllRead?: boolean;
1285
- maxHeight?: number | string;
1286
- }
1287
-
1288
- export const EvoNotificationPanel = forwardRef<HTMLDivElement, EvoNotificationPanelProps>(
1289
- function EvoNotificationPanel(
1290
- {
1291
- open = true,
1292
- onClose,
1293
- title = 'Notifications',
1294
- emptyState,
1295
- loading = false,
1296
- error,
1297
- showMarkAllRead = true,
1298
- maxHeight = 480,
1299
- className,
1300
- style,
1301
- ...rest
1302
- },
1303
- ref,
1304
- ) {
1305
- const { items, unread } = useInbox();
1306
- if (!open) return null;
1307
-
1308
- return (
1309
- <div
1310
- ref={ref}
1311
- className={cx(styles.panel, className)}
1312
- role="dialog"
1313
- aria-label={typeof title === 'string' ? title : 'Notifications'}
1314
- style={style}
1315
- {...rest}
1316
- >
1317
- <header className={styles.panelHeader}>
1318
- <div className={styles.panelTitle}>{title}</div>
1319
- <div className={styles.panelHeaderActions}>
1320
- {showMarkAllRead && unread > 0 && (
1321
- <button
1322
- type="button"
1323
- className={styles.panelMarkAll}
1324
- onClick={() => store.markAllRead()}
1325
- >
1326
- Mark all read
1327
- </button>
1328
- )}
1329
- {onClose && (
1330
- <button
1331
- type="button"
1332
- className={styles.panelClose}
1333
- onClick={onClose}
1334
- aria-label="Close notifications"
1335
- >
1336
-
1337
- </button>
1338
- )}
1339
- </div>
1340
- </header>
1341
- <div
1342
- className={styles.panelBody}
1343
- style={{ maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight }}
1344
- >
1345
- {loading ? (
1346
- <div className={styles.panelState}>Loading…</div>
1347
- ) : error ? (
1348
- <div className={cx(styles.panelState, styles.panelStateError)}>
1349
- {typeof error === 'string' ? error : error}
1350
- </div>
1351
- ) : items.length === 0 ? (
1352
- <div className={styles.panelState}>
1353
- {emptyState ?? <DefaultEmptyState />}
1354
- </div>
1355
- ) : (
1356
- <ul className={styles.itemList}>
1357
- {items.map((item) => (
1358
- <li key={item.id}>
1359
- <EvoNotificationItem item={item} />
1360
- </li>
1361
- ))}
1362
- </ul>
1363
- )}
1364
- </div>
1365
- </div>
1366
- );
1367
- },
1368
- );
1369
- EvoNotificationPanel.displayName = 'EvoNotificationPanel';
1370
-
1371
- const DefaultEmptyState = () => (
1372
- <div className={styles.emptyState}>
1373
- <div className={styles.emptyIcon} aria-hidden="true">
1374
- <BellGlyph />
1375
- </div>
1376
- <div className={styles.emptyTitle}>You're all caught up</div>
1377
- <div className={styles.emptyHint}>New notifications will appear here.</div>
1378
- </div>
1379
- );
1380
-
1381
- // ─── Item ────────────────────────────────────────────────────
1382
-
1383
- export interface EvoNotificationItemProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onClick'> {
1384
- item: EvoInboxItem;
1385
- onClick?: (item: EvoInboxItem) => void;
1386
- }
1387
-
1388
- export const EvoNotificationItem = forwardRef<HTMLDivElement, EvoNotificationItemProps>(
1389
- function EvoNotificationItem({ item, onClick, className, ...rest }, ref) {
1390
- // Seed with the item's own timestamp (deterministic) so server and
1391
- // client render identical relative text; switch to the real clock once
1392
- // mounted. Seeding with `Date.now()` instead causes a hydration mismatch.
1393
- const [now, setNow] = useState(item.timestamp);
1394
-
1395
- useEffect(() => {
1396
- setNow(Date.now());
1397
- const id = window.setInterval(() => setNow(Date.now()), 60_000);
1398
- return () => window.clearInterval(id);
1399
- }, []);
1400
-
1401
- const handleClick = useCallback(() => {
1402
- (onClick ?? item.onClick)?.(item);
1403
- if (!item.read) store.markRead(item.id);
1404
- }, [item, onClick]);
1405
-
1406
- const handleKey = useCallback(
1407
- (e: React.KeyboardEvent) => {
1408
- if (e.key === 'Enter' || e.key === ' ') {
1409
- e.preventDefault();
1410
- handleClick();
1411
- }
1412
- },
1413
- [handleClick],
1414
- );
1415
-
1416
- const interactive = Boolean(onClick ?? item.onClick);
1417
- const icon = item.icon ?? ICON_GLYPHS[item.severity];
1418
-
1419
- return (
1420
- <div
1421
- ref={ref}
1422
- className={cx(
1423
- styles.item,
1424
- !item.read && styles.itemUnread,
1425
- interactive && styles.itemInteractive,
1426
- className,
1427
- )}
1428
- role={interactive ? 'button' : 'group'}
1429
- tabIndex={interactive ? 0 : undefined}
1430
- onClick={interactive ? handleClick : undefined}
1431
- onKeyDown={interactive ? handleKey : undefined}
1432
- {...rest}
1433
- >
1434
- <span className={styles.itemUnreadDot} aria-hidden={item.read} />
1435
- <div className={cx(styles.itemMedia, styles[`sev-${item.severity}`])} aria-hidden="true">
1436
- {item.avatarUrl ? (
1437
- <img src={item.avatarUrl} alt="" className={styles.itemAvatar} />
1438
- ) : (
1439
- <span className={styles.itemMediaGlyph}>{icon}</span>
1440
- )}
1441
- </div>
1442
- <div className={styles.itemBody}>
1443
- <div className={styles.itemTitle}>{item.title}</div>
1444
- {item.description != null && (
1445
- <div className={styles.itemDescription}>{item.description}</div>
1446
- )}
1447
- <div className={styles.itemMeta}>
1448
- <span className={styles.itemTimestamp}>{formatRelative(item.timestamp, now)}</span>
1449
- {item.action && (
1450
- <button
1451
- type="button"
1452
- className={styles.itemAction}
1453
- onClick={(e) => {
1454
- e.stopPropagation();
1455
- item.action!.onClick(e);
1456
- }}
1457
- >
1458
- {item.action.label}
1459
- </button>
1460
- )}
1461
- </div>
1462
- </div>
1463
- <button
1464
- type="button"
1465
- className={styles.itemDismiss}
1466
- onClick={(e) => {
1467
- e.stopPropagation();
1468
- store.removeInbox(item.id);
1469
- }}
1470
- aria-label="Remove notification"
1471
- >
1472
-
1473
- </button>
1474
- </div>
1475
- );
1476
- },
1477
- );
1478
- EvoNotificationItem.displayName = 'EvoNotificationItem';
1479
-
1480
- // ─── Hook (optional convenience) ─────────────────────────────
1481
-
1482
- export function useEvoInbox() {
1483
- return useInbox();
1484
- }
1485
-
1486
- // ─── Namespace export ────────────────────────────────────────
1487
-
1488
- type EvoNotificationNS = {
1489
- Provider: typeof EvoNotificationProvider;
1490
- Toaster: typeof EvoNotificationToaster;
1491
- Bell: typeof EvoNotificationBell;
1492
- Panel: typeof EvoNotificationPanel;
1493
- Item: typeof EvoNotificationItem;
1494
- };
1495
-
1496
- export const EvoNotification: EvoNotificationNS = {
1497
- Provider: EvoNotificationProvider,
1498
- Toaster: EvoNotificationToaster,
1499
- Bell: EvoNotificationBell,
1500
- Panel: EvoNotificationPanel,
1501
- Item: EvoNotificationItem,
1502
- };
1503
-
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ useSyncExternalStore,
10
+ type ButtonHTMLAttributes,
11
+ type CSSProperties,
12
+ type HTMLAttributes,
13
+ type ReactNode,
14
+ } from 'react';
15
+ import ReactDOM from 'react-dom';
16
+ import styles from '../css/notification.module.scss';
17
+
18
+ // ============================================================
19
+ // EvoNotification — unified toast + notification-center system
20
+ // ------------------------------------------------------------
21
+ // Design notes (see CLAUDE.md §2 research stanza):
22
+ // • API shape borrowed from Sonner — module-level singleton
23
+ // so any file can `import { evoNotify }` and call it
24
+ // without a hook or being inside the React tree.
25
+ // • a11y model borrowed from Radix Toast — error → assertive,
26
+ // everything else → polite. Hover/focus pauses timers.
27
+ // • queue/limit/overflow-fold borrowed from Mantine — past
28
+ // `maxVisible` toasts collapse into a "+N more" pill.
29
+ // • inbox/bell/panel shape borrowed from MagicBell + Knock —
30
+ // read/unread, mark-all-read, empty/loading/error slots.
31
+ // • Animations are hand-rolled (zero deps): CSS keyframes for
32
+ // enter/exit and a CSS transition on the stacking transform
33
+ // for reorder; one global opt-out via `prefers-reduced-motion`.
34
+ // • Evo-specific extras: `groupKey` coalesces repeat toasts into
35
+ // a single counted card; `toast.progress()` drives a
36
+ // determinate progress bar to a success/error end state.
37
+ // ============================================================
38
+
39
+ // ─── Types ───────────────────────────────────────────────────
40
+
41
+ export type EvoNotificationSeverity = 'success' | 'error' | 'warning' | 'info';
42
+
43
+ export type EvoNotificationAnchor =
44
+ | 'top-left' | 'top-center' | 'top-right'
45
+ | 'bottom-left' | 'bottom-center' | 'bottom-right';
46
+
47
+ export interface EvoNotificationAction {
48
+ label: string;
49
+ onClick: (e: React.MouseEvent) => void;
50
+ }
51
+
52
+ export interface EvoToastOptions {
53
+ id?: string;
54
+ title?: ReactNode;
55
+ description?: ReactNode;
56
+ severity?: EvoNotificationSeverity;
57
+ icon?: ReactNode;
58
+ duration?: number;
59
+ persistent?: boolean;
60
+ anchor?: EvoNotificationAnchor;
61
+ action?: EvoNotificationAction;
62
+ dismissible?: boolean;
63
+ onDismiss?: (id: string) => void;
64
+ onAutoClose?: (id: string) => void;
65
+ className?: string;
66
+ inbox?: boolean | Partial<EvoInboxItemInput>;
67
+ /**
68
+ * Coalescing key. Toasts pushed with the same `groupKey` while an earlier
69
+ * one is still on screen fold into it — the card refreshes in place and
70
+ * shows an incremented count badge instead of stacking a duplicate.
71
+ * Ignored when an explicit `id` is supplied (id matching takes priority).
72
+ */
73
+ groupKey?: string;
74
+ /**
75
+ * Determinate progress, 0–1. When set, the toast renders a progress bar.
76
+ * Values outside the range are clamped. Usually driven via
77
+ * `evoNotify.toast.progress()`, but valid on any toast.
78
+ */
79
+ progress?: number;
80
+ }
81
+
82
+ export interface EvoPromiseMessages<T> {
83
+ loading: ReactNode | EvoToastOptions;
84
+ success: ReactNode | ((value: T) => ReactNode | EvoToastOptions);
85
+ error: ReactNode | ((err: unknown) => ReactNode | EvoToastOptions);
86
+ }
87
+
88
+ /**
89
+ * Handle returned by `evoNotify.toast.progress()`. Drives a determinate
90
+ * progress bar and resolves the toast to a success or error end state.
91
+ */
92
+ export interface EvoToastProgressHandle {
93
+ /** The underlying toast id — usable with `evoNotify.toast.dismiss`. */
94
+ readonly id: string;
95
+ /** Set the bar fill, 0–1 (values outside the range are clamped). */
96
+ setProgress: (value: number) => void;
97
+ /** Patch any toast option (title, description, severity, …). */
98
+ update: (options: EvoToastOptions) => void;
99
+ /** Resolve as success: bar fills to 100%, the toast then auto-dismisses. */
100
+ done: (options?: EvoToastOptions) => void;
101
+ /** Resolve as error: the toast becomes dismissible and auto-dismisses. */
102
+ fail: (options?: EvoToastOptions) => void;
103
+ /** Dismiss the toast immediately. */
104
+ dismiss: () => void;
105
+ }
106
+
107
+ export interface EvoInboxItemInput {
108
+ id?: string;
109
+ title: ReactNode;
110
+ description?: ReactNode;
111
+ severity?: EvoNotificationSeverity;
112
+ icon?: ReactNode;
113
+ avatarUrl?: string;
114
+ timestamp?: number | Date;
115
+ read?: boolean;
116
+ action?: EvoNotificationAction;
117
+ onClick?: (item: EvoInboxItem) => void;
118
+ meta?: Record<string, unknown>;
119
+ toast?: boolean | Partial<EvoToastOptions>;
120
+ }
121
+
122
+ export interface EvoInboxItem {
123
+ id: string;
124
+ title: ReactNode;
125
+ description?: ReactNode;
126
+ severity: EvoNotificationSeverity;
127
+ icon?: ReactNode;
128
+ avatarUrl?: string;
129
+ timestamp: number;
130
+ read: boolean;
131
+ action?: EvoNotificationAction;
132
+ onClick?: (item: EvoInboxItem) => void;
133
+ meta?: Record<string, unknown>;
134
+ }
135
+
136
+ interface InternalToast extends EvoToastOptions {
137
+ id: string;
138
+ severity: EvoNotificationSeverity;
139
+ createdAt: number;
140
+ message: ReactNode;
141
+ /** How many times this toast has been (re)pushed under its `groupKey`. */
142
+ count: number;
143
+ /** Bumped on every re-push / update so the row can restart its timer. */
144
+ restartKey: number;
145
+ /** True while the exit animation plays, before the store drops it. */
146
+ exiting?: boolean;
147
+ }
148
+
149
+ // ─── Module-level store ──────────────────────────────────────
150
+ // Singleton so `evoNotify.toast(...)` works from any file
151
+ // without requiring a hook or React context.
152
+
153
+ type ToastListener = (toasts: InternalToast[]) => void;
154
+ type InboxListener = (state: { items: EvoInboxItem[]; unread: number }) => void;
155
+
156
+ interface ProviderConfig {
157
+ defaultAnchor: EvoNotificationAnchor;
158
+ defaultDuration: number;
159
+ maxVisible: number;
160
+ pauseOnFocusLoss: boolean;
161
+ persistErrors: boolean;
162
+ }
163
+
164
+ const DEFAULT_CONFIG: ProviderConfig = {
165
+ defaultAnchor: 'top-right',
166
+ defaultDuration: 4000,
167
+ maxVisible: 3,
168
+ pauseOnFocusLoss: true,
169
+ persistErrors: false,
170
+ };
171
+
172
+ // Exit-animation duration. Must stay in sync with the `evoToastExit`
173
+ // keyframe length in notification.module.scss.
174
+ const EXIT_MS = 180;
175
+
176
+ // Stable empty references for `useSyncExternalStore` server/initial snapshots.
177
+ // Returning a fresh array/object from getServerSnapshot triggers an infinite
178
+ // render loop, so these MUST be the same identity on every read.
179
+ const EMPTY_TOASTS: ReadonlyArray<InternalToast> = Object.freeze([]) as ReadonlyArray<InternalToast>;
180
+ const EMPTY_INBOX_STATE: { items: EvoInboxItem[]; unread: number } = Object.freeze({
181
+ items: Object.freeze([]) as unknown as EvoInboxItem[],
182
+ unread: 0,
183
+ }) as { items: EvoInboxItem[]; unread: number };
184
+
185
+ class NotificationStore {
186
+ private toasts: InternalToast[] = [];
187
+ private inboxItems: EvoInboxItem[] = [];
188
+ // Cached inbox snapshot — same reference until inboxItems mutates.
189
+ private inboxSnapshot: { items: EvoInboxItem[]; unread: number } = {
190
+ items: [],
191
+ unread: 0,
192
+ };
193
+ private inboxOwnedExternally = false;
194
+ private inboxOnChange: ((items: EvoInboxItem[]) => void) | null = null;
195
+ private toastListeners = new Set<ToastListener>();
196
+ private inboxListeners = new Set<InboxListener>();
197
+ private configListeners = new Set<() => void>();
198
+ private config: ProviderConfig = DEFAULT_CONFIG;
199
+ private counter = 0;
200
+
201
+ setConfig(next: Partial<ProviderConfig>) {
202
+ const merged = { ...this.config, ...next };
203
+ const changed = (Object.keys(merged) as Array<keyof ProviderConfig>)
204
+ .some((k) => merged[k] !== this.config[k]);
205
+ if (!changed) return;
206
+ this.config = merged;
207
+ this.notifyConfig();
208
+ }
209
+
210
+ getConfig() {
211
+ return this.config;
212
+ }
213
+
214
+ subscribeConfig(fn: () => void) {
215
+ this.configListeners.add(fn);
216
+ return () => {
217
+ this.configListeners.delete(fn);
218
+ };
219
+ }
220
+
221
+ private notifyConfig() {
222
+ this.configListeners.forEach((fn) => fn());
223
+ }
224
+
225
+ // Resolves a toast's lifetime. `persistent` wins, then an explicit
226
+ // `duration`, then the `persistErrors` config (errors stay until the
227
+ // user dismisses them), then the global default.
228
+ private resolveDuration(
229
+ options: EvoToastOptions,
230
+ severity: EvoNotificationSeverity,
231
+ ): number {
232
+ if (options.persistent === true) return Infinity;
233
+ if (options.duration != null) return options.duration;
234
+ if (
235
+ severity === 'error' &&
236
+ this.config.persistErrors &&
237
+ options.persistent !== false
238
+ ) {
239
+ return Infinity;
240
+ }
241
+ return this.config.defaultDuration;
242
+ }
243
+
244
+ bindExternalInbox(items: EvoInboxItem[] | undefined, onChange: ((items: EvoInboxItem[]) => void) | undefined) {
245
+ if (items !== undefined) {
246
+ this.inboxOwnedExternally = true;
247
+ this.inboxItems = items;
248
+ this.inboxOnChange = onChange ?? null;
249
+ this.refreshInboxSnapshot();
250
+ this.notifyInbox();
251
+ } else {
252
+ this.inboxOwnedExternally = false;
253
+ this.inboxOnChange = null;
254
+ }
255
+ }
256
+
257
+ private refreshInboxSnapshot() {
258
+ let unread = 0;
259
+ for (const i of this.inboxItems) if (!i.read) unread += 1;
260
+ this.inboxSnapshot = { items: this.inboxItems, unread };
261
+ }
262
+
263
+ private nextId() {
264
+ this.counter += 1;
265
+ return `evo-${Date.now().toString(36)}-${this.counter}`;
266
+ }
267
+
268
+ // ----- Toast methods -----
269
+
270
+ pushToast(message: ReactNode, options: EvoToastOptions = {}): string {
271
+ const severity = options.severity ?? 'info';
272
+
273
+ // Coalescing: a toast carrying a `groupKey` (and no explicit id) folds
274
+ // into any still-active toast sharing that key — the card refreshes in
275
+ // place with an incremented count instead of stacking a duplicate.
276
+ if (options.groupKey != null && options.id == null) {
277
+ const group = this.toasts.find(
278
+ (t) => t.groupKey === options.groupKey && !t.exiting,
279
+ );
280
+ if (group) {
281
+ this.toasts = this.toasts.map((t): InternalToast =>
282
+ t.id === group.id
283
+ ? {
284
+ ...t,
285
+ ...options,
286
+ id: group.id,
287
+ severity,
288
+ duration: this.resolveDuration(options, severity),
289
+ message,
290
+ count: t.count + 1,
291
+ restartKey: t.restartKey + 1,
292
+ }
293
+ : t,
294
+ );
295
+ this.applyInboxSideEffect(group.id, message, options);
296
+ this.notifyToasts();
297
+ return group.id;
298
+ }
299
+ }
300
+
301
+ const id = options.id ?? this.nextId();
302
+ const duration = this.resolveDuration(options, severity);
303
+
304
+ const existing = this.toasts.find((t) => t.id === id);
305
+ if (existing) {
306
+ // Re-pushing under an existing id refreshes it, restarts its timer,
307
+ // and revives it if it happened to be mid-exit.
308
+ this.toasts = this.toasts.map((t): InternalToast =>
309
+ t.id === id
310
+ ? {
311
+ ...t,
312
+ ...options,
313
+ id,
314
+ severity,
315
+ duration,
316
+ message,
317
+ restartKey: t.restartKey + 1,
318
+ exiting: false,
319
+ }
320
+ : t,
321
+ );
322
+ } else {
323
+ const toast: InternalToast = {
324
+ ...options,
325
+ id,
326
+ severity,
327
+ duration,
328
+ message,
329
+ createdAt: Date.now(),
330
+ count: 1,
331
+ restartKey: 0,
332
+ };
333
+ this.toasts = [...this.toasts, toast];
334
+ }
335
+
336
+ this.applyInboxSideEffect(id, message, options);
337
+ this.notifyToasts();
338
+ return id;
339
+ }
340
+
341
+ private applyInboxSideEffect(
342
+ id: string,
343
+ message: ReactNode,
344
+ options: EvoToastOptions,
345
+ ) {
346
+ if (!options.inbox) return;
347
+ const inboxInput: EvoInboxItemInput = {
348
+ id: `${id}-inbox`,
349
+ title: options.title ?? message,
350
+ description: options.description,
351
+ severity: options.severity ?? 'info',
352
+ icon: options.icon,
353
+ action: options.action,
354
+ ...(typeof options.inbox === 'object' ? options.inbox : {}),
355
+ };
356
+ this.pushInbox(inboxInput);
357
+ }
358
+
359
+ updateToast(id: string, options: EvoToastOptions) {
360
+ const idx = this.toasts.findIndex((t) => t.id === id);
361
+ if (idx === -1) return;
362
+ const prev = this.toasts[idx];
363
+ const next: InternalToast = {
364
+ ...prev,
365
+ ...options,
366
+ id,
367
+ severity: options.severity ?? prev.severity,
368
+ message: 'title' in options && options.title != null ? options.title : prev.message,
369
+ // Bumped so the row resets its auto-dismiss countdown for the
370
+ // refreshed content (e.g. a resolved promise toast).
371
+ restartKey: prev.restartKey + 1,
372
+ };
373
+ // Re-resolve the lifetime. Without the `persistent === false` branch a
374
+ // toast that was persistent (a loading/progress toast) would keep its
375
+ // `Infinity` duration and never auto-dismiss after resolving.
376
+ if (options.persistent === true) {
377
+ next.duration = Infinity;
378
+ } else if (options.duration != null) {
379
+ next.duration = options.duration;
380
+ } else if (options.persistent === false) {
381
+ next.duration = this.resolveDuration(
382
+ { ...options, persistent: false },
383
+ next.severity,
384
+ );
385
+ }
386
+ this.toasts = this.toasts.map((t) => (t.id === id ? next : t));
387
+ this.notifyToasts();
388
+ }
389
+
390
+ // Phase 1 of removal: flag the toast(s) as exiting and fire the close
391
+ // callback. The row plays the exit animation, then calls `removeToast`.
392
+ // Every removal path — close button, action, auto-close, dismissAll —
393
+ // funnels through here, so exit animation is always consistent.
394
+ dismissToast(id?: string, reason: 'dismiss' | 'auto' = 'dismiss') {
395
+ if (id == null) {
396
+ const active = this.toasts.filter((t) => !t.exiting);
397
+ if (active.length === 0) return;
398
+ active.forEach((t) => t.onDismiss?.(t.id));
399
+ this.toasts = this.toasts.map((t): InternalToast =>
400
+ t.exiting ? t : { ...t, exiting: true },
401
+ );
402
+ this.notifyToasts();
403
+ return;
404
+ }
405
+ const target = this.toasts.find((t) => t.id === id);
406
+ if (!target || target.exiting) return;
407
+ if (reason === 'auto') target.onAutoClose?.(id);
408
+ else target.onDismiss?.(id);
409
+ this.toasts = this.toasts.map((t): InternalToast =>
410
+ t.id === id ? { ...t, exiting: true } : t,
411
+ );
412
+ this.notifyToasts();
413
+ }
414
+
415
+ // Phase 2: drop the toast once its exit animation has finished.
416
+ removeToast(id: string) {
417
+ const before = this.toasts.length;
418
+ this.toasts = this.toasts.filter((t) => t.id !== id);
419
+ if (this.toasts.length !== before) this.notifyToasts();
420
+ }
421
+
422
+ getToasts() {
423
+ return this.toasts;
424
+ }
425
+
426
+ subscribeToasts(fn: ToastListener) {
427
+ this.toastListeners.add(fn);
428
+ return () => {
429
+ this.toastListeners.delete(fn);
430
+ };
431
+ }
432
+
433
+ private notifyToasts() {
434
+ this.toastListeners.forEach((fn) => fn(this.toasts));
435
+ }
436
+
437
+ // ----- Inbox methods -----
438
+
439
+ pushInbox(input: EvoInboxItemInput): string {
440
+ const id = input.id ?? this.nextId();
441
+ const ts = input.timestamp instanceof Date
442
+ ? input.timestamp.getTime()
443
+ : input.timestamp ?? Date.now();
444
+
445
+ const item: EvoInboxItem = {
446
+ id,
447
+ title: input.title,
448
+ description: input.description,
449
+ severity: input.severity ?? 'info',
450
+ icon: input.icon,
451
+ avatarUrl: input.avatarUrl,
452
+ timestamp: ts,
453
+ read: input.read ?? false,
454
+ action: input.action,
455
+ onClick: input.onClick,
456
+ meta: input.meta,
457
+ };
458
+
459
+ const idx = this.inboxItems.findIndex((i) => i.id === id);
460
+ const next = idx === -1
461
+ ? [item, ...this.inboxItems]
462
+ : this.inboxItems.map((i) => (i.id === id ? item : i));
463
+ this.commitInbox(next);
464
+
465
+ if (input.toast) {
466
+ const toastInput: EvoToastOptions = {
467
+ id: `${id}-toast`,
468
+ title: item.title,
469
+ description: item.description,
470
+ severity: item.severity,
471
+ icon: item.icon,
472
+ action: item.action,
473
+ ...(typeof input.toast === 'object' ? input.toast : {}),
474
+ };
475
+ this.pushToast(item.title, toastInput);
476
+ }
477
+
478
+ return id;
479
+ }
480
+
481
+ markRead(id: string) {
482
+ this.commitInbox(this.inboxItems.map((i) => (i.id === id ? { ...i, read: true } : i)));
483
+ }
484
+
485
+ markUnread(id: string) {
486
+ this.commitInbox(this.inboxItems.map((i) => (i.id === id ? { ...i, read: false } : i)));
487
+ }
488
+
489
+ markAllRead() {
490
+ this.commitInbox(this.inboxItems.map((i) => (i.read ? i : { ...i, read: true })));
491
+ }
492
+
493
+ removeInbox(id: string) {
494
+ this.commitInbox(this.inboxItems.filter((i) => i.id !== id));
495
+ }
496
+
497
+ clearInbox() {
498
+ this.commitInbox([]);
499
+ }
500
+
501
+ setInboxItems(items: EvoInboxItem[]) {
502
+ this.commitInbox(items);
503
+ }
504
+
505
+ getInboxState() {
506
+ return this.inboxSnapshot;
507
+ }
508
+
509
+ subscribeInbox(fn: InboxListener) {
510
+ this.inboxListeners.add(fn);
511
+ return () => {
512
+ this.inboxListeners.delete(fn);
513
+ };
514
+ }
515
+
516
+ private commitInbox(next: EvoInboxItem[]) {
517
+ this.inboxItems = next;
518
+ this.refreshInboxSnapshot();
519
+ if (this.inboxOwnedExternally) {
520
+ this.inboxOnChange?.(next);
521
+ }
522
+ this.notifyInbox();
523
+ }
524
+
525
+ private notifyInbox() {
526
+ const state = this.inboxSnapshot;
527
+ this.inboxListeners.forEach((fn) => fn(state));
528
+ }
529
+ }
530
+
531
+ const store = new NotificationStore();
532
+
533
+ // ─── Public singleton API ────────────────────────────────────
534
+
535
+ export interface EvoNotifyAPI {
536
+ toast: {
537
+ (message: ReactNode, options?: EvoToastOptions): string;
538
+ success: (message: ReactNode, options?: EvoToastOptions) => string;
539
+ error: (message: ReactNode, options?: EvoToastOptions) => string;
540
+ warning: (message: ReactNode, options?: EvoToastOptions) => string;
541
+ info: (message: ReactNode, options?: EvoToastOptions) => string;
542
+ loading: (message: ReactNode, options?: EvoToastOptions) => string;
543
+ promise: <T>(p: Promise<T> | (() => Promise<T>), msgs: EvoPromiseMessages<T>) => string;
544
+ progress: (message: ReactNode, options?: EvoToastOptions) => EvoToastProgressHandle;
545
+ update: (id: string, options: EvoToastOptions) => void;
546
+ dismiss: (id?: string) => void;
547
+ };
548
+ push: (item: EvoInboxItemInput) => string;
549
+ inbox: {
550
+ markRead: (id: string) => void;
551
+ markUnread: (id: string) => void;
552
+ markAllRead: () => void;
553
+ remove: (id: string) => void;
554
+ clear: () => void;
555
+ setItems: (items: EvoInboxItem[]) => void;
556
+ getState: () => { items: EvoInboxItem[]; unread: number };
557
+ subscribe: (fn: (s: { items: EvoInboxItem[]; unread: number }) => void) => () => void;
558
+ };
559
+ dismissAll: () => void;
560
+ }
561
+
562
+ const baseToast = (message: ReactNode, options?: EvoToastOptions) =>
563
+ store.pushToast(message, options);
564
+
565
+ const toastApi = baseToast as EvoNotifyAPI['toast'];
566
+
567
+ toastApi.success = (m, o) => store.pushToast(m, { ...o, severity: 'success' });
568
+ toastApi.error = (m, o) => store.pushToast(m, { ...o, severity: 'error' });
569
+ toastApi.warning = (m, o) => store.pushToast(m, { ...o, severity: 'warning' });
570
+ toastApi.info = (m, o) => store.pushToast(m, { ...o, severity: 'info' });
571
+ toastApi.loading = (m, o) =>
572
+ store.pushToast(m, { ...o, severity: 'info', persistent: true, dismissible: false });
573
+ toastApi.update = (id, o) => store.updateToast(id, o);
574
+ toastApi.dismiss = (id) => store.dismissToast(id);
575
+ toastApi.promise = <T,>(
576
+ p: Promise<T> | (() => Promise<T>),
577
+ msgs: EvoPromiseMessages<T>,
578
+ ) => {
579
+ const id = store.pushToast(
580
+ typeof msgs.loading === 'object' && msgs.loading !== null && !isReactNode(msgs.loading)
581
+ ? (msgs.loading as EvoToastOptions).title ?? ''
582
+ : (msgs.loading as ReactNode),
583
+ {
584
+ ...(typeof msgs.loading === 'object' && !isReactNode(msgs.loading)
585
+ ? (msgs.loading as EvoToastOptions)
586
+ : {}),
587
+ severity: 'info',
588
+ persistent: true,
589
+ dismissible: false,
590
+ },
591
+ );
592
+
593
+ const promise = typeof p === 'function' ? p() : p;
594
+
595
+ promise.then(
596
+ (value) => {
597
+ const resolved = typeof msgs.success === 'function'
598
+ ? (msgs.success as (v: T) => ReactNode | EvoToastOptions)(value)
599
+ : msgs.success;
600
+ const isOpts = resolved !== null && typeof resolved === 'object' && !isReactNode(resolved);
601
+ const opts = isOpts ? (resolved as EvoToastOptions) : {};
602
+ const msg = isOpts ? opts.title ?? '' : (resolved as ReactNode);
603
+ store.updateToast(id, {
604
+ ...opts,
605
+ severity: 'success',
606
+ persistent: false,
607
+ dismissible: true,
608
+ title: msg,
609
+ });
610
+ },
611
+ (err) => {
612
+ const resolved = typeof msgs.error === 'function'
613
+ ? (msgs.error as (e: unknown) => ReactNode | EvoToastOptions)(err)
614
+ : msgs.error;
615
+ const isOpts = resolved !== null && typeof resolved === 'object' && !isReactNode(resolved);
616
+ const opts = isOpts ? (resolved as EvoToastOptions) : {};
617
+ const msg = isOpts ? opts.title ?? '' : (resolved as ReactNode);
618
+ store.updateToast(id, {
619
+ ...opts,
620
+ severity: 'error',
621
+ persistent: false,
622
+ dismissible: true,
623
+ title: msg,
624
+ });
625
+ },
626
+ );
627
+
628
+ return id;
629
+ };
630
+
631
+ toastApi.progress = (message, options) => {
632
+ const id = store.pushToast(message, {
633
+ ...options,
634
+ severity: options?.severity ?? 'info',
635
+ persistent: true,
636
+ dismissible: options?.dismissible ?? false,
637
+ progress: clamp01(options?.progress ?? 0),
638
+ });
639
+ return {
640
+ id,
641
+ setProgress: (value) => store.updateToast(id, { progress: clamp01(value) }),
642
+ update: (opts) => store.updateToast(id, opts),
643
+ done: (opts) =>
644
+ store.updateToast(id, {
645
+ ...opts,
646
+ severity: opts?.severity ?? 'success',
647
+ progress: 1,
648
+ persistent: false,
649
+ dismissible: true,
650
+ }),
651
+ fail: (opts) =>
652
+ store.updateToast(id, {
653
+ ...opts,
654
+ severity: opts?.severity ?? 'error',
655
+ persistent: false,
656
+ dismissible: true,
657
+ }),
658
+ dismiss: () => store.dismissToast(id),
659
+ };
660
+ };
661
+
662
+ function isReactNode(v: unknown): boolean {
663
+ if (v == null) return true;
664
+ const t = typeof v;
665
+ if (t === 'string' || t === 'number' || t === 'boolean') return true;
666
+ if (Array.isArray(v)) return true;
667
+ if (t === 'object' && v !== null && '$$typeof' in (v as object)) return true;
668
+ return false;
669
+ }
670
+
671
+ /** Clamps a number into the 0–1 range; non-numbers and NaN fall back to 0. */
672
+ function clamp01(n: number): number {
673
+ if (typeof n !== 'number' || Number.isNaN(n)) return 0;
674
+ return n < 0 ? 0 : n > 1 ? 1 : n;
675
+ }
676
+
677
+ export const evoNotify: EvoNotifyAPI = {
678
+ toast: toastApi,
679
+ push: (item) => store.pushInbox(item),
680
+ inbox: {
681
+ markRead: (id) => store.markRead(id),
682
+ markUnread: (id) => store.markUnread(id),
683
+ markAllRead: () => store.markAllRead(),
684
+ remove: (id) => store.removeInbox(id),
685
+ clear: () => store.clearInbox(),
686
+ setItems: (items) => store.setInboxItems(items),
687
+ getState: () => store.getInboxState(),
688
+ subscribe: (fn) => store.subscribeInbox(fn),
689
+ },
690
+ dismissAll: () => store.dismissToast(),
691
+ };
692
+
693
+ // ─── Helpers ─────────────────────────────────────────────────
694
+
695
+ function cx(...parts: Array<string | undefined | false | null>) {
696
+ return parts.filter(Boolean).join(' ');
697
+ }
698
+
699
+ const ICON_GLYPHS: Record<EvoNotificationSeverity, string> = {
700
+ success: '✓',
701
+ error: '✕',
702
+ warning: '!',
703
+ info: 'i',
704
+ };
705
+
706
+ function formatRelative(ts: number, now: number): string {
707
+ const d = Math.max(0, now - ts);
708
+ const s = Math.floor(d / 1000);
709
+ if (s < 60) return 'just now';
710
+ const m = Math.floor(s / 60);
711
+ if (m < 60) return `${m}m ago`;
712
+ const h = Math.floor(m / 60);
713
+ if (h < 24) return `${h}h ago`;
714
+ const days = Math.floor(h / 24);
715
+ if (days < 7) return `${days}d ago`;
716
+ return new Date(ts).toLocaleDateString();
717
+ }
718
+
719
+ // Extracts plain text from a toast for the screen-reader live region and the
720
+ // row's `aria-label`. JSX titles/descriptions yield '' (announced silently).
721
+ function toastText(t: InternalToast): string {
722
+ const parts: string[] = [];
723
+ const title = t.title ?? t.message;
724
+ if (typeof title === 'string' || typeof title === 'number') {
725
+ parts.push(String(title));
726
+ }
727
+ if (typeof t.description === 'string' || typeof t.description === 'number') {
728
+ parts.push(String(t.description));
729
+ }
730
+ return parts.join('. ');
731
+ }
732
+
733
+ function useToasts() {
734
+ return useSyncExternalStore(
735
+ (cb) => store.subscribeToasts(() => cb()),
736
+ () => store.getToasts(),
737
+ () => EMPTY_TOASTS as InternalToast[],
738
+ );
739
+ }
740
+
741
+ function useInbox() {
742
+ return useSyncExternalStore(
743
+ (cb) => store.subscribeInbox(() => cb()),
744
+ () => store.getInboxState(),
745
+ () => EMPTY_INBOX_STATE,
746
+ );
747
+ }
748
+
749
+ // Subscribes to provider config so the Toaster reacts to live changes of
750
+ // `maxVisible` / `pauseOnFocusLoss` / etc. instead of reading a stale value.
751
+ function useConfig() {
752
+ return useSyncExternalStore(
753
+ (cb) => store.subscribeConfig(cb),
754
+ () => store.getConfig(),
755
+ () => DEFAULT_CONFIG,
756
+ );
757
+ }
758
+
759
+ // ─── Provider ────────────────────────────────────────────────
760
+
761
+ export interface EvoNotificationProviderProps {
762
+ children: ReactNode;
763
+ defaultAnchor?: EvoNotificationAnchor;
764
+ maxVisible?: number;
765
+ defaultDuration?: number;
766
+ pauseOnFocusLoss?: boolean;
767
+ /**
768
+ * When true, `error` toasts stay until dismissed instead of auto-closing.
769
+ * Recommended for accessibility — errors should not vanish on a timer.
770
+ * A per-toast `duration` or `persistent` still overrides this.
771
+ */
772
+ persistErrors?: boolean;
773
+ inboxItems?: EvoInboxItem[];
774
+ onInboxChange?: (items: EvoInboxItem[]) => void;
775
+ }
776
+
777
+ export const EvoNotificationProvider = ({
778
+ children,
779
+ defaultAnchor = 'top-right',
780
+ maxVisible = 3,
781
+ defaultDuration = 4000,
782
+ pauseOnFocusLoss = true,
783
+ persistErrors = false,
784
+ inboxItems,
785
+ onInboxChange,
786
+ }: EvoNotificationProviderProps) => {
787
+ // Push config into the store on every render where it changes.
788
+ useLayoutEffect(() => {
789
+ store.setConfig({ defaultAnchor, maxVisible, defaultDuration, pauseOnFocusLoss, persistErrors });
790
+ }, [defaultAnchor, maxVisible, defaultDuration, pauseOnFocusLoss, persistErrors]);
791
+
792
+ useLayoutEffect(() => {
793
+ store.bindExternalInbox(inboxItems, onInboxChange);
794
+ }, [inboxItems, onInboxChange]);
795
+
796
+ return <>{children}</>;
797
+ };
798
+ EvoNotificationProvider.displayName = 'EvoNotificationProvider';
799
+
800
+ // ─── Toaster ─────────────────────────────────────────────────
801
+
802
+ export interface EvoNotificationToasterProps {
803
+ anchor?: EvoNotificationAnchor;
804
+ className?: string;
805
+ }
806
+
807
+ interface ToastRowProps {
808
+ toast: InternalToast;
809
+ // Effective anchor for this toast (per-toast override resolved against the
810
+ // toaster default). Drives which way the depth offset leans.
811
+ anchor: EvoNotificationAnchor;
812
+ index: number;
813
+ total: number;
814
+ hovered: boolean;
815
+ pausedExternally: boolean;
816
+ reducedMotion: boolean;
817
+ }
818
+
819
+ const ToastRow = ({ toast, anchor, index, total, hovered, pausedExternally, reducedMotion }: ToastRowProps) => {
820
+ const elapsedRef = useRef(0);
821
+ const timerStartRef = useRef<number | null>(null);
822
+ const timeoutRef = useRef<number | null>(null);
823
+ const restartRef = useRef(toast.restartKey);
824
+
825
+ const exiting = toast.exiting ?? false;
826
+ const paused = hovered || pausedExternally;
827
+ const finite = Number.isFinite(toast.duration);
828
+
829
+ // Phase 2 of removal: once the store flags this toast `exiting`, let the
830
+ // CSS exit animation play, then drop it from the store. Close button,
831
+ // action, auto-close and dismissAll all reach this path, so every removal
832
+ // animates identically — and the timeout is cleaned up on unmount.
833
+ useEffect(() => {
834
+ if (!exiting) return;
835
+ const t = window.setTimeout(
836
+ () => store.removeToast(toast.id),
837
+ reducedMotion ? 0 : EXIT_MS,
838
+ );
839
+ return () => window.clearTimeout(t);
840
+ }, [exiting, reducedMotion, toast.id]);
841
+
842
+ // Auto-dismiss countdown. Pauses on hover / window blur, resets when the
843
+ // toast is re-pushed or updated (restartKey bump), and stops once exiting.
844
+ useEffect(() => {
845
+ if (restartRef.current !== toast.restartKey) {
846
+ restartRef.current = toast.restartKey;
847
+ elapsedRef.current = 0;
848
+ }
849
+ if (!finite || exiting) return;
850
+ if (paused) {
851
+ if (timerStartRef.current != null) {
852
+ elapsedRef.current += Date.now() - timerStartRef.current;
853
+ timerStartRef.current = null;
854
+ }
855
+ if (timeoutRef.current != null) {
856
+ window.clearTimeout(timeoutRef.current);
857
+ timeoutRef.current = null;
858
+ }
859
+ return;
860
+ }
861
+
862
+ const remaining = Math.max(0, (toast.duration as number) - elapsedRef.current);
863
+ timerStartRef.current = Date.now();
864
+ timeoutRef.current = window.setTimeout(
865
+ () => store.dismissToast(toast.id, 'auto'),
866
+ remaining,
867
+ );
868
+
869
+ return () => {
870
+ if (timeoutRef.current != null) {
871
+ window.clearTimeout(timeoutRef.current);
872
+ timeoutRef.current = null;
873
+ }
874
+ if (timerStartRef.current != null) {
875
+ elapsedRef.current += Date.now() - timerStartRef.current;
876
+ timerStartRef.current = null;
877
+ }
878
+ };
879
+ }, [paused, finite, exiting, toast.duration, toast.restartKey, toast.id]);
880
+
881
+ // Stacked appearance: items behind the front fade and scale slightly,
882
+ // expand on hover (Sonner-style).
883
+ const depth = total - 1 - index;
884
+ const baseScale = hovered ? 1 : Math.max(0.94, 1 - depth * 0.04);
885
+ const baseTranslate = hovered ? depth * 8 : depth * 6;
886
+ const baseOpacity = hovered ? 1 : Math.max(0.7, 1 - depth * 0.15);
887
+
888
+ // Older cards sit behind the newest one and peek out *away* from the
889
+ // anchored edge: downward (+Y) for top anchors, upward (-Y) for bottom
890
+ // anchors. This must agree with the per-anchor flex-direction in the SCSS —
891
+ // together they keep the newest toast flush against the anchored edge.
892
+ const offsetSign = anchor.startsWith('bottom') ? 1 : -1;
893
+ const style: CSSProperties = {
894
+ transform: `translateY(${baseTranslate * offsetSign}px) scale(${baseScale})`,
895
+ opacity: baseOpacity,
896
+ zIndex: 1000 + index,
897
+ };
898
+
899
+ const dismissible = toast.dismissible ?? true;
900
+ const icon = toast.icon ?? ICON_GLYPHS[toast.severity];
901
+ const titleNode = toast.title ?? toast.message;
902
+ const label = toastText(toast);
903
+ const hasProgress = typeof toast.progress === 'number';
904
+ const progressValue = hasProgress ? clamp01(toast.progress as number) : 0;
905
+
906
+ // When the toast has plain-text content the Toaster's persistent live
907
+ // region announces it, so the card itself is just a navigable `group`.
908
+ // A JSX-only toast (no extractable text) keeps an in-place live role as
909
+ // a fallback. The two paths are mutually exclusive — never double-announced.
910
+ const announced = label !== '';
911
+
912
+ return (
913
+ <div
914
+ className={cx(
915
+ styles.toast,
916
+ styles[`sev-${toast.severity}`],
917
+ exiting && styles.exiting,
918
+ reducedMotion && styles.noMotion,
919
+ toast.className,
920
+ )}
921
+ style={style}
922
+ role={announced ? 'group' : toast.severity === 'error' ? 'alert' : 'status'}
923
+ aria-label={announced ? label : undefined}
924
+ >
925
+ <span className={styles.toastIcon} aria-hidden="true">{icon}</span>
926
+ <div className={styles.toastBody}>
927
+ {titleNode != null && (
928
+ <div className={styles.toastTitle}>
929
+ {titleNode}
930
+ {toast.count > 1 && (
931
+ <span className={styles.toastCount} aria-label={`repeated ${toast.count} times`}>
932
+ ×{toast.count}
933
+ </span>
934
+ )}
935
+ </div>
936
+ )}
937
+ {toast.description != null && (
938
+ <div className={styles.toastDescription}>{toast.description}</div>
939
+ )}
940
+ </div>
941
+ {toast.action && (
942
+ <button
943
+ type="button"
944
+ className={styles.toastAction}
945
+ onClick={(e) => {
946
+ toast.action!.onClick(e);
947
+ store.dismissToast(toast.id);
948
+ }}
949
+ >
950
+ {toast.action.label}
951
+ </button>
952
+ )}
953
+ {dismissible && (
954
+ <button
955
+ type="button"
956
+ className={styles.toastClose}
957
+ onClick={() => {
958
+ store.dismissToast(toast.id);
959
+ }}
960
+ aria-label="Dismiss notification"
961
+ >
962
+
963
+ </button>
964
+ )}
965
+ {hasProgress && (
966
+ <div
967
+ className={styles.toastProgressTrack}
968
+ role="progressbar"
969
+ aria-valuemin={0}
970
+ aria-valuemax={1}
971
+ aria-valuenow={progressValue}
972
+ >
973
+ <div
974
+ className={styles.toastProgressFill}
975
+ style={{ transform: `scaleX(${progressValue})` }}
976
+ />
977
+ </div>
978
+ )}
979
+ </div>
980
+ );
981
+ };
982
+
983
+ const ANCHORS: EvoNotificationAnchor[] = [
984
+ 'top-left', 'top-center', 'top-right',
985
+ 'bottom-left', 'bottom-center', 'bottom-right',
986
+ ];
987
+
988
+ // Identity of the Toaster currently allowed to render. Guards against two
989
+ // <EvoNotificationToaster> instances each drawing (and announcing) every
990
+ // toast — see B7.
991
+ let activeToasterOwner: symbol | null = null;
992
+
993
+ export const EvoNotificationToaster = ({ anchor, className }: EvoNotificationToasterProps) => {
994
+ const all = useToasts();
995
+ const { defaultAnchor, maxVisible, pauseOnFocusLoss } = useConfig();
996
+ const fallback = anchor ?? defaultAnchor;
997
+
998
+ const [hovered, setHovered] = useState<EvoNotificationAnchor | null>(null);
999
+ const [windowFocused, setWindowFocused] = useState(true);
1000
+ const [reducedMotion, setReducedMotion] = useState(false);
1001
+ const [mounted, setMounted] = useState(false);
1002
+ const [primary, setPrimary] = useState(false);
1003
+
1004
+ // Persistent screen-reader live region. A polite/assertive pair lives in
1005
+ // the portal at all times — injecting a fresh node that already carries
1006
+ // `aria-live` is announced unreliably across screen readers (B5).
1007
+ const [politeMsg, setPoliteMsg] = useState('');
1008
+ const [assertiveMsg, setAssertiveMsg] = useState('');
1009
+ const announcedRef = useRef<Set<string>>(new Set());
1010
+
1011
+ const ownerRef = useRef<symbol | null>(null);
1012
+ if (!ownerRef.current) ownerRef.current = Symbol('evo-toaster');
1013
+
1014
+ // Claim the single render slot; later instances render nothing.
1015
+ useEffect(() => {
1016
+ const me = ownerRef.current!;
1017
+ if (activeToasterOwner == null) {
1018
+ activeToasterOwner = me;
1019
+ setPrimary(true);
1020
+ } else if (activeToasterOwner !== me) {
1021
+ console.warn(
1022
+ '[EvoNotification] More than one <EvoNotificationToaster> is mounted; ' +
1023
+ 'only the first renders. Remove the duplicate(s).',
1024
+ );
1025
+ }
1026
+ return () => {
1027
+ if (activeToasterOwner === me) activeToasterOwner = null;
1028
+ };
1029
+ }, []);
1030
+
1031
+ useEffect(() => {
1032
+ setMounted(true);
1033
+ if (typeof window === 'undefined') return;
1034
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
1035
+ setReducedMotion(mq.matches);
1036
+ const onChange = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
1037
+ mq.addEventListener?.('change', onChange);
1038
+ return () => mq.removeEventListener?.('change', onChange);
1039
+ }, []);
1040
+
1041
+ useEffect(() => {
1042
+ if (!pauseOnFocusLoss) return;
1043
+ const onFocus = () => setWindowFocused(true);
1044
+ const onBlur = () => setWindowFocused(false);
1045
+ window.addEventListener('focus', onFocus);
1046
+ window.addEventListener('blur', onBlur);
1047
+ return () => {
1048
+ window.removeEventListener('focus', onFocus);
1049
+ window.removeEventListener('blur', onBlur);
1050
+ };
1051
+ }, [pauseOnFocusLoss]);
1052
+
1053
+ // Announce newly-arrived toasts through the persistent live region.
1054
+ // Coalesced updates reuse an already-seen id, so they don't re-announce.
1055
+ useEffect(() => {
1056
+ const seen = announcedRef.current;
1057
+ const live = new Set<string>();
1058
+ for (const t of all) {
1059
+ live.add(t.id);
1060
+ if (seen.has(t.id) || t.exiting) continue;
1061
+ seen.add(t.id);
1062
+ const text = toastText(t);
1063
+ if (!text) continue;
1064
+ if (t.severity === 'error') setAssertiveMsg(text);
1065
+ else setPoliteMsg(text);
1066
+ }
1067
+ for (const id of seen) {
1068
+ if (!live.has(id)) seen.delete(id);
1069
+ }
1070
+ }, [all]);
1071
+
1072
+ // Esc dismisses the newest live toast in the hovered/focused group.
1073
+ useEffect(() => {
1074
+ const onKey = (e: KeyboardEvent) => {
1075
+ if (e.key === 'Escape' && hovered != null) {
1076
+ const group = all.filter(
1077
+ (t) => !t.exiting && (t.anchor ?? fallback) === hovered,
1078
+ );
1079
+ if (group.length > 0) store.dismissToast(group[group.length - 1].id);
1080
+ }
1081
+ };
1082
+ window.addEventListener('keydown', onKey);
1083
+ return () => window.removeEventListener('keydown', onKey);
1084
+ }, [all, fallback, hovered]);
1085
+
1086
+ if (!mounted || typeof document === 'undefined') return null;
1087
+ if (!primary) return null;
1088
+
1089
+ // Group by effective anchor.
1090
+ const grouped: Record<EvoNotificationAnchor, InternalToast[]> = {
1091
+ 'top-left': [], 'top-center': [], 'top-right': [],
1092
+ 'bottom-left': [], 'bottom-center': [], 'bottom-right': [],
1093
+ };
1094
+ for (const t of all) {
1095
+ const a = t.anchor ?? fallback;
1096
+ grouped[a].push(t);
1097
+ }
1098
+
1099
+ return ReactDOM.createPortal(
1100
+ <div className={cx(styles.toasterRoot, className)} aria-label="Notifications">
1101
+ <div className={styles.srOnly} aria-live="polite" aria-atomic="true">
1102
+ {politeMsg}
1103
+ </div>
1104
+ <div className={styles.srOnly} aria-live="assertive" aria-atomic="true">
1105
+ {assertiveMsg}
1106
+ </div>
1107
+ {ANCHORS.map((a) => {
1108
+ const group = grouped[a];
1109
+ if (group.length === 0) return null;
1110
+ const overflow = Math.max(0, group.length - maxVisible);
1111
+ // Clamp the start index: a bare `group.length - maxVisible` goes
1112
+ // negative when fewer than `maxVisible` toasts exist, and a negative
1113
+ // slice counts from the end — dropping the oldest toasts (B1).
1114
+ const visible = group.slice(Math.max(0, group.length - maxVisible));
1115
+ const isHovered = hovered === a;
1116
+ const pausedExt = !windowFocused;
1117
+ return (
1118
+ <div
1119
+ key={a}
1120
+ className={cx(styles.anchor, styles[`anchor-${a}`])}
1121
+ onMouseEnter={() => setHovered(a)}
1122
+ onMouseLeave={() => setHovered(null)}
1123
+ onFocus={() => setHovered(a)}
1124
+ onBlur={(e) => {
1125
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setHovered(null);
1126
+ }}
1127
+ >
1128
+ {overflow > 0 && (
1129
+ <div className={styles.overflowPill} aria-hidden="true">
1130
+ +{overflow} more
1131
+ </div>
1132
+ )}
1133
+ {visible.map((t, i) => (
1134
+ <ToastRow
1135
+ key={t.id}
1136
+ toast={t}
1137
+ anchor={a}
1138
+ index={i}
1139
+ total={visible.length}
1140
+ hovered={isHovered}
1141
+ pausedExternally={pausedExt}
1142
+ reducedMotion={reducedMotion}
1143
+ />
1144
+ ))}
1145
+ </div>
1146
+ );
1147
+ })}
1148
+ </div>,
1149
+ document.body,
1150
+ );
1151
+ };
1152
+ EvoNotificationToaster.displayName = 'EvoNotificationToaster';
1153
+
1154
+ // ─── Bell ────────────────────────────────────────────────────
1155
+
1156
+ export interface EvoNotificationBellProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
1157
+ variant?: 'solid' | 'ghost';
1158
+ size?: 'sm' | 'md' | 'lg';
1159
+ hideZero?: boolean;
1160
+ maxBadgeCount?: number;
1161
+ panelPlacement?: 'bottom-end' | 'bottom-start' | 'bottom' | 'top-end' | 'top-start';
1162
+ renderPanel?: 'popover' | 'none';
1163
+ panelTitle?: ReactNode;
1164
+ panelEmptyState?: ReactNode;
1165
+ }
1166
+
1167
+ export const EvoNotificationBell = forwardRef<HTMLButtonElement, EvoNotificationBellProps>(
1168
+ function EvoNotificationBell(
1169
+ {
1170
+ variant = 'ghost',
1171
+ size = 'md',
1172
+ hideZero = true,
1173
+ maxBadgeCount = 99,
1174
+ panelPlacement = 'bottom-end',
1175
+ renderPanel = 'popover',
1176
+ panelTitle,
1177
+ panelEmptyState,
1178
+ className,
1179
+ onClick,
1180
+ ...rest
1181
+ },
1182
+ ref,
1183
+ ) {
1184
+ const { unread } = useInbox();
1185
+ const [open, setOpen] = useState(false);
1186
+ const wrapperRef = useRef<HTMLDivElement>(null);
1187
+ const panelId = useId();
1188
+
1189
+ useEffect(() => {
1190
+ if (!open || renderPanel !== 'popover') return;
1191
+ const onDocClick = (e: MouseEvent) => {
1192
+ if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
1193
+ };
1194
+ const onKey = (e: KeyboardEvent) => {
1195
+ if (e.key === 'Escape') setOpen(false);
1196
+ };
1197
+ document.addEventListener('mousedown', onDocClick);
1198
+ document.addEventListener('keydown', onKey);
1199
+ return () => {
1200
+ document.removeEventListener('mousedown', onDocClick);
1201
+ document.removeEventListener('keydown', onKey);
1202
+ };
1203
+ }, [open, renderPanel]);
1204
+
1205
+ const badgeText = unread > maxBadgeCount ? `${maxBadgeCount}+` : String(unread);
1206
+ const showBadge = unread > 0 || !hideZero;
1207
+
1208
+ return (
1209
+ <div ref={wrapperRef} className={styles.bellWrapper}>
1210
+ <button
1211
+ ref={ref}
1212
+ type="button"
1213
+ className={cx(
1214
+ styles.bell,
1215
+ styles[`bell-${variant}`],
1216
+ styles[`bell-${size}`],
1217
+ open && styles.bellOpen,
1218
+ className,
1219
+ )}
1220
+ aria-label={
1221
+ unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'
1222
+ }
1223
+ aria-haspopup={renderPanel === 'popover' ? 'dialog' : undefined}
1224
+ aria-expanded={renderPanel === 'popover' ? open : undefined}
1225
+ aria-controls={renderPanel === 'popover' ? panelId : undefined}
1226
+ onClick={(e) => {
1227
+ onClick?.(e);
1228
+ if (renderPanel === 'popover') setOpen((v) => !v);
1229
+ }}
1230
+ {...rest}
1231
+ >
1232
+ <BellGlyph />
1233
+ {showBadge && (
1234
+ <span className={cx(styles.bellBadge, unread === 0 && styles.bellBadgeZero)}>
1235
+ {badgeText}
1236
+ </span>
1237
+ )}
1238
+ </button>
1239
+ {renderPanel === 'popover' && open && (
1240
+ <div
1241
+ id={panelId}
1242
+ className={cx(styles.bellPanelHost, styles[`place-${panelPlacement}`])}
1243
+ >
1244
+ <EvoNotificationPanel
1245
+ open
1246
+ onClose={() => setOpen(false)}
1247
+ title={panelTitle}
1248
+ emptyState={panelEmptyState}
1249
+ />
1250
+ </div>
1251
+ )}
1252
+ </div>
1253
+ );
1254
+ },
1255
+ );
1256
+ EvoNotificationBell.displayName = 'EvoNotificationBell';
1257
+
1258
+ const BellGlyph = () => (
1259
+ <svg
1260
+ width="18"
1261
+ height="18"
1262
+ viewBox="0 0 24 24"
1263
+ fill="none"
1264
+ stroke="currentColor"
1265
+ strokeWidth="1.8"
1266
+ strokeLinecap="round"
1267
+ strokeLinejoin="round"
1268
+ aria-hidden="true"
1269
+ >
1270
+ <path d="M6 8a6 6 0 0 1 12 0c0 7 3 8 3 8H3s3-1 3-8" />
1271
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
1272
+ </svg>
1273
+ );
1274
+
1275
+ // ─── Panel ───────────────────────────────────────────────────
1276
+
1277
+ export interface EvoNotificationPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
1278
+ open?: boolean;
1279
+ onClose?: () => void;
1280
+ title?: ReactNode;
1281
+ emptyState?: ReactNode;
1282
+ loading?: boolean;
1283
+ error?: ReactNode;
1284
+ showMarkAllRead?: boolean;
1285
+ maxHeight?: number | string;
1286
+ }
1287
+
1288
+ export const EvoNotificationPanel = forwardRef<HTMLDivElement, EvoNotificationPanelProps>(
1289
+ function EvoNotificationPanel(
1290
+ {
1291
+ open = true,
1292
+ onClose,
1293
+ title = 'Notifications',
1294
+ emptyState,
1295
+ loading = false,
1296
+ error,
1297
+ showMarkAllRead = true,
1298
+ maxHeight = 480,
1299
+ className,
1300
+ style,
1301
+ ...rest
1302
+ },
1303
+ ref,
1304
+ ) {
1305
+ const { items, unread } = useInbox();
1306
+ if (!open) return null;
1307
+
1308
+ return (
1309
+ <div
1310
+ ref={ref}
1311
+ className={cx(styles.panel, className)}
1312
+ role="dialog"
1313
+ aria-label={typeof title === 'string' ? title : 'Notifications'}
1314
+ style={style}
1315
+ {...rest}
1316
+ >
1317
+ <header className={styles.panelHeader}>
1318
+ <div className={styles.panelTitle}>{title}</div>
1319
+ <div className={styles.panelHeaderActions}>
1320
+ {showMarkAllRead && unread > 0 && (
1321
+ <button
1322
+ type="button"
1323
+ className={styles.panelMarkAll}
1324
+ onClick={() => store.markAllRead()}
1325
+ >
1326
+ Mark all read
1327
+ </button>
1328
+ )}
1329
+ {onClose && (
1330
+ <button
1331
+ type="button"
1332
+ className={styles.panelClose}
1333
+ onClick={onClose}
1334
+ aria-label="Close notifications"
1335
+ >
1336
+
1337
+ </button>
1338
+ )}
1339
+ </div>
1340
+ </header>
1341
+ <div
1342
+ className={styles.panelBody}
1343
+ style={{ maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight }}
1344
+ >
1345
+ {loading ? (
1346
+ <div className={styles.panelState}>Loading…</div>
1347
+ ) : error ? (
1348
+ <div className={cx(styles.panelState, styles.panelStateError)}>
1349
+ {typeof error === 'string' ? error : error}
1350
+ </div>
1351
+ ) : items.length === 0 ? (
1352
+ <div className={styles.panelState}>
1353
+ {emptyState ?? <DefaultEmptyState />}
1354
+ </div>
1355
+ ) : (
1356
+ <ul className={styles.itemList}>
1357
+ {items.map((item) => (
1358
+ <li key={item.id}>
1359
+ <EvoNotificationItem item={item} />
1360
+ </li>
1361
+ ))}
1362
+ </ul>
1363
+ )}
1364
+ </div>
1365
+ </div>
1366
+ );
1367
+ },
1368
+ );
1369
+ EvoNotificationPanel.displayName = 'EvoNotificationPanel';
1370
+
1371
+ const DefaultEmptyState = () => (
1372
+ <div className={styles.emptyState}>
1373
+ <div className={styles.emptyIcon} aria-hidden="true">
1374
+ <BellGlyph />
1375
+ </div>
1376
+ <div className={styles.emptyTitle}>You're all caught up</div>
1377
+ <div className={styles.emptyHint}>New notifications will appear here.</div>
1378
+ </div>
1379
+ );
1380
+
1381
+ // ─── Item ────────────────────────────────────────────────────
1382
+
1383
+ export interface EvoNotificationItemProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onClick'> {
1384
+ item: EvoInboxItem;
1385
+ onClick?: (item: EvoInboxItem) => void;
1386
+ }
1387
+
1388
+ export const EvoNotificationItem = forwardRef<HTMLDivElement, EvoNotificationItemProps>(
1389
+ function EvoNotificationItem({ item, onClick, className, ...rest }, ref) {
1390
+ // Seed with the item's own timestamp (deterministic) so server and
1391
+ // client render identical relative text; switch to the real clock once
1392
+ // mounted. Seeding with `Date.now()` instead causes a hydration mismatch.
1393
+ const [now, setNow] = useState(item.timestamp);
1394
+
1395
+ useEffect(() => {
1396
+ setNow(Date.now());
1397
+ const id = window.setInterval(() => setNow(Date.now()), 60_000);
1398
+ return () => window.clearInterval(id);
1399
+ }, []);
1400
+
1401
+ const handleClick = useCallback(() => {
1402
+ (onClick ?? item.onClick)?.(item);
1403
+ if (!item.read) store.markRead(item.id);
1404
+ }, [item, onClick]);
1405
+
1406
+ const handleKey = useCallback(
1407
+ (e: React.KeyboardEvent) => {
1408
+ if (e.key === 'Enter' || e.key === ' ') {
1409
+ e.preventDefault();
1410
+ handleClick();
1411
+ }
1412
+ },
1413
+ [handleClick],
1414
+ );
1415
+
1416
+ const interactive = Boolean(onClick ?? item.onClick);
1417
+ const icon = item.icon ?? ICON_GLYPHS[item.severity];
1418
+
1419
+ return (
1420
+ <div
1421
+ ref={ref}
1422
+ className={cx(
1423
+ styles.item,
1424
+ !item.read && styles.itemUnread,
1425
+ interactive && styles.itemInteractive,
1426
+ className,
1427
+ )}
1428
+ role={interactive ? 'button' : 'group'}
1429
+ tabIndex={interactive ? 0 : undefined}
1430
+ onClick={interactive ? handleClick : undefined}
1431
+ onKeyDown={interactive ? handleKey : undefined}
1432
+ {...rest}
1433
+ >
1434
+ <span className={styles.itemUnreadDot} aria-hidden={item.read} />
1435
+ <div className={cx(styles.itemMedia, styles[`sev-${item.severity}`])} aria-hidden="true">
1436
+ {item.avatarUrl ? (
1437
+ <img src={item.avatarUrl} alt="" className={styles.itemAvatar} />
1438
+ ) : (
1439
+ <span className={styles.itemMediaGlyph}>{icon}</span>
1440
+ )}
1441
+ </div>
1442
+ <div className={styles.itemBody}>
1443
+ <div className={styles.itemTitle}>{item.title}</div>
1444
+ {item.description != null && (
1445
+ <div className={styles.itemDescription}>{item.description}</div>
1446
+ )}
1447
+ <div className={styles.itemMeta}>
1448
+ <span className={styles.itemTimestamp}>{formatRelative(item.timestamp, now)}</span>
1449
+ {item.action && (
1450
+ <button
1451
+ type="button"
1452
+ className={styles.itemAction}
1453
+ onClick={(e) => {
1454
+ e.stopPropagation();
1455
+ item.action!.onClick(e);
1456
+ }}
1457
+ >
1458
+ {item.action.label}
1459
+ </button>
1460
+ )}
1461
+ </div>
1462
+ </div>
1463
+ <button
1464
+ type="button"
1465
+ className={styles.itemDismiss}
1466
+ onClick={(e) => {
1467
+ e.stopPropagation();
1468
+ store.removeInbox(item.id);
1469
+ }}
1470
+ aria-label="Remove notification"
1471
+ >
1472
+
1473
+ </button>
1474
+ </div>
1475
+ );
1476
+ },
1477
+ );
1478
+ EvoNotificationItem.displayName = 'EvoNotificationItem';
1479
+
1480
+ // ─── Hook (optional convenience) ─────────────────────────────
1481
+
1482
+ export function useEvoInbox() {
1483
+ return useInbox();
1484
+ }
1485
+
1486
+ // ─── Namespace export ────────────────────────────────────────
1487
+
1488
+ type EvoNotificationNS = {
1489
+ Provider: typeof EvoNotificationProvider;
1490
+ Toaster: typeof EvoNotificationToaster;
1491
+ Bell: typeof EvoNotificationBell;
1492
+ Panel: typeof EvoNotificationPanel;
1493
+ Item: typeof EvoNotificationItem;
1494
+ };
1495
+
1496
+ export const EvoNotification: EvoNotificationNS = {
1497
+ Provider: EvoNotificationProvider,
1498
+ Toaster: EvoNotificationToaster,
1499
+ Bell: EvoNotificationBell,
1500
+ Panel: EvoNotificationPanel,
1501
+ Item: EvoNotificationItem,
1502
+ };
1503
+