UNPKG

melt

Version:

The next generation of Melt UI. Built for Svelte 5.

240 lines (239 loc) 7 kB
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(); }, }; } }