svelte-daisy-toaster
Version:
Svelte DaisyUI Toast Component
383 lines (346 loc) • 9.03 kB
JavaScript
import { getContext, onDestroy, setContext } from 'svelte';
/**
* @typedef {"default" | "info" | "success" | "warning" | "error" | "loading"} ToastType
*/
/**
* @typedef {"outline" | "dash" | "soft"} ToastStyle
*/
/**
* @typedef {{
* text?: string,
* class?: string,
* callback?: (toast: Toast) => void
* }} ToastButton
*/
/**
* @typedef {{
* id: string,
* type: ToastType,
* message: string,
* title?: string,
* position?: string,
* style?: ToastStyle,
* button?: ToastButton,
* showCloseButton?: boolean,
* customClass?: string,
* visible: boolean,
* _toastState: ToastState,
* success: (message?: string, options?: ToastOptions) => void,
* error: (message?: string, options?: ToastOptions) => void,
* info: (message?: string, options?: ToastOptions) => void,
* warning: (message?: string, options?: ToastOptions) => void,
* update: (partial: Partial<ToastOptions>) => void,
* dismiss: () => void
* }} Toast
*/
/**
* @typedef {{
* type?: ToastType,
* message?: string,
* title?: string,
* durationMs?: number,
* position?: string,
* style?: ToastStyle,
* button?: ToastButton,
* showCloseButton?: boolean,
* customClass?: string
* }} ToastOptions
*/
/**
* @typedef {Toast} ToastController
*/
export class ToastState {
/** @type {Toast[]} */
toasts = $state([]);
/** @type {Record<string, number>} */
widths = $state({});
/** @type {Record<string, number>} */
heights = $state({});
/** @type {Map<string, any>} */
toastToTimeoutMap = new Map();
/** @type {Map<string, number>} */
toastToStartTimeMap = new Map();
/** @type {Map<string, number>} */
toastToRemainingTimeMap = new Map();
constructor() {
onDestroy(() => {
for (const timeout of this.toastToTimeoutMap.values()) {
clearTimeout(timeout);
}
this.toastToRemainingTimeMap.clear();
this.toastToStartTimeMap.clear();
this.toastToTimeoutMap.clear();
});
}
/**
* Create and add a new toast
* @param {ToastOptions} [options]
* @returns {Toast}
*/
add({
type = 'default',
message = '',
title,
durationMs = 5000,
position = 'top-right',
style,
button,
showCloseButton = false,
customClass
} = {}) {
const id = crypto.randomUUID();
/** @type {Toast} */
const toast = {
id,
type,
message,
title,
position,
style,
button,
showCloseButton,
customClass,
visible: true,
/** @type {ToastState} */ _toastState: this // reference to toast state for callbacks
};
// Add controller methods
Object.assign(toast, createToastController(this, toast));
this.toasts.push(toast);
// Only auto-dismiss when duration is a positive number
if (typeof durationMs === 'number' && durationMs > 0 && Number.isFinite(durationMs)) {
this.toastToRemainingTimeMap.set(id, durationMs);
this.toastToStartTimeMap.set(id, Date.now());
this.toastToTimeoutMap.set(
id,
setTimeout(() => {
this.startRemoval(id);
}, durationMs)
);
}
return toast;
}
/**
* Update an existing toast by id.
* If durationMs is provided and > 0, (re)schedule auto-dismiss from now.
*/
/**
* Update an existing toast by id
* @param {string} id
* @param {Partial<Omit<Toast, 'id'|'_toastState'>> & { durationMs?: number }} [partial]
* @returns {void}
*/
update(id, partial = {}) {
const toast = this.toasts.find((t) => t.id === id);
if (!toast) return;
// Merge fields
Object.assign(toast, partial);
// Reset any existing timeout
const prev = this.toastToTimeoutMap.get(id);
if (prev) {
clearTimeout(prev);
this.toastToTimeoutMap.delete(id);
}
const { durationMs } = partial;
if (typeof durationMs === 'number' && durationMs > 0 && Number.isFinite(durationMs)) {
this.toastToRemainingTimeMap.set(id, durationMs);
this.toastToStartTimeMap.set(id, Date.now());
this.toastToTimeoutMap.set(
id,
setTimeout(() => {
this.startRemoval(id);
}, durationMs)
);
}
}
/**
* Pause toast timeout
* @param {string} id
*/
pause(id) {
const timeout = this.toastToTimeoutMap.get(id);
if (timeout) {
clearTimeout(timeout);
this.toastToTimeoutMap.delete(id);
const startTime = this.toastToStartTimeMap.get(id);
const remaining = this.toastToRemainingTimeMap.get(id);
if (startTime !== undefined && remaining !== undefined) {
const elapsed = Date.now() - startTime;
const newRemaining = Math.max(0, remaining - elapsed);
this.toastToRemainingTimeMap.set(id, newRemaining);
}
}
}
/**
* Resume toast timeout
* @param {string} id
*/
resume(id) {
const remaining = this.toastToRemainingTimeMap.get(id);
if (remaining !== undefined && remaining > 0) {
this.toastToStartTimeMap.set(id, Date.now());
this.toastToTimeoutMap.set(
id,
setTimeout(() => {
this.startRemoval(id);
}, remaining)
);
} else if (remaining === 0) {
this.startRemoval(id);
}
}
/**
* Start removal animation then remove
* @param {string} id
*/
startRemoval(id) {
// set visible to false to trigger animation
const toast = this.toasts.find((t) => t.id === id);
if (toast) {
toast.visible = false;
// wait for animation to complete
setTimeout(() => {
this.remove(id);
}, 650);
}
}
/**
* Remove a toast immediately
* @param {string} id
*/
remove(id) {
const timeout = this.toastToTimeoutMap.get(id);
if (timeout) {
clearTimeout(timeout);
this.toastToTimeoutMap.delete(id);
}
delete this.widths[id];
delete this.heights[id];
this.toastToTimeoutMap.delete(id);
this.toastToStartTimeMap.delete(id);
this.toastToRemainingTimeMap.delete(id);
const index = this.toasts.findIndex((toast) => toast.id === id);
if (index !== -1) {
this.toasts.splice(index, 1);
}
}
}
let currentToastState;
const TOAST_KEY = Symbol('TOAST');
export function setToastState() {
currentToastState = new ToastState();
return setContext(TOAST_KEY, currentToastState);
}
export function getToastState() {
return getContext(TOAST_KEY);
}
/**
* Create a toast notification
* @param {string | ToastOptions} messageOrOptions
* @param {ToastOptions} [options]
* @returns {Toast | undefined}
*/
export function toast(messageOrOptions, options = {}) {
/** @type {ToastOptions} */
let finalOptions;
if (typeof messageOrOptions === 'string') {
finalOptions = { message: messageOrOptions, type: 'default', ...options };
} else {
finalOptions = messageOrOptions;
}
// priority get from context - safe when calling in child component
let ts;
try {
ts = getContext(TOAST_KEY);
} catch {
/* ignore */
}
ts ??= currentToastState;
if (ts) {
return ts.add(finalOptions);
} else {
console.warn('ToastState not initialized, toast skipped:', finalOptions.message);
}
}
/**
* Creates a controller object for a toast
* @param {ToastState} ts
* @param {Toast} t
* @returns {ToastController}
*/
function createToastController(ts, t) {
const id = t.id;
return {
id,
success: (msg, extra = {}) =>
ts.update(id, {
type: 'success',
message: msg ?? t.message,
durationMs: 2000,
...extra
}),
error: (msg, extra = {}) =>
ts.update(id, {
type: 'error',
message: msg ?? t.message,
durationMs: 3000,
...extra
}),
info: (msg, extra = {}) =>
ts.update(id, {
type: 'info',
message: msg ?? t.message,
durationMs: 2500,
...extra
}),
warning: (msg, extra = {}) =>
ts.update(id, {
type: 'warning',
message: msg ?? t.message,
durationMs: 2500,
...extra
}),
update: (partial) => ts.update(id, partial),
dismiss: () => ts.startRemoval(id)
};
}
// shortcut: toast.success('...'), v.v. with flexible options
['info', 'success', 'warning', 'error'].forEach((t) => {
/**
* Shortcut helper that supports both legacy params and an options object.
*
* Legacy:
* toast.success(message, durationMs?, position?, style?, title?)
* Options object (second arg):
* toast.success(message, { durationMs, position, style, title, ... })
* Or full options object as first param:
* toast.success({ message, durationMs, ... })
*/
toast[t] = (messageOrOptions, d, pos, s, title) => {
// Case 1: first arg is an options object
if (
messageOrOptions &&
typeof messageOrOptions === 'object' &&
!Array.isArray(messageOrOptions)
) {
return toast({ type: t, ...messageOrOptions });
}
// Case 2: second arg is an options object
if (d && typeof d === 'object' && !Array.isArray(d)) {
return toast({ type: t, message: String(messageOrOptions ?? ''), ...d });
}
// Case 3: legacy positional params
return toast({
type: t,
message: String(messageOrOptions ?? ''),
durationMs: d,
position: pos,
style: s,
title
});
};
});
// loading helper that returns an object with controls
toast.loading = (message = 'Loading...', options = {}) => {
// durationMs: 0 to keep persistent until manually dismissed or updated
return toast({ type: 'loading', message, durationMs: 0, ...options });
};