Newer
Older
vmk-ui-kit / src / vue / components / GnToastProvider.js
/**
 * GnToastProvider - Renderless-ish toast host for Vue.
 *
 * Renders slot content plus a single toast at the bottom-right.
 * Exposes an imperative API via provide/inject and component expose.
 */

import { defineComponent, h, provide, ref, nextTick, getCurrentInstance } from "vue";
import { cx, iconNode, normalizeVariant } from "../utils.js";
import { toastKey } from "../composables/toast-context.js";

const iconByVariant = {
	info: "ph-info",
	success: "ph-check-circle",
	warning: "ph-warning",
	danger: "ph-warning-octagon",
	error: "ph-warning-octagon",
	primary: "ph-info",
	secondary: "ph-info"
};

export default defineComponent({
	name: "GnToastProvider",
	props: {
		lifetime: { type: Number, default: 4000 }
	},
	setup(props, { slots, expose }) {
		const toast = ref(null);
		const closing = ref(false);
		const showing = ref(false);
		const hovered = ref(false);
		let timer = null;
		let closeTimer = null;
		let startedAt = 0;
		let remaining = 0;

		const clearTimers = () => {
			window.clearTimeout(timer);
			window.clearTimeout(closeTimer);
			timer = null;
			closeTimer = null;
		};

		const dismiss = () => {
			clearTimers();
			closing.value = true;
			showing.value = false;
			closeTimer = window.setTimeout(() => {
				toast.value = null;
				closing.value = false;
			}, 300);
		};

		const close = () => {
			clearTimers();
			closing.value = false;
			showing.value = false;
			hovered.value = false;
			toast.value = null;
			remaining = 0;
		};

		const scheduleClose = delay => {
			clearTimers();
			startedAt = Date.now();
			remaining = delay;
			timer = window.setTimeout(() => {
				remaining = 0;
				dismiss();
			}, delay);
		};

		const pause = () => {
			if (!timer) return;
			remaining = Math.max(0, remaining - (Date.now() - startedAt));
			window.clearTimeout(timer);
			timer = null;
			hovered.value = true;
		};

		const resume = () => {
			hovered.value = false;
			if (!toast.value || remaining <= 0) return;
			scheduleClose(remaining);
		};

		const show = options => {
			const alone = options.alone !== undefined ? options.alone : true;
			if (alone && toast.value) {
				dismiss();
			}

			clearTimers();
			closing.value = false;
			showing.value = false;
			hovered.value = false;
			remaining = 0;

			const variant = normalizeVariant(options.variant || options.type || "info", "info");
			const lifetime = options.lifetime !== undefined ? options.lifetime : props.lifetime;
			toast.value = {
				id: Date.now(),
				variant: variant === "error" ? "danger" : variant,
				title: options.title || "",
				text: options.text || options.message || "",
				icon: options.icon || iconByVariant[variant] || iconByVariant.info,
				lifetime
			};

			if (lifetime > 0) {
				scheduleClose(lifetime);
			}

			nextTick(() => {
				requestAnimationFrame(() => {
					showing.value = true;
				});
			});
		};

		const api = {
			show,
			close,
			info: options => show({ ...options, variant: "info" }),
			success: options => show({ ...options, variant: "success" }),
			warning: options => show({ ...options, variant: "warning" }),
			danger: options => show({ ...options, variant: "danger" }),
			error: options => show({ ...options, variant: "danger" })
		};

		provide(toastKey, api);
		expose(api);

		const instance = getCurrentInstance();
		if (instance?.appContext?.app?.config?.globalProperties) {
			instance.appContext.app.config.globalProperties.$vmkToasts = api;
		}

		const toastClass = () => {
			if (closing.value) return "a-hide";
			if (showing.value) return "a-show";
			return "";
		};

		return () => [
			slots.default?.(),
			toast.value && h("div", {
				class: cx("toast", toastClass(), `toast-${toast.value.variant}`),
				role: "alert",
				onMouseenter: pause,
				onMouseleave: resume
			}, [
				h("div", { class: "toast-content" }, [
					h("div", { class: "toast-header" }, [
						iconNode(toast.value.icon),
						toast.value.title
					]),
					toast.value.text && h("p", { class: "toast-text" }, toast.value.text)
				]),
				h("button", {
					class: "btn-icon toast-close",
					type: "button",
					"aria-label": "Close",
					onClick: close
				}, [iconNode("ph-x")]),
				toast.value.lifetime > 0 && h("div", { class: "toast-progress" }, [
					h("div", {
						class: "toast-progress-bar",
						style: { animationDuration: `${toast.value.lifetime}ms` }
					})
				])
			])
		];
	}
});