melt
Version:
The next generation of Melt UI. Built for Svelte 5.
240 lines (239 loc) • 7 kB
JavaScript
import { extract } from "../utils/extract";
import { createBuilderMetadata, createId } from "../utils/identifiers";
import { SvelteMap } from "svelte/reactivity";
import { isHtmlElement, isTouch } from "../utils/is";
import { AnimationFrames } from "../utils/animation-frames.svelte";
import { safelyHidePopover, safelyShowPopover } from "../utils/popover";
import { useEventListener, watch } from "runed";
const toasterMeta = createBuilderMetadata("toaster", ["root"]);
const toastMeta = createBuilderMetadata("toaster-toast", [
"content",
"title",
"description",
"close",
]);
export class Toaster {
// Props
#props;
ids = toasterMeta.createIds();
closeDelay = $derived(extract(this.#props.closeDelay, 5000));
type = $derived(extract(this.#props.type, "polite"));
hover = $derived(extract(this.#props.hover, "pause"));
tabHidden = $derived(extract(this.#props.tabHidden, "pause-all"));
// State
#toastsMap = new SvelteMap();
/** The active toasts. */
toasts = $derived(Array.from(this.#toastsMap.values()));
#subscribers = 0;
constructor(props = {}) {
this.#props = props;
}
/**
* Adds a toast.
*/
addToast = (props) => {
const propsWithDefaults = {
closeDelay: this.closeDelay,
type: this.type,
...props,
};
const id = props.id ?? createId();
const toast = new Toast({
toaster: this,
id,
...propsWithDefaults,
});
this.#toastsMap.set(id, toast);
return toast;
};
/**
* Removes the toast with the specified ID.
* @param id The id of the toast.
*/
removeToast = (id) => {
const toast = this.#toastsMap.get(id);
if (!toast)
return;
this.#toastsMap.delete(id);
toast.cleanup();
};
/**
* Updates a toast's data.
* @param id The id of the toast.
* @param data The updated data.
*/
updateToast = (args) => {
const toast = this.#toastsMap.get(args.id);
if (!toast)
return;
toast.data = args.data;
if (typeof args.closeDelay === "number")
toast.closeDelay = args.closeDelay;
if (typeof args.type === "string")
toast.type = args.type;
};
/**
* Pauses all toasts.
*/
pauseAll() {
this.toasts.forEach((t) => t.pause());
}
/**
* Resumes all toasts countdowns.
*/
resumeAll() {
this.toasts.forEach((t) => t.resume());
}
#onVisibilityChange() {
if (this.tabHidden === "pause-all" && document.hidden) {
this.pauseAll();
}
else {
this.resumeAll();
}
}
/**
* Spread attributes for the container of the toasts.
*/
get root() {
if ($effect.tracking()) {
this.#subscribers++;
$effect(() => {
return () => {
this.#subscribers--;
};
});
watch(() => this.#subscribers, (s) => {
if (s !== 1)
return;
$effect(() => {
const el = document.getElementById(this.ids.root);
if (!isHtmlElement(el))
return;
if (!this.toasts.length) {
safelyHidePopover(el);
return;
}
safelyShowPopover(el);
});
});
useEventListener(document, "visibilitychange", this.#onVisibilityChange.bind(this));
}
return {
[toasterMeta.dataAttrs.root]: "",
id: this.ids.root,
popover: "manual",
};
}
}
class Toast {
/** Props */
#props;
toaster = $derived(this.#props.toaster);
id = $derived(this.#props.id);
/** The original data you passed to the `addToast` function. */
data = $state();
closeDelay = $derived(this.#props.closeDelay);
type = $derived(this.#props.type);
/** State */
ids = toastMeta.createIds();
createdAt;
#frames;
timeElapsed = $state(0);
percentage = $derived((100 * this.timeElapsed) / this.closeDelay);
constructor(props) {
this.#props = props;
this.data = props.data;
this.createdAt = performance.now();
if (!this.closeDelay)
return;
this.#frames = new AnimationFrames(({ delta }) => {
this.timeElapsed += delta;
if (this.timeElapsed > this.closeDelay) {
this.removeSelf();
}
});
}
/** Remove toast. */
removeSelf = () => {
this.toaster.removeToast(this.id);
};
/** @internal */
cleanup = () => {
this.#frames?.stop();
};
/** Pause toast timer. */
pause = () => {
this.#frames?.stop();
};
/** Reset toast timer. */
reset = () => {
this.timeElapsed = 0;
this.#frames?.start();
};
/** Resume toast timer */
resume = () => {
this.#frames?.start();
};
/**
* Spread attributes for a toast's content (wrapper) element.
*/
get content() {
return {
[toastMeta.dataAttrs.content]: "",
id: this.ids.content,
role: "alert",
"aria-labelledby": this.ids.title,
"aria-describedby": this.ids.description,
"aria-live": this.type ?? this.toaster.type,
tabindex: -1,
onpointerenter: (e) => {
if (isTouch(e))
return;
if (this.toaster.hover === "pause") {
this.pause();
}
else if (this.toaster.hover === "pause-all") {
for (const toast of this.toaster.toasts) {
toast.pause();
}
}
},
onpointerleave: (e) => {
if (isTouch(e))
return;
if (this.toaster.hover === "pause") {
this.resume();
}
else if (this.toaster.hover === "pause-all") {
for (const toast of this.toaster.toasts) {
toast.resume();
}
}
},
};
}
/**
* Spread attributes for a toast's title element.
*/
get title() {
return { id: this.ids.title };
}
/**
* Soread attributes for a toast's description element.
*/
get description() {
return { id: this.ids.description };
}
/**
* Spread attributes for a toast's close button element.
*/
get close() {
return {
[toastMeta.dataAttrs.close]: "",
onclick: () => {
this.removeSelf();
},
};
}
}