@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/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.4.0";
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
- : (isConfirm ? 'ts-toast-zoom-in' : (position.startsWith('top') ? 'ts-toast-slide-top'
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.innerHTML = message; // Allow HTML content in message
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
- }, 2000); // Simulate a loading period of 2 seconds
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
- // Auto remove after the duration (skip for confirm mode or when duration <= 0)
356
- if (!isConfirm && duration > 0) {
357
- const autoRemove = setTimeout(() => {
358
- removeWithAnimation(toastElement, () => {
359
- if (onDismiss && typeof onDismiss === 'function') onDismiss(toastElement);
360
- });
361
- }, duration);
362
- toastElement._autoRemove = autoRemove;
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
- if (toastElement._autoRemove) clearTimeout(toastElement._autoRemove); // Clear the auto-remove timeout
369
- removeWithAnimation(toastElement, () => {
370
- if (onClick && typeof onClick === 'function') onClick(toastElement);
371
- if (onDismiss && typeof onDismiss === 'function') onDismiss(toastElement);
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
- if (Math.abs(touchStartX - touchEndX) > 50) { // Swipe distance threshold
388
- if (toastElement._autoRemove) clearTimeout(toastElement._autoRemove);
389
- removeWithAnimation(toastElement, () => {
390
- if (onDismiss && typeof onDismiss === 'function') onDismiss(toastElement);
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
- onClick = null, // Custom onClick event listener
424
- onShow = null, // Custom onShow event listener
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
- // Append the new icon immediately (inside the content row for confirm dialogs)
472
- const contentRow = toastElement.querySelector('.ts-toast-content');
473
- (contentRow || toastElement).appendChild(iconElement);
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); // Simulate loader completion after 2 seconds
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
- // Set the auto-remove timer again to ensure toast disappears after the duration
491
- const autoRemove = setTimeout(() => {
492
- const removeWithAnimation = (el, cb) => {
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
- removeWithAnimation(toastElement, () => {
504
- if (onDismiss && typeof onDismiss === 'function') onDismiss(toastElement);
505
- });
506
- }, duration);
507
-
508
- toastElement._autoRemove = autoRemove; // Re-set the auto-remove timer
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
- if (toastElement._autoRemove) clearTimeout(toastElement._autoRemove);
558
- toastElement.classList.add('ts-toast-slide-out');
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;