@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.
- package/README.md +3 -3
- package/dist/TopNav/TopNav.d.ts +19 -0
- package/dist/declarations.d.ts +6 -6
- package/dist/evo-ui.css +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +3301 -3197
- package/package.json +52 -52
- package/src/Alert/Alert.tsx +49 -49
- package/src/AutoComplete/AutoComplete.tsx +810 -810
- package/src/Badge/Badge.tsx +53 -53
- package/src/Breadcrumb/Breadcrumb.tsx +53 -53
- package/src/Button/Button.tsx +125 -125
- package/src/Card/Card.tsx +257 -257
- package/src/Checkbox/Checkbox.tsx +59 -59
- package/src/CommandPalette/CommandPalette.tsx +185 -185
- package/src/Container/Container.tsx +31 -31
- package/src/Divider/Divider.tsx +31 -31
- package/src/Form/Form.tsx +185 -185
- package/src/Grid/Grid.tsx +66 -66
- package/src/ImageCropper/ImageCropper.tsx +911 -911
- package/src/Input/Input.tsx +74 -74
- package/src/Modal/Modal.tsx +77 -77
- package/src/Nav/Nav.tsx +708 -708
- package/src/Notification/Notification.tsx +1503 -1503
- package/src/Pagination/Pagination.tsx +76 -76
- package/src/Radio/Radio.tsx +69 -69
- package/src/RichTextArea/RichTextArea.tsx +886 -869
- package/src/Select/Select.tsx +515 -515
- package/src/Skeleton/Skeleton.tsx +70 -70
- package/src/Stack/Stack.tsx +52 -52
- package/src/Table/Table.tsx +335 -335
- package/src/Tabs/Tabs.tsx +90 -90
- package/src/Theme/ThemeProvider.tsx +253 -253
- package/src/Theme/ThemeToggle.tsx +79 -79
- package/src/Toggle/Toggle.tsx +48 -48
- package/src/Tooltip/Tooltip.tsx +38 -38
- package/src/TopNav/TopNav.tsx +1163 -994
- package/src/TreeSelect/TreeSelect.tsx +825 -825
- package/src/css/alert.module.scss +93 -93
- package/src/css/autocomplete.module.scss +416 -416
- package/src/css/badge.module.scss +82 -82
- package/src/css/base/_color.scss +159 -159
- package/src/css/base/_theme.scss +237 -237
- package/src/css/base/_variables.scss +161 -161
- package/src/css/breadcrumb.module.scss +50 -50
- package/src/css/button.module.scss +385 -385
- package/src/css/card.module.scss +217 -217
- package/src/css/checkbox.module.scss +123 -120
- package/src/css/commandpalette.module.scss +211 -211
- package/src/css/container.module.scss +18 -18
- package/src/css/divider.module.scss +41 -41
- package/src/css/form.module.scss +245 -245
- package/src/css/imagecropper.module.scss +397 -397
- package/src/css/input.module.scss +89 -89
- package/src/css/modal.module.scss +105 -105
- package/src/css/nav.module.scss +494 -494
- package/src/css/notification.module.scss +691 -691
- package/src/css/pagination.module.scss +63 -63
- package/src/css/radio.module.scss +89 -89
- package/src/css/richtextarea.module.scss +307 -307
- package/src/css/select.module.scss +525 -525
- package/src/css/skeleton.module.scss +30 -30
- package/src/css/table.module.scss +386 -386
- package/src/css/tabs.module.scss +63 -63
- package/src/css/theme-toggle.module.scss +83 -83
- package/src/css/toggle.module.scss +54 -54
- package/src/css/tooltip.module.scss +97 -97
- package/src/css/topnav.module.scss +568 -396
- package/src/css/treeselect.module.scss +558 -558
- package/src/css/utilities/_borders.scss +111 -111
- package/src/css/utilities/_colors.scss +66 -66
- package/src/css/utilities/_effects.scss +216 -216
- package/src/css/utilities/_layout.scss +181 -181
- package/src/css/utilities/_position.scss +75 -75
- package/src/css/utilities/_sizing.scss +138 -138
- package/src/css/utilities/_spacing.scss +99 -99
- package/src/css/utilities/_typography.scss +121 -121
- package/src/css/utilities/index.scss +24 -24
- package/src/declarations.d.ts +6 -6
- 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
|
+
|