-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathNotificationContext.tsx
More file actions
326 lines (292 loc) · 9.29 KB
/
Copy pathNotificationContext.tsx
File metadata and controls
326 lines (292 loc) · 9.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @object-ui/react - Notification Context
*
* Provides a spec-driven notification system to the component tree.
* Implements NotificationSchema, NotificationConfigSchema, and
* NotificationActionSchema from @objectstack/spec v2.0.7.
*/
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
/** Notification severity levels aligned with NotificationSeveritySchema */
export type NotificationSeverityLevel = 'info' | 'success' | 'warning' | 'error';
/**
* Notification display type — the spec `NotificationTypeSchema`
* (`ui/notification.zod.ts`: toast / snackbar / banner / alert / inline).
* This union used to claim alignment while carrying a renderer-local `modal`
* and missing `alert` / `inline` (#2942); `modal` stays accepted as a
* deprecated legacy spelling for stored items.
*/
export type NotificationDisplayType =
| 'toast'
| 'snackbar'
| 'banner'
| 'alert'
| 'inline'
/** @deprecated renderer dialect — never in the spec; presented as `alert` */
| 'modal';
/**
* Notification position — the spec `NotificationPositionSchema`
* (underscore spellings). The hyphen forms are this context's historical
* dialect, kept accepted for stored items.
*/
export type NotificationPositionValue =
| 'top_left'
| 'top_center'
| 'top_right'
| 'bottom_left'
| 'bottom_center'
| 'bottom_right'
/** @deprecated legacy spelling — use `top_left` */
| 'top-left'
/** @deprecated legacy spelling — use `top_center` */
| 'top-center'
/** @deprecated legacy spelling — use `top_right` */
| 'top-right'
/** @deprecated legacy spelling — use `bottom_left` */
| 'bottom-left'
/** @deprecated legacy spelling — use `bottom_center` */
| 'bottom-center'
/** @deprecated legacy spelling — use `bottom_right` */
| 'bottom-right';
/**
* The spec vocabularies this context implements — exported for the parity
* tests (#2942), which fail the moment `NotificationTypeSchema` /
* `NotificationPositionSchema` and these sets drift in either direction.
*/
export const SUPPORTED_NOTIFICATION_DISPLAY_TYPES: ReadonlySet<string> = new Set([
'toast', 'snackbar', 'banner', 'alert', 'inline',
]);
export const SUPPORTED_NOTIFICATION_POSITIONS: ReadonlySet<string> = new Set([
'top_left', 'top_center', 'top_right', 'bottom_left', 'bottom_center', 'bottom_right',
]);
/** Action button on a notification */
export interface NotificationActionButton {
label: string;
onClick: () => void;
variant?: 'default' | 'destructive' | 'outline';
}
/** A single notification item */
export interface NotificationItem {
id: string;
title: string;
message?: string;
severity: NotificationSeverityLevel;
displayType?: NotificationDisplayType;
actions?: NotificationActionButton[];
/** Duration in ms (0 = persistent). Default: 5000 */
duration?: number;
/** Whether the notification has been read */
read?: boolean;
/** Timestamp */
createdAt: Date;
/** Optional icon name (lucide icon) */
icon?: string;
}
/** Configuration for the notification system */
export interface NotificationSystemConfig {
/** Default position for toast notifications */
position?: NotificationPositionValue;
/** Default duration in ms */
defaultDuration?: number;
/** Maximum number of visible notifications */
maxVisible?: number;
/** Whether to stack notifications */
stacking?: boolean;
}
export interface NotificationProviderProps {
children: React.ReactNode;
/** System configuration */
config?: NotificationSystemConfig;
/** External toast handler (e.g., Sonner) for rendering toasts */
onToast?: (notification: NotificationItem) => void;
}
interface NotificationContextValue {
/** All notifications (including history) */
notifications: NotificationItem[];
/** Unread notification count */
unreadCount: number;
/** Add a notification */
notify: (notification: Omit<NotificationItem, 'id' | 'createdAt' | 'read'>) => string;
/** Convenience: show info notification */
info: (title: string, message?: string) => string;
/** Convenience: show success notification */
success: (title: string, message?: string) => string;
/** Convenience: show warning notification */
warning: (title: string, message?: string) => string;
/** Convenience: show error notification */
error: (title: string, message?: string) => string;
/** Mark a notification as read */
markAsRead: (id: string) => void;
/** Mark all notifications as read */
markAllAsRead: () => void;
/** Dismiss a notification */
dismiss: (id: string) => void;
/** Clear all notifications */
clearAll: () => void;
/** System configuration */
config: NotificationSystemConfig;
}
let notificationCounter = 0;
const NotificationCtx = createContext<NotificationContextValue | null>(null);
/**
* NotificationProvider — Provides a spec-driven notification system.
*
* @example
* ```tsx
* <NotificationProvider
* config={{ position: 'top-right', defaultDuration: 5000, maxVisible: 5 }}
* onToast={(n) => toast[n.severity](n.title, { description: n.message })}
* >
* <App />
* </NotificationProvider>
* ```
*/
export const NotificationProvider: React.FC<NotificationProviderProps> = ({
children,
config: userConfig = {},
onToast,
}) => {
const config = useMemo<NotificationSystemConfig>(
() => ({
position: 'top-right',
defaultDuration: 5000,
maxVisible: 5,
stacking: true,
...userConfig,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(userConfig)],
);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const notify = useCallback(
(input: Omit<NotificationItem, 'id' | 'createdAt' | 'read'>): string => {
const id = `notification-${++notificationCounter}`;
const notification: NotificationItem = {
...input,
// Materialize the declared presentation (spec default: toast; the
// legacy `modal` dialect presents as its nearest spec family, alert)
// so the delegate can branch on it — it used to be stored and never
// read anywhere (#2942).
displayType: input.displayType === 'modal' ? 'alert' : (input.displayType ?? 'toast'),
id,
createdAt: new Date(),
read: false,
};
setNotifications((prev) => [notification, ...prev]);
// Delegate to external toast handler if provided
if (onToast) {
onToast(notification);
}
// Auto-dismiss non-persistent notifications
const duration = input.duration ?? config.defaultDuration ?? 5000;
if (duration > 0) {
setTimeout(() => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, duration);
}
return id;
},
[config.defaultDuration, onToast],
);
const info = useCallback(
(title: string, message?: string) =>
notify({ title, message, severity: 'info' }),
[notify],
);
const success = useCallback(
(title: string, message?: string) =>
notify({ title, message, severity: 'success' }),
[notify],
);
const warning = useCallback(
(title: string, message?: string) =>
notify({ title, message, severity: 'warning' }),
[notify],
);
const error = useCallback(
(title: string, message?: string) =>
notify({ title, message, severity: 'error' }),
[notify],
);
const markAsRead = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
);
}, []);
const markAllAsRead = useCallback(() => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}, []);
const dismiss = useCallback((id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
const clearAll = useCallback(() => {
setNotifications([]);
}, []);
const unreadCount = useMemo(
() => notifications.filter((n) => !n.read).length,
[notifications],
);
const value = useMemo<NotificationContextValue>(
() => ({
notifications,
unreadCount,
notify,
info,
success,
warning,
error,
markAsRead,
markAllAsRead,
dismiss,
clearAll,
config,
}),
[
notifications,
unreadCount,
notify,
info,
success,
warning,
error,
markAsRead,
markAllAsRead,
dismiss,
clearAll,
config,
],
);
return (
<NotificationCtx.Provider value={value}>
{children}
</NotificationCtx.Provider>
);
};
NotificationProvider.displayName = 'NotificationProvider';
/**
* Hook to consume the NotificationProvider context.
*
* @throws Error if used outside a NotificationProvider
*/
export function useNotifications(): NotificationContextValue {
const ctx = useContext(NotificationCtx);
if (!ctx) {
throw new Error(
'useNotifications must be used within a <NotificationProvider>. ' +
'Wrap your app with <NotificationProvider> to use the notification system.',
);
}
return ctx;
}
/**
* Hook to check if a NotificationProvider is available.
*/
export function useHasNotificationProvider(): boolean {
return useContext(NotificationCtx) !== null;
}