toastflow-core 0.0.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/package.json +19 -0
- package/src/index.ts +2 -0
- package/src/store.ts +694 -0
- package/src/types.ts +315 -0
- package/tsconfig.json +6 -0
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "toastflow-core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"module": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
9
|
+
"test": "vitest"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"tsup": "^8.5.1",
|
|
13
|
+
"typescript": "^5.9.3",
|
|
14
|
+
"vitest": "^4.0.13"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"uuid": "^13.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/index.ts
ADDED
package/src/store.ts
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ToastConfig,
|
|
3
|
+
ToastContext,
|
|
4
|
+
ToastEvent,
|
|
5
|
+
ToastId,
|
|
6
|
+
ToastInstance,
|
|
7
|
+
ToastLoadingConfig,
|
|
8
|
+
ToastLoadingInput,
|
|
9
|
+
ToastLoadingResult,
|
|
10
|
+
ToastOptions,
|
|
11
|
+
ToastOrder,
|
|
12
|
+
ToastShowInput,
|
|
13
|
+
ToastState,
|
|
14
|
+
ToastStore,
|
|
15
|
+
ToastType,
|
|
16
|
+
ToastUpdateInput,
|
|
17
|
+
} from "./types";
|
|
18
|
+
import { v4 as uuidv4 } from "uuid";
|
|
19
|
+
|
|
20
|
+
type Listener = (state: ToastState) => void;
|
|
21
|
+
type EventListener = (event: ToastEvent) => void;
|
|
22
|
+
type TimeoutHandle = ReturnType<typeof setTimeout>;
|
|
23
|
+
|
|
24
|
+
interface TimerState {
|
|
25
|
+
timeout: TimeoutHandle | null;
|
|
26
|
+
startTime: number;
|
|
27
|
+
remaining: number;
|
|
28
|
+
paused: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const defaultCreatedAtFormatter = function (createdAt: number): string {
|
|
32
|
+
try {
|
|
33
|
+
return new Date(createdAt).toLocaleTimeString([], {
|
|
34
|
+
hour: "2-digit",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return new Date(createdAt).toISOString();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const defaults: ToastConfig = {
|
|
43
|
+
offset: "16px",
|
|
44
|
+
gap: "8px",
|
|
45
|
+
zIndex: 9999,
|
|
46
|
+
width: "350px",
|
|
47
|
+
duration: 5000,
|
|
48
|
+
maxVisible: 5,
|
|
49
|
+
position: "top-right",
|
|
50
|
+
preventDuplicates: false,
|
|
51
|
+
order: "newest",
|
|
52
|
+
progressBar: true,
|
|
53
|
+
pauseOnHover: true,
|
|
54
|
+
pauseStrategy: "resume",
|
|
55
|
+
animation: {
|
|
56
|
+
name: "Toastflow__animation",
|
|
57
|
+
bump: "Toastflow__animation-bump",
|
|
58
|
+
clearAll: "Toastflow__animation-clearAll",
|
|
59
|
+
update: "Toastflow__animation-update",
|
|
60
|
+
},
|
|
61
|
+
closeButton: true,
|
|
62
|
+
closeOnClick: false,
|
|
63
|
+
supportHtml: false,
|
|
64
|
+
showCreatedAt: false,
|
|
65
|
+
createdAtFormatter: defaultCreatedAtFormatter,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const HIDE_ANIMATION_DURATION = 50;
|
|
69
|
+
|
|
70
|
+
export function createToastStore(
|
|
71
|
+
globalConfig: Partial<ToastConfig> = {},
|
|
72
|
+
): ToastStore {
|
|
73
|
+
let state: ToastState = { toasts: [] };
|
|
74
|
+
const listeners = new Set<Listener>();
|
|
75
|
+
const eventListeners = new Set<EventListener>();
|
|
76
|
+
const timers = new Map<ToastId, TimerState>();
|
|
77
|
+
const promiseRuns = new Map<ToastId, symbol>();
|
|
78
|
+
|
|
79
|
+
const resolvedGlobalConfig: ToastConfig = getConfig();
|
|
80
|
+
|
|
81
|
+
function notify() {
|
|
82
|
+
for (const listener of listeners) {
|
|
83
|
+
listener(state);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function emitEvent(event: ToastEvent) {
|
|
88
|
+
for (const listener of eventListeners) {
|
|
89
|
+
listener(event);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getState(): ToastState {
|
|
94
|
+
return state;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function subscribe(listener: Listener): () => void {
|
|
98
|
+
listeners.add(listener);
|
|
99
|
+
listener(state);
|
|
100
|
+
return () => {
|
|
101
|
+
listeners.delete(listener);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function subscribeEvents(listener: EventListener): () => void {
|
|
106
|
+
eventListeners.add(listener);
|
|
107
|
+
return () => {
|
|
108
|
+
eventListeners.delete(listener);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function show(options: ToastShowInput): ToastId {
|
|
113
|
+
assertShowInput(options, "show");
|
|
114
|
+
|
|
115
|
+
const toast = resolveConfig(resolvedGlobalConfig, options);
|
|
116
|
+
|
|
117
|
+
if (toast.preventDuplicates) {
|
|
118
|
+
const duplicate = state.toasts.find(function (t) {
|
|
119
|
+
return (
|
|
120
|
+
t.position === toast.position &&
|
|
121
|
+
t.type === toast.type &&
|
|
122
|
+
t.title === toast.title &&
|
|
123
|
+
t.description === toast.description &&
|
|
124
|
+
t.phase !== "leaving" &&
|
|
125
|
+
t.phase !== "clear-all"
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (duplicate) {
|
|
130
|
+
const updated: ToastInstance = {
|
|
131
|
+
...duplicate,
|
|
132
|
+
...toast,
|
|
133
|
+
id: duplicate.id,
|
|
134
|
+
createdAt: duplicate.createdAt,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
clearAutoDismiss(duplicate.id);
|
|
138
|
+
scheduleAutoDismiss(updated);
|
|
139
|
+
|
|
140
|
+
state = {
|
|
141
|
+
toasts: state.toasts.map(function (t) {
|
|
142
|
+
return t.id === updated.id ? updated : t;
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
emitEvent({ id: updated.id, kind: "duplicate" });
|
|
147
|
+
notify();
|
|
148
|
+
|
|
149
|
+
return duplicate.id;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const id = uuidv4();
|
|
154
|
+
const createdAt = Date.now();
|
|
155
|
+
|
|
156
|
+
const toastInstance: ToastInstance = {
|
|
157
|
+
...toast,
|
|
158
|
+
id,
|
|
159
|
+
createdAt,
|
|
160
|
+
phase: "enter",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const samePos = state.toasts.filter(function (t) {
|
|
164
|
+
return (
|
|
165
|
+
t.position === toastInstance.position &&
|
|
166
|
+
t.phase !== "leaving" &&
|
|
167
|
+
t.phase !== "clear-all"
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
toastInstance.maxVisible > 0 &&
|
|
173
|
+
samePos.length >= toastInstance.maxVisible
|
|
174
|
+
) {
|
|
175
|
+
const toEvict = pickOverflowToast(samePos, toastInstance.order);
|
|
176
|
+
if (toEvict) {
|
|
177
|
+
dismiss(toEvict.id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
state = {
|
|
182
|
+
toasts: insertToast(state.toasts, toastInstance),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (toastInstance.onMount) {
|
|
186
|
+
toastInstance.onMount({
|
|
187
|
+
id,
|
|
188
|
+
position: toastInstance.position,
|
|
189
|
+
type: toastInstance.type,
|
|
190
|
+
title: toastInstance.title,
|
|
191
|
+
description: toastInstance.description,
|
|
192
|
+
createdAt: toastInstance.createdAt,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
scheduleAutoDismiss(toastInstance);
|
|
197
|
+
notify();
|
|
198
|
+
|
|
199
|
+
return id;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function loading<T>(
|
|
203
|
+
input: ToastLoadingInput<T>,
|
|
204
|
+
config: ToastLoadingConfig<T>,
|
|
205
|
+
): ToastLoadingResult<T> {
|
|
206
|
+
const loadingOptions: ToastShowInput = {
|
|
207
|
+
...config.loading,
|
|
208
|
+
type: "loading",
|
|
209
|
+
duration: Infinity,
|
|
210
|
+
progressBar: false,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
assertShowInput(loadingOptions, "loading.loading");
|
|
214
|
+
|
|
215
|
+
const runToken = Symbol("toastflow-loading-run");
|
|
216
|
+
const toastId = show(loadingOptions);
|
|
217
|
+
promiseRuns.set(toastId, runToken);
|
|
218
|
+
|
|
219
|
+
function successOptions(value: T): ToastShowInput {
|
|
220
|
+
const resolved =
|
|
221
|
+
typeof config.success === "function"
|
|
222
|
+
? config.success(value)
|
|
223
|
+
: config.success;
|
|
224
|
+
|
|
225
|
+
assertContentFields(resolved, "loading.success");
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
...resolvedGlobalConfig,
|
|
229
|
+
...resolved,
|
|
230
|
+
type: "success",
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function errorOptions(error: unknown): ToastShowInput {
|
|
235
|
+
const resolved =
|
|
236
|
+
typeof config.error === "function" ? config.error(error) : config.error;
|
|
237
|
+
|
|
238
|
+
assertContentFields(resolved, "loading.error");
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
...resolvedGlobalConfig,
|
|
242
|
+
...resolved,
|
|
243
|
+
type: "error",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function applyIfActive(options: ToastShowInput) {
|
|
248
|
+
if (promiseRuns.get(toastId) !== runToken) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
promiseRuns.delete(toastId);
|
|
252
|
+
update(toastId, options);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function handleSuccess(value: T): T {
|
|
256
|
+
applyIfActive(successOptions(value));
|
|
257
|
+
return value;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function handleError(error: unknown): never {
|
|
261
|
+
applyIfActive(errorOptions(error));
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let task: Promise<T>;
|
|
266
|
+
try {
|
|
267
|
+
task = typeof input === "function" ? input() : input;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
applyIfActive(errorOptions(error));
|
|
270
|
+
const rejected = Promise.reject(error) as ToastLoadingResult<T>;
|
|
271
|
+
rejected.toastId = toastId;
|
|
272
|
+
return rejected;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const result = task.then(
|
|
276
|
+
handleSuccess,
|
|
277
|
+
handleError,
|
|
278
|
+
) as ToastLoadingResult<T>;
|
|
279
|
+
result.toastId = toastId;
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function update(id: ToastId, options: ToastUpdateInput): void {
|
|
284
|
+
const existing = state.toasts.find((t) => t.id === id);
|
|
285
|
+
if (!existing) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
assertUpdateInput(options);
|
|
290
|
+
|
|
291
|
+
if (options.type) {
|
|
292
|
+
assertToastType(options.type, "update");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const merged: ToastOptions = {
|
|
296
|
+
...existing,
|
|
297
|
+
...options,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const resolved = resolveConfig(resolvedGlobalConfig, merged);
|
|
301
|
+
|
|
302
|
+
const updated: ToastInstance = {
|
|
303
|
+
...existing,
|
|
304
|
+
...resolved,
|
|
305
|
+
id: existing.id,
|
|
306
|
+
createdAt: existing.createdAt,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
clearAutoDismiss(id);
|
|
310
|
+
scheduleAutoDismiss(updated);
|
|
311
|
+
|
|
312
|
+
state = {
|
|
313
|
+
toasts: state.toasts.map((t) => (t.id === id ? updated : t)),
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
emitEvent({ id, kind: "timer-reset" });
|
|
317
|
+
emitEvent({ id, kind: "update" });
|
|
318
|
+
notify();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function dismiss(id: ToastId): void {
|
|
322
|
+
const toast = state.toasts.find((t) => t.id === id);
|
|
323
|
+
if (!toast) {
|
|
324
|
+
clearAutoDismiss(id);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
clearAutoDismiss(id);
|
|
329
|
+
promiseRuns.delete(id);
|
|
330
|
+
|
|
331
|
+
const context: ToastContext = {
|
|
332
|
+
id,
|
|
333
|
+
position: toast.position,
|
|
334
|
+
type: toast.type,
|
|
335
|
+
title: toast.title,
|
|
336
|
+
description: toast.description,
|
|
337
|
+
createdAt: toast.createdAt,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
if (toast.onClose) {
|
|
341
|
+
toast.onClose(context);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
state = {
|
|
345
|
+
toasts: state.toasts.map(function (t) {
|
|
346
|
+
if (t.id !== id) {
|
|
347
|
+
return t;
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
...t,
|
|
351
|
+
phase: "leaving",
|
|
352
|
+
};
|
|
353
|
+
}),
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
notify();
|
|
357
|
+
|
|
358
|
+
setTimeout(function () {
|
|
359
|
+
const still = state.toasts.find((t) => t.id === id);
|
|
360
|
+
if (!still) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
state = {
|
|
365
|
+
toasts: state.toasts.filter((t) => t.id !== id),
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
if (toast.onUnmount) {
|
|
369
|
+
toast.onUnmount(context);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
notify();
|
|
373
|
+
}, HIDE_ANIMATION_DURATION);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function dismissAll(): void {
|
|
377
|
+
if (!state.toasts.length) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const current = state.toasts;
|
|
382
|
+
|
|
383
|
+
for (const toast of current) {
|
|
384
|
+
clearAutoDismiss(toast.id);
|
|
385
|
+
promiseRuns.delete(toast.id);
|
|
386
|
+
|
|
387
|
+
const context: ToastContext = {
|
|
388
|
+
id: toast.id,
|
|
389
|
+
position: toast.position,
|
|
390
|
+
type: toast.type,
|
|
391
|
+
title: toast.title,
|
|
392
|
+
description: toast.description,
|
|
393
|
+
createdAt: toast.createdAt,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
if (toast.onClose) {
|
|
397
|
+
toast.onClose(context);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
state = {
|
|
402
|
+
toasts: state.toasts.map(function (t) {
|
|
403
|
+
return {
|
|
404
|
+
...t,
|
|
405
|
+
phase: "clear-all",
|
|
406
|
+
};
|
|
407
|
+
}),
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
notify();
|
|
411
|
+
|
|
412
|
+
setTimeout(function () {
|
|
413
|
+
for (const toast of current) {
|
|
414
|
+
const context: ToastContext = {
|
|
415
|
+
id: toast.id,
|
|
416
|
+
position: toast.position,
|
|
417
|
+
type: toast.type,
|
|
418
|
+
title: toast.title,
|
|
419
|
+
description: toast.description,
|
|
420
|
+
createdAt: toast.createdAt,
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
if (toast.onUnmount) {
|
|
424
|
+
toast.onUnmount(context);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
state = { toasts: [] };
|
|
429
|
+
notify();
|
|
430
|
+
}, HIDE_ANIMATION_DURATION);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function scheduleAutoDismiss(toastInstance: ToastInstance) {
|
|
434
|
+
if (
|
|
435
|
+
!Number.isFinite(toastInstance.duration) ||
|
|
436
|
+
toastInstance.duration <= 0
|
|
437
|
+
) {
|
|
438
|
+
timers.delete(toastInstance.id);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
const duration = toastInstance.duration;
|
|
444
|
+
|
|
445
|
+
const handle: TimeoutHandle = setTimeout(() => {
|
|
446
|
+
dismiss(toastInstance.id);
|
|
447
|
+
}, duration);
|
|
448
|
+
|
|
449
|
+
timers.set(toastInstance.id, {
|
|
450
|
+
timeout: handle,
|
|
451
|
+
startTime: now,
|
|
452
|
+
remaining: duration,
|
|
453
|
+
paused: false,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function clearAutoDismiss(id: ToastId) {
|
|
458
|
+
const timer = timers.get(id);
|
|
459
|
+
if (timer && timer.timeout) {
|
|
460
|
+
clearTimeout(timer.timeout);
|
|
461
|
+
}
|
|
462
|
+
timers.delete(id);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function pause(id: ToastId): void {
|
|
466
|
+
const timer = timers.get(id);
|
|
467
|
+
if (!timer || timer.paused) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const now = Date.now();
|
|
472
|
+
const elapsed = now - timer.startTime;
|
|
473
|
+
const remaining = Math.max(timer.remaining - elapsed, 0);
|
|
474
|
+
|
|
475
|
+
if (timer.timeout) {
|
|
476
|
+
clearTimeout(timer.timeout);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
timers.set(id, {
|
|
480
|
+
timeout: null,
|
|
481
|
+
startTime: now,
|
|
482
|
+
remaining,
|
|
483
|
+
paused: true,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function resume(id: ToastId): void {
|
|
488
|
+
const timer = timers.get(id);
|
|
489
|
+
const toastInstance = state.toasts.find((t) => t.id === id);
|
|
490
|
+
|
|
491
|
+
if (!toastInstance) {
|
|
492
|
+
timers.delete(id);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (
|
|
497
|
+
toastInstance.duration === 0 ||
|
|
498
|
+
!Number.isFinite(toastInstance.duration)
|
|
499
|
+
) {
|
|
500
|
+
timers.delete(id);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const strategy = toastInstance.pauseStrategy;
|
|
505
|
+
|
|
506
|
+
let remaining: number;
|
|
507
|
+
|
|
508
|
+
if (!timer || !timer.paused) {
|
|
509
|
+
remaining = toastInstance.duration;
|
|
510
|
+
if (strategy === "reset") {
|
|
511
|
+
emitEvent({ id, kind: "timer-reset" });
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
if (strategy === "reset") {
|
|
515
|
+
remaining = toastInstance.duration;
|
|
516
|
+
emitEvent({ id, kind: "timer-reset" });
|
|
517
|
+
} else {
|
|
518
|
+
remaining = timer.remaining;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (remaining <= 0) {
|
|
523
|
+
dismiss(id);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const now = Date.now();
|
|
528
|
+
const handle: TimeoutHandle = setTimeout(() => {
|
|
529
|
+
dismiss(id);
|
|
530
|
+
}, remaining);
|
|
531
|
+
|
|
532
|
+
timers.set(id, {
|
|
533
|
+
timeout: handle,
|
|
534
|
+
startTime: now,
|
|
535
|
+
remaining,
|
|
536
|
+
paused: false,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function getConfig(): ToastConfig {
|
|
541
|
+
return {
|
|
542
|
+
...defaults,
|
|
543
|
+
...globalConfig,
|
|
544
|
+
animation: {
|
|
545
|
+
...defaults.animation,
|
|
546
|
+
...(globalConfig.animation ?? {}),
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
getState,
|
|
553
|
+
subscribe,
|
|
554
|
+
subscribeEvents,
|
|
555
|
+
show,
|
|
556
|
+
loading,
|
|
557
|
+
update,
|
|
558
|
+
dismiss,
|
|
559
|
+
dismissAll,
|
|
560
|
+
pause,
|
|
561
|
+
resume,
|
|
562
|
+
getConfig,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ------------- helpers -------------
|
|
567
|
+
|
|
568
|
+
const VALID_TYPES = new Set<ToastType>([
|
|
569
|
+
"loading",
|
|
570
|
+
"default",
|
|
571
|
+
"success",
|
|
572
|
+
"error",
|
|
573
|
+
"info",
|
|
574
|
+
"warning",
|
|
575
|
+
]);
|
|
576
|
+
|
|
577
|
+
function assertToastType(type: ToastType, caller: string) {
|
|
578
|
+
if (!VALID_TYPES.has(type)) {
|
|
579
|
+
throw new Error(`[toastflow-core] ${caller} requires a valid toast type.`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function assertContentFields(
|
|
584
|
+
options: { title?: unknown; description?: unknown },
|
|
585
|
+
caller: string,
|
|
586
|
+
): asserts options is { title?: string; description?: string } {
|
|
587
|
+
const hasTitle = isNonEmptyString(options.title);
|
|
588
|
+
const hasDescription = isNonEmptyString(options.description);
|
|
589
|
+
|
|
590
|
+
if (!hasTitle && !hasDescription) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`[toastflow-core] ${caller} requires a non-empty title or description.`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function assertShowInput(options: ToastShowInput, caller: string) {
|
|
598
|
+
assertToastType(options.type, caller);
|
|
599
|
+
assertContentFields(options, caller);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function assertUpdateInput(options: ToastUpdateInput) {
|
|
603
|
+
assertContentFields(options, "update");
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function isNonEmptyString(value: unknown): boolean {
|
|
607
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function resolveConfig(
|
|
611
|
+
base: ToastConfig,
|
|
612
|
+
overrides: ToastOptions | ToastShowInput | ToastUpdateInput,
|
|
613
|
+
): ToastOptions {
|
|
614
|
+
const {
|
|
615
|
+
type,
|
|
616
|
+
title,
|
|
617
|
+
description,
|
|
618
|
+
animation: animationOverride,
|
|
619
|
+
...restOverrides
|
|
620
|
+
} = overrides as Partial<ToastOptions>;
|
|
621
|
+
|
|
622
|
+
const animation = {
|
|
623
|
+
...base.animation,
|
|
624
|
+
...(animationOverride ?? {}),
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
...base,
|
|
629
|
+
...restOverrides,
|
|
630
|
+
animation,
|
|
631
|
+
type: type ?? "default",
|
|
632
|
+
title: title ?? "",
|
|
633
|
+
description: description ?? "",
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function insertToast(
|
|
638
|
+
existing: ToastInstance[],
|
|
639
|
+
next: ToastInstance,
|
|
640
|
+
): ToastInstance[] {
|
|
641
|
+
if (next.duration === 0 || !Number.isFinite(next.duration)) {
|
|
642
|
+
next.progressBar = false;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const position = next.position;
|
|
646
|
+
const order = next.order;
|
|
647
|
+
|
|
648
|
+
const others = existing.filter((t) => t.position !== position);
|
|
649
|
+
const samePos = existing.filter((t) => t.position === position);
|
|
650
|
+
|
|
651
|
+
const isTop = position.startsWith("top-");
|
|
652
|
+
|
|
653
|
+
if (order === "newest") {
|
|
654
|
+
if (isTop) {
|
|
655
|
+
return [...others, next, ...samePos];
|
|
656
|
+
} else {
|
|
657
|
+
return [...others, ...samePos, next];
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (isTop) {
|
|
662
|
+
return [...others, ...samePos, next];
|
|
663
|
+
} else {
|
|
664
|
+
return [...others, next, ...samePos];
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function pickOverflowToast(
|
|
669
|
+
samePos: ToastInstance[],
|
|
670
|
+
order: ToastOrder,
|
|
671
|
+
): ToastInstance | null {
|
|
672
|
+
if (!samePos.length) {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (order === "newest") {
|
|
677
|
+
let oldest = samePos[0];
|
|
678
|
+
for (const t of samePos) {
|
|
679
|
+
if (t.createdAt < oldest.createdAt) {
|
|
680
|
+
oldest = t;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return oldest;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
let newest = samePos[0];
|
|
687
|
+
for (const t of samePos) {
|
|
688
|
+
if (t.createdAt > newest.createdAt) {
|
|
689
|
+
newest = t;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return newest;
|
|
694
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unique identifier assigned to each toast instance.
|
|
3
|
+
*/
|
|
4
|
+
export type ToastId = string;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Supported viewport anchors where toasts can stack.
|
|
8
|
+
*/
|
|
9
|
+
export type ToastPosition =
|
|
10
|
+
| "top-left"
|
|
11
|
+
| "top-center"
|
|
12
|
+
| "top-right"
|
|
13
|
+
| "bottom-left"
|
|
14
|
+
| "bottom-center"
|
|
15
|
+
| "bottom-right";
|
|
16
|
+
/**
|
|
17
|
+
* Order to display and evict toasts in a stack.
|
|
18
|
+
*/
|
|
19
|
+
export type ToastOrder = "newest" | "oldest";
|
|
20
|
+
/**
|
|
21
|
+
* Determines how the timer behaves after an interaction pause.
|
|
22
|
+
*/
|
|
23
|
+
export type PauseStrategy = "resume" | "reset";
|
|
24
|
+
/**
|
|
25
|
+
* Semantic type that controls the toast's appearance.
|
|
26
|
+
*/
|
|
27
|
+
export type ToastType =
|
|
28
|
+
| "loading"
|
|
29
|
+
| "default"
|
|
30
|
+
| "success"
|
|
31
|
+
| "error"
|
|
32
|
+
| "info"
|
|
33
|
+
| "warning";
|
|
34
|
+
/**
|
|
35
|
+
* Internal lifecycle markers used for animations and cleanup.
|
|
36
|
+
*/
|
|
37
|
+
export type ToastPhase = "enter" | "leaving" | "clear-all";
|
|
38
|
+
/**
|
|
39
|
+
* Event kinds emitted to event subscribers.
|
|
40
|
+
*/
|
|
41
|
+
export type ToastEventKind = "duplicate" | "timer-reset" | "update";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Payload emitted whenever the store dispatches a toast event.
|
|
45
|
+
*/
|
|
46
|
+
export interface ToastEvent {
|
|
47
|
+
id: ToastId;
|
|
48
|
+
kind: ToastEventKind;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Async work passed to the loading helper.
|
|
53
|
+
*/
|
|
54
|
+
export type ToastLoadingInput<T> = Promise<T> | (() => Promise<T>);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Text and rendering options for the loading helper phases.
|
|
58
|
+
*/
|
|
59
|
+
export interface ToastLoadingConfig<T> {
|
|
60
|
+
loading: ToastContentInput;
|
|
61
|
+
success: ToastLoadingRender<T>;
|
|
62
|
+
error: ToastLoadingRender<unknown>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* CSS class names used to animate toasts.
|
|
67
|
+
*/
|
|
68
|
+
export interface ToastAnimation {
|
|
69
|
+
name: string;
|
|
70
|
+
bump: string;
|
|
71
|
+
clearAll: string;
|
|
72
|
+
update: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Minimal context shared with lifecycle and click callbacks.
|
|
77
|
+
*/
|
|
78
|
+
export interface ToastContext {
|
|
79
|
+
id: ToastId;
|
|
80
|
+
position: ToastPosition;
|
|
81
|
+
type: ToastType;
|
|
82
|
+
title: string;
|
|
83
|
+
description: string;
|
|
84
|
+
createdAt: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Global configuration that shapes how toasts look and behave.
|
|
89
|
+
*/
|
|
90
|
+
export interface ToastConfig {
|
|
91
|
+
/**
|
|
92
|
+
* Distance from the viewport edge where toasts start.
|
|
93
|
+
*/
|
|
94
|
+
offset: string;
|
|
95
|
+
/**
|
|
96
|
+
* Gap between toasts stacked at the same position.
|
|
97
|
+
*/
|
|
98
|
+
gap: string;
|
|
99
|
+
/**
|
|
100
|
+
* z-index applied to the toast container.
|
|
101
|
+
*/
|
|
102
|
+
zIndex: number;
|
|
103
|
+
/**
|
|
104
|
+
* Fixed width applied to each toast.
|
|
105
|
+
*/
|
|
106
|
+
width: string;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Time in milliseconds before a toast auto-dismisses.
|
|
110
|
+
* Use Infinity or 0 to keep it visible until manually dismissed.
|
|
111
|
+
*/
|
|
112
|
+
duration: number;
|
|
113
|
+
/**
|
|
114
|
+
* Maximum number of visible toasts per position; extra items are evicted.
|
|
115
|
+
*/
|
|
116
|
+
maxVisible: number;
|
|
117
|
+
/**
|
|
118
|
+
* Default stack position used when none is provided.
|
|
119
|
+
*/
|
|
120
|
+
position: ToastPosition;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* When true, skip showing identical toasts that are still visible.
|
|
124
|
+
*/
|
|
125
|
+
preventDuplicates: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Controls whether new toasts appear before or after older ones.
|
|
128
|
+
*/
|
|
129
|
+
order: ToastOrder;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* When enabled, render a progress bar for finite durations.
|
|
133
|
+
*/
|
|
134
|
+
progressBar: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* Pause the timer while the toast is hovered.
|
|
137
|
+
*/
|
|
138
|
+
pauseOnHover: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Whether resuming should continue the remaining time or restart it.
|
|
141
|
+
*/
|
|
142
|
+
pauseStrategy: PauseStrategy;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* CSS animation class overrides for various toast transitions.
|
|
146
|
+
*/
|
|
147
|
+
animation: Partial<ToastAnimation>;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Show a close button inside each toast.
|
|
151
|
+
*/
|
|
152
|
+
closeButton: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* Allow closing a toast by clicking anywhere on it.
|
|
155
|
+
*/
|
|
156
|
+
closeOnClick: boolean;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* When true, title/description may contain HTML.
|
|
160
|
+
*/
|
|
161
|
+
supportHtml: boolean;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Show the createdAt timestamp in the rendered toast.
|
|
165
|
+
*/
|
|
166
|
+
showCreatedAt: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Format the createdAt timestamp when displayed.
|
|
169
|
+
*/
|
|
170
|
+
createdAtFormatter: (createdAt: number) => string;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Called after the toast is added to state.
|
|
174
|
+
*/
|
|
175
|
+
onMount?(ctx: ToastContext): void;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Called after the toast is removed from state.
|
|
179
|
+
*/
|
|
180
|
+
onUnmount?(ctx: ToastContext): void;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Called when the toast is clicked.
|
|
184
|
+
*/
|
|
185
|
+
onClick?(ctx: ToastContext, event: MouseEvent): void;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Called right before a toast starts leaving.
|
|
189
|
+
*/
|
|
190
|
+
onClose?(ctx: ToastContext): void;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fully resolved options applied to a toast instance.
|
|
195
|
+
*/
|
|
196
|
+
export interface ToastOptions extends ToastConfig {
|
|
197
|
+
type: ToastType;
|
|
198
|
+
title: string;
|
|
199
|
+
description: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Minimal text payload required to render a toast.
|
|
204
|
+
*/
|
|
205
|
+
export type ToastTextInput =
|
|
206
|
+
| { title: string; description?: string }
|
|
207
|
+
| { title?: string; description: string };
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Text payload with optional configuration overrides.
|
|
211
|
+
*/
|
|
212
|
+
export type ToastContentInput = ToastTextInput &
|
|
213
|
+
Partial<Omit<ToastOptions, "type" | "title" | "description">>;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Payload required to create a toast.
|
|
217
|
+
*/
|
|
218
|
+
export type ToastShowInput = { type: ToastType } & ToastContentInput;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Allowed fields when updating an existing toast.
|
|
222
|
+
*/
|
|
223
|
+
export type ToastUpdateInput = ToastContentInput &
|
|
224
|
+
Partial<Omit<ToastOptions, "title" | "description">>;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Render instructions for the loading helper's success/error states.
|
|
228
|
+
*/
|
|
229
|
+
export type ToastLoadingRender<T> =
|
|
230
|
+
| ToastContentInput
|
|
231
|
+
| ((value: T) => ToastContentInput);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Promise returned from the loading helper that also exposes the toast id.
|
|
235
|
+
*/
|
|
236
|
+
export type ToastLoadingResult<T> = Promise<T> & { toastId: ToastId };
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Concrete toast stored in state, including timing metadata.
|
|
240
|
+
*/
|
|
241
|
+
export interface ToastInstance extends ToastOptions {
|
|
242
|
+
id: ToastId;
|
|
243
|
+
createdAt: number;
|
|
244
|
+
phase?: ToastPhase;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Shape of the store's current state.
|
|
249
|
+
*/
|
|
250
|
+
export interface ToastState {
|
|
251
|
+
toasts: ToastInstance[];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Public API for interacting with the toast store.
|
|
256
|
+
*/
|
|
257
|
+
export interface ToastStore {
|
|
258
|
+
/**
|
|
259
|
+
* Return the current toast state snapshot.
|
|
260
|
+
*/
|
|
261
|
+
getState(): ToastState;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Subscribe to state changes; immediately emits the current state.
|
|
265
|
+
*/
|
|
266
|
+
subscribe(listener: (state: ToastState) => void): () => void;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Subscribe to one-off toast events such as duplicates or timer resets.
|
|
270
|
+
*/
|
|
271
|
+
subscribeEvents(listener: (event: ToastEvent) => void): () => void;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Create and show a toast; returns its id.
|
|
275
|
+
*/
|
|
276
|
+
show(options: ToastShowInput): ToastId;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Update an existing toast by id.
|
|
280
|
+
*/
|
|
281
|
+
update(id: ToastId, options: ToastUpdateInput): void;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Dismiss a single toast by id.
|
|
285
|
+
*/
|
|
286
|
+
dismiss(id: ToastId): void;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Dismiss all toasts at once.
|
|
290
|
+
*/
|
|
291
|
+
dismissAll(): void;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Pause the auto-dismiss timer for a toast.
|
|
295
|
+
*/
|
|
296
|
+
pause(id: ToastId): void;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Resume the auto-dismiss timer using the configured strategy.
|
|
300
|
+
*/
|
|
301
|
+
resume(id: ToastId): void;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Return the resolved global configuration.
|
|
305
|
+
*/
|
|
306
|
+
getConfig(): ToastConfig;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Wrap an async task with a loading toast that updates on completion.
|
|
310
|
+
*/
|
|
311
|
+
loading<T>(
|
|
312
|
+
input: ToastLoadingInput<T>,
|
|
313
|
+
config: ToastLoadingConfig<T>,
|
|
314
|
+
): ToastLoadingResult<T>;
|
|
315
|
+
}
|