@tsirosgeorge/toastnotification 5.4.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +55 -1
- package/assets/css/toast.css +56 -1
- package/assets/css/toast.min.css +1 -1
- package/package.json +3 -2
- package/toast.d.ts +43 -1
- package/toast.js +332 -68
- package/toast.min.js +1 -1
- package/toast.module.js +332 -68
package/toast.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// Single source of truth for the CDN this build points at.
|
|
4
4
|
// `npm run sync:version` rewrites it from package.json, so it can never go stale.
|
|
5
|
-
const TS_TOAST_VERSION = "5.
|
|
5
|
+
const TS_TOAST_VERSION = "5.6.0";
|
|
6
6
|
// Point this at your own copy of assets/ to self-host the CSS and icons
|
|
7
7
|
// (useful offline, behind a strict CSP, or when you don't want a CDN dependency):
|
|
8
8
|
// window.TS_TOAST_ASSET_BASE = '/vendor/toastnotification';
|
|
@@ -10,6 +10,24 @@ const TS_TOAST_CDN = (typeof window !== 'undefined' && window.TS_TOAST_ASSET_BAS
|
|
|
10
10
|
? String(window.TS_TOAST_ASSET_BASE).replace(/\/+$/, '')
|
|
11
11
|
: `https://cdn.jsdelivr.net/npm/@tsirosgeorge/toastnotification@${TS_TOAST_VERSION}`;
|
|
12
12
|
|
|
13
|
+
// How many confirm dialogs are currently open. The body scroll lock belongs to the
|
|
14
|
+
// group, so only the last dialog to close may release it.
|
|
15
|
+
let tsToastOpenModals = 0;
|
|
16
|
+
let tsToastPrevOverflow = '';
|
|
17
|
+
let tsToastPrevPaddingRight = '';
|
|
18
|
+
|
|
19
|
+
const tsToastReducedMotion = () =>
|
|
20
|
+
typeof window !== 'undefined' &&
|
|
21
|
+
typeof window.matchMedia === 'function' &&
|
|
22
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
23
|
+
|
|
24
|
+
// Everything inside the dialog a keyboard can reach, in DOM order.
|
|
25
|
+
const tsToastFocusable = (root) => Array.from(
|
|
26
|
+
root.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
|
27
|
+
).filter((el) => !el.disabled && el.offsetParent !== null);
|
|
28
|
+
|
|
29
|
+
let tsToastIdCounter = 0;
|
|
30
|
+
|
|
13
31
|
// Load the stylesheet from the CDN, unless the page opted out by importing it itself
|
|
14
32
|
// (set window.TS_TOAST_NO_CSS = true before loading, or ship assets/css/toast.css yourself).
|
|
15
33
|
(function loadStylesheet() {
|
|
@@ -33,6 +51,12 @@ const TS_TOAST_CDN = (typeof window !== 'undefined' && window.TS_TOAST_ASSET_BAS
|
|
|
33
51
|
/* Ensure center positions exist even if external CSS lacks them */
|
|
34
52
|
.ts-toast-container.top-center { top: 1rem; left: 50%; transform: translateX(-50%); align-items: center; }
|
|
35
53
|
.ts-toast-container.bottom-center { bottom: 1rem; left: 50%; transform: translateX(-50%); align-items: center; }
|
|
54
|
+
.ts-toast-container.center { top: 50%; left: 50%; transform: translate(-50%, -50%); align-items: center; }
|
|
55
|
+
.ts-toast-overlay.center { align-items: center; justify-content: center; }
|
|
56
|
+
@keyframes ts-toast-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
|
57
|
+
.ts-toast .ts-toast-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; transform-origin: left center; border-radius: 0 0 8px 8px; background: currentColor; opacity: 0.35; pointer-events: none; }
|
|
58
|
+
.ts-toast .ts-toast-action { order: -1; flex: none; appearance: none; border: 0; background: transparent; color: #3b82f6; font: inherit; font-weight: 600; padding: 4px 8px; margin-left: 4px; border-radius: 6px; cursor: pointer; }
|
|
59
|
+
.ts-toast .ts-toast-action:hover { background: rgba(59,130,246,0.12); }
|
|
36
60
|
.ts-toast-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 2147483646; }
|
|
37
61
|
.ts-toast.ts-toast-confirm { max-width: min(92vw, 440px); width: max(320px, 60%); flex-direction: column; gap: 12px; padding: 16px 20px; background: var(--toast-bg, #fff); color: var(--toast-color, #000); border: 1px solid var(--toast-border, #e5e7eb); border-radius: 12px; box-shadow: var(--toast-shadow, 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)); text-align: center; }
|
|
38
62
|
.ts-toast.ts-toast-confirm .ts-toast-content { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; }
|
|
@@ -56,6 +80,9 @@ const TS_TOAST_CDN = (typeof window !== 'undefined' && window.TS_TOAST_ASSET_BAS
|
|
|
56
80
|
})();
|
|
57
81
|
|
|
58
82
|
const toast = function (message, options = {}) {
|
|
83
|
+
// Site-wide defaults, so a page can turn on things like showProgress once
|
|
84
|
+
// instead of repeating them at every call site.
|
|
85
|
+
options = { ...(toast.defaults || {}), ...options };
|
|
59
86
|
const {
|
|
60
87
|
position = 'top-right',
|
|
61
88
|
animation = 'slide-right', // Default fallback animation
|
|
@@ -84,6 +111,17 @@ const toast = function (message, options = {}) {
|
|
|
84
111
|
useOverlay = true,
|
|
85
112
|
closeOnOverlayClick = true,
|
|
86
113
|
showClose = false,
|
|
114
|
+
// Freeze the countdown while the pointer or keyboard focus is on the toast
|
|
115
|
+
pauseOnHover = true,
|
|
116
|
+
// Thin bar counting the remaining time down
|
|
117
|
+
showProgress = false,
|
|
118
|
+
// { text, onClick } renders a button inside the toast, e.g. Undo
|
|
119
|
+
action = null,
|
|
120
|
+
// Escape cancels a confirm dialog
|
|
121
|
+
closeOnEscape = true,
|
|
122
|
+
// `message` is written as HTML for backwards compatibility. Pass false to
|
|
123
|
+
// render it as plain text, which is what you want for anything user-supplied.
|
|
124
|
+
allowHtml = true,
|
|
87
125
|
// interactions
|
|
88
126
|
dismissOnClick = true, // ignored if confirm-mode
|
|
89
127
|
onClick = null, // Custom onClick event listener
|
|
@@ -92,6 +130,7 @@ const toast = function (message, options = {}) {
|
|
|
92
130
|
} = options;
|
|
93
131
|
|
|
94
132
|
const isConfirm = (mode === 'confirm' || mode === 'swal');
|
|
133
|
+
const reducedMotion = tsToastReducedMotion();
|
|
95
134
|
|
|
96
135
|
// Pick an animation intelligently when one wasn't explicitly provided
|
|
97
136
|
const resolvedAnimation = (typeof options.animation === 'string' && options.animation.trim())
|
|
@@ -107,10 +146,12 @@ const toast = function (message, options = {}) {
|
|
|
107
146
|
};
|
|
108
147
|
return m[a] || a;
|
|
109
148
|
})(options.animation.trim())
|
|
110
|
-
|
|
149
|
+
// 'center' has no edge to slide in from, so it zooms like a dialog.
|
|
150
|
+
: (isConfirm || position === 'center' ? 'ts-toast-zoom-in'
|
|
151
|
+
: position.startsWith('top') ? 'ts-toast-slide-top'
|
|
111
152
|
: position.startsWith('bottom') ? 'ts-toast-slide-bottom'
|
|
112
153
|
: position.endsWith('left') ? 'ts-toast-slide-left'
|
|
113
|
-
: 'ts-toast-slide-right')
|
|
154
|
+
: 'ts-toast-slide-right');
|
|
114
155
|
|
|
115
156
|
// helper: remove with smooth CSS transition and cleanup (used by alerts)
|
|
116
157
|
const removeWithAnimation = (el, callback) => {
|
|
@@ -143,13 +184,24 @@ const toast = function (message, options = {}) {
|
|
|
143
184
|
el.classList.remove('ts-toast-slide-out');
|
|
144
185
|
if (el.parentNode) el.parentNode.removeChild(el);
|
|
145
186
|
if (typeof callback === 'function') callback();
|
|
146
|
-
}, 500);
|
|
187
|
+
}, reducedMotion ? 0 : 500);
|
|
147
188
|
};
|
|
148
189
|
|
|
149
190
|
const toastElement = document.createElement('div');
|
|
150
191
|
toastElement.className = `ts-toast ts-toast-${type}${isConfirm ? ' ts-toast-confirm' : ''}`;
|
|
151
192
|
toastElement.dataset.anim = resolvedAnimation;
|
|
152
|
-
toastElement.style.animation = `${resolvedAnimation} 0.5s ease`;
|
|
193
|
+
if (!reducedMotion) toastElement.style.animation = `${resolvedAnimation} 0.5s ease`;
|
|
194
|
+
|
|
195
|
+
const uid = `ts-toast-${++tsToastIdCounter}`;
|
|
196
|
+
if (isConfirm) {
|
|
197
|
+
// Without these a screen reader announces nothing, and without tabindex the
|
|
198
|
+
// dialog cannot take focus away from whatever opened it.
|
|
199
|
+
toastElement.setAttribute('role', 'dialog');
|
|
200
|
+
toastElement.setAttribute('aria-modal', 'true');
|
|
201
|
+
toastElement.tabIndex = -1;
|
|
202
|
+
} else if (type === 'error' || type === 'warning') {
|
|
203
|
+
toastElement.setAttribute('role', 'alert');
|
|
204
|
+
}
|
|
153
205
|
// In confirm mode, we stack content vertically; in alert mode keep original layout
|
|
154
206
|
if (!isConfirm) {
|
|
155
207
|
toastElement.style.flexDirection = 'row-reverse';
|
|
@@ -181,7 +233,11 @@ const toast = function (message, options = {}) {
|
|
|
181
233
|
// Create Body
|
|
182
234
|
const toastBody = document.createElement('div');
|
|
183
235
|
toastBody.className = 'ts-toast-body';
|
|
184
|
-
toastBody.
|
|
236
|
+
toastBody.id = `${uid}-body`;
|
|
237
|
+
// HTML by default for backwards compatibility; pass allowHtml: false for
|
|
238
|
+
// anything that came from a user.
|
|
239
|
+
if (allowHtml) toastBody.innerHTML = message;
|
|
240
|
+
else toastBody.textContent = message;
|
|
185
241
|
|
|
186
242
|
// Content row for confirm (icon + text side-by-side)
|
|
187
243
|
let contentRow = null;
|
|
@@ -192,10 +248,13 @@ const toast = function (message, options = {}) {
|
|
|
192
248
|
if (title) {
|
|
193
249
|
const titleEl = document.createElement('div');
|
|
194
250
|
titleEl.className = 'ts-toast-title';
|
|
251
|
+
titleEl.id = `${uid}-title`;
|
|
195
252
|
titleEl.textContent = title;
|
|
196
253
|
contentRow.appendChild(titleEl);
|
|
254
|
+
toastElement.setAttribute('aria-labelledby', titleEl.id);
|
|
197
255
|
}
|
|
198
256
|
contentRow.appendChild(toastBody);
|
|
257
|
+
toastElement.setAttribute('aria-describedby', toastBody.id);
|
|
199
258
|
toastElement.appendChild(contentRow);
|
|
200
259
|
} else {
|
|
201
260
|
toastElement.appendChild(toastBody);
|
|
@@ -214,6 +273,13 @@ const toast = function (message, options = {}) {
|
|
|
214
273
|
inputElement.className = 'ts-toast-input';
|
|
215
274
|
inputElement.placeholder = inputPlaceholder;
|
|
216
275
|
inputElement.value = inputValue;
|
|
276
|
+
// Enter submits a single-line field, the way a native prompt does.
|
|
277
|
+
// A textarea keeps Enter for newlines.
|
|
278
|
+
if (input !== 'textarea') {
|
|
279
|
+
inputElement.addEventListener('keydown', (e) => {
|
|
280
|
+
if (e.key === 'Enter') { e.preventDefault(); resolveAndClose(true); }
|
|
281
|
+
});
|
|
282
|
+
}
|
|
217
283
|
toastElement.appendChild(inputElement);
|
|
218
284
|
}
|
|
219
285
|
|
|
@@ -223,6 +289,8 @@ const toast = function (message, options = {}) {
|
|
|
223
289
|
// Assigned in confirm mode; the overlay and (x) handlers below call it so that
|
|
224
290
|
// *every* way of dismissing the dialog settles the promise and the callbacks.
|
|
225
291
|
let resolveAndClose = null;
|
|
292
|
+
// Releases the scroll lock, the key handler and the focus this dialog took.
|
|
293
|
+
let releaseModal = () => {};
|
|
226
294
|
if (isConfirm) {
|
|
227
295
|
actionsContainer = document.createElement('div');
|
|
228
296
|
actionsContainer.className = 'ts-toast-actions';
|
|
@@ -262,6 +330,7 @@ const toast = function (message, options = {}) {
|
|
|
262
330
|
if (confirmed && typeof onConfirm === 'function') onConfirm(result, toastElement);
|
|
263
331
|
if (!confirmed && typeof onCancel === 'function') onCancel(toastElement);
|
|
264
332
|
if (typeof onResult === 'function') onResult(result, toastElement);
|
|
333
|
+
releaseModal();
|
|
265
334
|
// Use the same slide+fade removal as alerts
|
|
266
335
|
removeWithAnimation(toastElement, () => {
|
|
267
336
|
if (onDismiss && typeof onDismiss === 'function') onDismiss(toastElement);
|
|
@@ -274,6 +343,31 @@ const toast = function (message, options = {}) {
|
|
|
274
343
|
confirmBtn.addEventListener('click', (e) => { e.stopPropagation(); resolveAndClose(true); });
|
|
275
344
|
}
|
|
276
345
|
|
|
346
|
+
// Action button (e.g. Undo). Clicking it runs the callback and closes the toast.
|
|
347
|
+
if (!isConfirm && action && typeof action === 'object' && action.text) {
|
|
348
|
+
const actionBtn = document.createElement('button');
|
|
349
|
+
actionBtn.className = 'ts-toast-action';
|
|
350
|
+
actionBtn.type = 'button';
|
|
351
|
+
actionBtn.textContent = action.text;
|
|
352
|
+
actionBtn.addEventListener('click', (e) => {
|
|
353
|
+
e.stopPropagation(); // do not also trigger dismissOnClick
|
|
354
|
+
if (typeof action.onClick === 'function') action.onClick(toastElement);
|
|
355
|
+
toastElement._dismiss();
|
|
356
|
+
});
|
|
357
|
+
toastElement.appendChild(actionBtn);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Progress bar. The width is driven by a CSS animation whose duration is the
|
|
361
|
+
// toast's own, so pausing it is one property and never drifts from the timer.
|
|
362
|
+
let progressBar = null;
|
|
363
|
+
if (!isConfirm && showProgress && duration > 0 && !reducedMotion) {
|
|
364
|
+
progressBar = document.createElement('div');
|
|
365
|
+
progressBar.className = 'ts-toast-progress';
|
|
366
|
+
progressBar.style.animation = `ts-toast-progress ${duration}ms linear forwards`;
|
|
367
|
+
progressBar.style.animationPlayState = 'paused';
|
|
368
|
+
toastElement.appendChild(progressBar);
|
|
369
|
+
}
|
|
370
|
+
|
|
277
371
|
// Loader Element
|
|
278
372
|
let loader = null;
|
|
279
373
|
if (showLoader) {
|
|
@@ -284,6 +378,7 @@ const toast = function (message, options = {}) {
|
|
|
284
378
|
|
|
285
379
|
// Container/Overlay
|
|
286
380
|
let overlay = null;
|
|
381
|
+
let containerEl = null;
|
|
287
382
|
if (isConfirm && useOverlay) {
|
|
288
383
|
overlay = document.createElement('div');
|
|
289
384
|
// A modal centres by default. The `position` default of 'top-right' is meant
|
|
@@ -304,11 +399,17 @@ const toast = function (message, options = {}) {
|
|
|
304
399
|
toastElement.appendChild(closeBtn);
|
|
305
400
|
}
|
|
306
401
|
if (closeOnOverlayClick) {
|
|
402
|
+
// The cancel must both start and end on the backdrop. Selecting text in
|
|
403
|
+
// the input and releasing the mouse outside the card produced a click
|
|
404
|
+
// whose target was the overlay, which threw the dialog away mid-edit.
|
|
405
|
+
let pressedOnBackdrop = false;
|
|
406
|
+
overlay.addEventListener('pointerdown', (e) => { pressedOnBackdrop = e.target === overlay; });
|
|
307
407
|
overlay.addEventListener('click', (e) => {
|
|
308
408
|
// Backdrop click is a cancel: settle the promise and onCancel/onResult,
|
|
309
409
|
// then close. Previously this resolved only the internal el.result,
|
|
310
410
|
// so `await toast.confirm(...)` hung forever.
|
|
311
|
-
if (e.target === overlay) resolveAndClose(false);
|
|
411
|
+
if (e.target === overlay && pressedOnBackdrop) resolveAndClose(false);
|
|
412
|
+
pressedOnBackdrop = false;
|
|
312
413
|
});
|
|
313
414
|
}
|
|
314
415
|
} else {
|
|
@@ -319,7 +420,90 @@ const toast = function (message, options = {}) {
|
|
|
319
420
|
container.className = `ts-toast-container ${position}`;
|
|
320
421
|
document.body.appendChild(container);
|
|
321
422
|
}
|
|
423
|
+
if (!container.hasAttribute('aria-live')) {
|
|
424
|
+
// Without this a toast is invisible to a screen reader.
|
|
425
|
+
container.setAttribute('role', 'status');
|
|
426
|
+
container.setAttribute('aria-live', 'polite');
|
|
427
|
+
container.setAttribute('aria-relevant', 'additions');
|
|
428
|
+
}
|
|
322
429
|
container.appendChild(toastElement);
|
|
430
|
+
containerEl = container;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (isConfirm) {
|
|
434
|
+
// The dialog has to own the keyboard while it is open. Without this the
|
|
435
|
+
// element that opened it keeps focus, so pressing Enter or Space activates
|
|
436
|
+
// it again and stacks a second dialog on top of the first — and Tab walks
|
|
437
|
+
// through the page behind the backdrop.
|
|
438
|
+
const previouslyFocused = document.activeElement;
|
|
439
|
+
|
|
440
|
+
tsToastOpenModals += 1;
|
|
441
|
+
if (tsToastOpenModals === 1) {
|
|
442
|
+
tsToastPrevOverflow = document.body.style.overflow;
|
|
443
|
+
tsToastPrevPaddingRight = document.body.style.paddingRight;
|
|
444
|
+
// Hiding the scrollbar makes the page wider, which shifts the whole
|
|
445
|
+
// layout sideways as the dialog opens. Pad by the scrollbar's width to
|
|
446
|
+
// hold it still. A no-op where scrollbars are overlays, as on macOS.
|
|
447
|
+
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
|
|
448
|
+
if (scrollbar > 0) {
|
|
449
|
+
const current = parseFloat(window.getComputedStyle(document.body).paddingRight) || 0;
|
|
450
|
+
document.body.style.paddingRight = `${current + scrollbar}px`;
|
|
451
|
+
}
|
|
452
|
+
document.body.style.overflow = 'hidden';
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// With dialogs stacked, only the top one should answer the keyboard.
|
|
456
|
+
const isTopmost = () => {
|
|
457
|
+
const open = document.querySelectorAll('.ts-toast.ts-toast-confirm');
|
|
458
|
+
return open.length === 0 || open[open.length - 1] === toastElement;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const onKeydown = (e) => {
|
|
462
|
+
if (!isTopmost()) return;
|
|
463
|
+
|
|
464
|
+
if (e.key === 'Escape' && closeOnEscape) {
|
|
465
|
+
e.preventDefault();
|
|
466
|
+
resolveAndClose(false);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (e.key !== 'Tab') return;
|
|
470
|
+
|
|
471
|
+
const focusables = tsToastFocusable(toastElement);
|
|
472
|
+
if (!focusables.length) { e.preventDefault(); return; }
|
|
473
|
+
|
|
474
|
+
const first = focusables[0];
|
|
475
|
+
const last = focusables[focusables.length - 1];
|
|
476
|
+
|
|
477
|
+
// Focus can start outside the dialog (the trigger button); pull it back.
|
|
478
|
+
if (!toastElement.contains(document.activeElement)) {
|
|
479
|
+
e.preventDefault();
|
|
480
|
+
(e.shiftKey ? last : first).focus();
|
|
481
|
+
} else if (e.shiftKey && document.activeElement === first) {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
last.focus();
|
|
484
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
485
|
+
e.preventDefault();
|
|
486
|
+
first.focus();
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
document.addEventListener('keydown', onKeydown, true);
|
|
490
|
+
|
|
491
|
+
releaseModal = () => {
|
|
492
|
+
document.removeEventListener('keydown', onKeydown, true);
|
|
493
|
+
tsToastOpenModals = Math.max(0, tsToastOpenModals - 1);
|
|
494
|
+
if (tsToastOpenModals === 0) {
|
|
495
|
+
document.body.style.overflow = tsToastPrevOverflow;
|
|
496
|
+
document.body.style.paddingRight = tsToastPrevPaddingRight;
|
|
497
|
+
}
|
|
498
|
+
// Hand the keyboard back to whatever opened the dialog.
|
|
499
|
+
if (previouslyFocused && typeof previouslyFocused.focus === 'function' &&
|
|
500
|
+
document.contains(previouslyFocused)) {
|
|
501
|
+
previouslyFocused.focus();
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// Land on the input when there is one, otherwise the confirm button.
|
|
506
|
+
(inputElement || toastElement.querySelector('.ts-toast-btn.confirm') || toastElement).focus();
|
|
323
507
|
}
|
|
324
508
|
|
|
325
509
|
// Trigger the onShow event if provided
|
|
@@ -332,6 +516,10 @@ const toast = function (message, options = {}) {
|
|
|
332
516
|
toastElement.classList.add('ts-toast-show');
|
|
333
517
|
}, 100);
|
|
334
518
|
|
|
519
|
+
// The loader always ran for 2s, so with a shorter duration the toast was gone
|
|
520
|
+
// before the icon it reveals ever appeared.
|
|
521
|
+
const loaderMs = duration > 0 ? Math.min(2000, Math.max(0, duration - 500)) : 2000;
|
|
522
|
+
|
|
335
523
|
// Handle Loader and Icon
|
|
336
524
|
if (showLoader && loader) {
|
|
337
525
|
setTimeout(() => {
|
|
@@ -343,7 +531,7 @@ const toast = function (message, options = {}) {
|
|
|
343
531
|
if (isConfirm && contentRow) contentRow.appendChild(iconElement);
|
|
344
532
|
else toastElement.appendChild(iconElement); // Add icon only if not present
|
|
345
533
|
}
|
|
346
|
-
},
|
|
534
|
+
}, loaderMs);
|
|
347
535
|
}
|
|
348
536
|
if (!showLoader) {
|
|
349
537
|
// For confirm, icon already added above inside contentRow; avoid moving it
|
|
@@ -352,47 +540,103 @@ const toast = function (message, options = {}) {
|
|
|
352
540
|
}
|
|
353
541
|
}
|
|
354
542
|
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
543
|
+
// Dismissal state lives on the element so toast.update() can rebind the callback
|
|
544
|
+
// and reuse this exact removal path instead of keeping its own copy of it.
|
|
545
|
+
toastElement._onDismiss = typeof onDismiss === 'function' ? onDismiss : null;
|
|
546
|
+
|
|
547
|
+
// A plain setTimeout cannot be paused, so the countdown is tracked by hand:
|
|
548
|
+
// `remaining` is what is left, and hovering banks it.
|
|
549
|
+
let remaining = duration;
|
|
550
|
+
let timerId = null;
|
|
551
|
+
let startedAt = 0;
|
|
552
|
+
|
|
553
|
+
const stopTimer = () => {
|
|
554
|
+
if (timerId) { clearTimeout(timerId); timerId = null; }
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const startTimer = () => {
|
|
558
|
+
if (isConfirm || remaining <= 0 || timerId) return;
|
|
559
|
+
startedAt = Date.now();
|
|
560
|
+
timerId = setTimeout(() => { timerId = null; toastElement._dismiss(); }, remaining);
|
|
561
|
+
if (progressBar) progressBar.style.animationPlayState = 'running';
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const pauseTimer = () => {
|
|
565
|
+
if (!timerId) return;
|
|
566
|
+
clearTimeout(timerId);
|
|
567
|
+
timerId = null;
|
|
568
|
+
remaining -= Date.now() - startedAt;
|
|
569
|
+
if (progressBar) progressBar.style.animationPlayState = 'paused';
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
toastElement._dismiss = () => {
|
|
573
|
+
if (toastElement._removing) return; // never run the exit twice
|
|
574
|
+
toastElement._removing = true;
|
|
575
|
+
stopTimer();
|
|
576
|
+
removeWithAnimation(toastElement, () => {
|
|
577
|
+
if (toastElement._onDismiss) toastElement._onDismiss(toastElement);
|
|
578
|
+
// Containers used to pile up in the DOM, one per position, forever.
|
|
579
|
+
if (containerEl && !containerEl.children.length) containerEl.remove();
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
// Lets toast.update() re-arm the countdown without reaching into internals.
|
|
584
|
+
toastElement._setDuration = (ms) => {
|
|
585
|
+
stopTimer();
|
|
586
|
+
remaining = ms;
|
|
587
|
+
startTimer();
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
startTimer();
|
|
591
|
+
|
|
592
|
+
if (!isConfirm && pauseOnHover) {
|
|
593
|
+
// Give the reader a chance to finish the sentence.
|
|
594
|
+
toastElement.addEventListener('mouseenter', pauseTimer);
|
|
595
|
+
toastElement.addEventListener('mouseleave', startTimer);
|
|
596
|
+
toastElement.addEventListener('focusin', pauseTimer);
|
|
597
|
+
toastElement.addEventListener('focusout', startTimer);
|
|
363
598
|
}
|
|
364
599
|
|
|
365
600
|
// Add event listener for closing the toast when clicked (disabled in confirm mode)
|
|
366
601
|
if (!isConfirm && dismissOnClick) {
|
|
367
602
|
toastElement.addEventListener('click', () => {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
});
|
|
603
|
+
// onClick belongs to the click, not to the end of the exit animation,
|
|
604
|
+
// which is where it used to fire half a second late.
|
|
605
|
+
if (onClick && typeof onClick === 'function') onClick(toastElement);
|
|
606
|
+
toastElement._dismiss();
|
|
373
607
|
});
|
|
374
608
|
}
|
|
375
609
|
|
|
376
610
|
// Add swipe event listeners for mobile dismissal
|
|
377
611
|
if (!isConfirm) {
|
|
378
612
|
let touchStartX = 0;
|
|
613
|
+
let touchStartY = 0;
|
|
379
614
|
let touchEndX = 0;
|
|
380
615
|
|
|
616
|
+
// Passive: these never preventDefault, and a non-passive touchstart blocks
|
|
617
|
+
// scrolling on the whole toast.
|
|
381
618
|
toastElement.addEventListener('touchstart', (e) => {
|
|
382
619
|
touchStartX = e.changedTouches[0].screenX;
|
|
383
|
-
|
|
620
|
+
touchStartY = e.changedTouches[0].screenY;
|
|
621
|
+
}, { passive: true });
|
|
384
622
|
|
|
385
623
|
toastElement.addEventListener('touchend', (e) => {
|
|
386
624
|
touchEndX = e.changedTouches[0].screenX;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
625
|
+
const dx = Math.abs(touchStartX - touchEndX);
|
|
626
|
+
const dy = Math.abs(touchStartY - e.changedTouches[0].screenY);
|
|
627
|
+
// Only a mostly-horizontal swipe dismisses, so scrolling the page past a
|
|
628
|
+
// toast no longer throws it away on a bit of sideways drift.
|
|
629
|
+
if (dx > 50 && dx > dy) toastElement._dismiss();
|
|
393
630
|
});
|
|
394
631
|
}
|
|
395
632
|
|
|
633
|
+
// Let callers dismiss a toast they are holding, instead of only waiting out
|
|
634
|
+
// the duration or making the user click it.
|
|
635
|
+
toastElement.close = () => {
|
|
636
|
+
if (isConfirm) { resolveAndClose(false); return; }
|
|
637
|
+
toastElement._dismiss();
|
|
638
|
+
};
|
|
639
|
+
|
|
396
640
|
return toastElement;
|
|
397
641
|
};
|
|
398
642
|
|
|
@@ -419,10 +663,9 @@ const toast = function (message, options = {}) {
|
|
|
419
663
|
type = null,
|
|
420
664
|
icon = null,
|
|
421
665
|
showLoader = false,
|
|
422
|
-
duration = 3000, // Default duration (in ms)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
onDismiss = null // Custom onDismiss event listener
|
|
666
|
+
duration = 3000, // Default duration (in ms); 0 keeps the toast on screen
|
|
667
|
+
allowHtml = true,
|
|
668
|
+
onDismiss = null // Replaces the callback the toast was created with
|
|
426
669
|
} = options;
|
|
427
670
|
|
|
428
671
|
// Remove old loader (if any)
|
|
@@ -440,7 +683,8 @@ const toast = function (message, options = {}) {
|
|
|
440
683
|
}
|
|
441
684
|
const toastBody = toastElement.querySelector('.ts-toast-body');
|
|
442
685
|
if (toastBody) {
|
|
443
|
-
toastBody.innerHTML = message;
|
|
686
|
+
if (allowHtml) toastBody.innerHTML = message;
|
|
687
|
+
else toastBody.textContent = message;
|
|
444
688
|
}
|
|
445
689
|
|
|
446
690
|
// Handle Icon update only if it's new or hasn't been set yet
|
|
@@ -468,9 +712,12 @@ const toast = function (message, options = {}) {
|
|
|
468
712
|
iconElement.appendChild(img);
|
|
469
713
|
}
|
|
470
714
|
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
(
|
|
715
|
+
// Only attach an icon we actually have. Without a type and without an explicit
|
|
716
|
+
// icon this used to append an empty <span><img></span>.
|
|
717
|
+
if (icon || iconElement.querySelector('img[src]')) {
|
|
718
|
+
const contentRow = toastElement.querySelector('.ts-toast-content');
|
|
719
|
+
(contentRow || toastElement).appendChild(iconElement);
|
|
720
|
+
}
|
|
474
721
|
|
|
475
722
|
// Handle loader if requested
|
|
476
723
|
if (showLoader) {
|
|
@@ -479,33 +726,19 @@ const toast = function (message, options = {}) {
|
|
|
479
726
|
toastElement.appendChild(loader);
|
|
480
727
|
setTimeout(() => {
|
|
481
728
|
loader.classList.add('done');
|
|
482
|
-
}, 2000
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Clear previous auto-remove timer if needed
|
|
486
|
-
if (toastElement._autoRemove) {
|
|
487
|
-
clearTimeout(toastElement._autoRemove);
|
|
729
|
+
}, duration > 0 ? Math.min(2000, Math.max(0, duration - 500)) : 2000);
|
|
488
730
|
}
|
|
489
731
|
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
el.classList.add('ts-toast-slide-out');
|
|
494
|
-
el.classList.remove('ts-toast-show');
|
|
495
|
-
el.style.animation = '';
|
|
496
|
-
setTimeout(() => {
|
|
497
|
-
el.classList.remove('ts-toast-slide-out');
|
|
498
|
-
if (el.parentNode) el.parentNode.removeChild(el);
|
|
499
|
-
if (typeof cb === 'function') cb();
|
|
500
|
-
}, 500);
|
|
501
|
-
};
|
|
732
|
+
// Rebind the dismiss callback rather than leaving the creation-time one in place,
|
|
733
|
+
// which meant an updated toast fired two different onDismiss handlers.
|
|
734
|
+
if (typeof onDismiss === 'function') toastElement._onDismiss = onDismiss;
|
|
502
735
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
736
|
+
// Re-arm the countdown through the toast's own timer, so duration: 0 keeps the
|
|
737
|
+
// toast on screen. This used to schedule setTimeout(..., 0) and remove it at once,
|
|
738
|
+
// which broke every `toast.loading(...).update(msg, { duration: 0 })`.
|
|
739
|
+
if (typeof toastElement._setDuration === 'function') {
|
|
740
|
+
toastElement._setDuration(duration);
|
|
741
|
+
}
|
|
509
742
|
};
|
|
510
743
|
|
|
511
744
|
toast.loading = function (message, options = {}) {
|
|
@@ -553,15 +786,11 @@ const toast = function (message, options = {}) {
|
|
|
553
786
|
showLoader: false // Disable loader when updating the message
|
|
554
787
|
});
|
|
555
788
|
},
|
|
789
|
+
// Reuse the element's own close so onDismiss fires, which this
|
|
790
|
+
// hand-rolled copy of the removal never did.
|
|
556
791
|
close: () => {
|
|
557
|
-
|
|
558
|
-
toastElement.
|
|
559
|
-
toastElement.classList.remove('ts-toast-show');
|
|
560
|
-
toastElement.style.animation = '';
|
|
561
|
-
setTimeout(() => {
|
|
562
|
-
toastElement.classList.remove('ts-toast-slide-out');
|
|
563
|
-
if (toastElement.parentNode) toastElement.parentNode.removeChild(toastElement);
|
|
564
|
-
}, 500);
|
|
792
|
+
toastElement._managedByLoading = false;
|
|
793
|
+
toastElement.close();
|
|
565
794
|
}
|
|
566
795
|
};
|
|
567
796
|
};
|
|
@@ -583,6 +812,41 @@ const toast = function (message, options = {}) {
|
|
|
583
812
|
});
|
|
584
813
|
};
|
|
585
814
|
|
|
815
|
+
// Wraps an async action: one loading toast that becomes the success or error
|
|
816
|
+
// message, instead of hand-rolling loading/update/catch at every call site.
|
|
817
|
+
toast.promise = function (promise, messages = {}, options = {}) {
|
|
818
|
+
const {
|
|
819
|
+
loading = 'Loading…',
|
|
820
|
+
success = 'Done',
|
|
821
|
+
error = 'Something went wrong'
|
|
822
|
+
} = messages;
|
|
823
|
+
|
|
824
|
+
const handle = toast.loading(loading, options);
|
|
825
|
+
// Messages may be functions so they can name what actually came back.
|
|
826
|
+
const text = (msg, value) => (typeof msg === 'function' ? msg(value) : msg);
|
|
827
|
+
|
|
828
|
+
return Promise.resolve(promise).then(
|
|
829
|
+
(value) => {
|
|
830
|
+
handle.update(text(success, value), { type: 'success', duration: options.duration });
|
|
831
|
+
return value;
|
|
832
|
+
},
|
|
833
|
+
(err) => {
|
|
834
|
+
handle.update(text(error, err), { type: 'error', duration: options.duration });
|
|
835
|
+
throw err; // the caller still owns the failure
|
|
836
|
+
}
|
|
837
|
+
);
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
// Options applied to every toast unless the call overrides them.
|
|
841
|
+
toast.defaults = {};
|
|
842
|
+
|
|
843
|
+
// Close every toast currently on screen. Confirm dialogs settle as a cancel.
|
|
844
|
+
toast.dismissAll = function () {
|
|
845
|
+
document.querySelectorAll('.ts-toast').forEach((el) => {
|
|
846
|
+
if (typeof el.close === 'function') el.close();
|
|
847
|
+
});
|
|
848
|
+
};
|
|
849
|
+
|
|
586
850
|
// Expose globally for CDN / browser usage
|
|
587
851
|
if (typeof window !== 'undefined') {
|
|
588
852
|
window.toast = toast;
|