UNPKG

@zag-js/toast

Version:

Core logic for the toast widget implemented as a state machine

262 lines (261 loc) • 7.77 kB
// src/toast.machine.ts import { createGuards, createMachine } from "@zag-js/core"; import { raf } from "@zag-js/dom-query"; import { ensureProps, setRafTimeout } from "@zag-js/utils"; import * as dom from "./toast.dom.mjs"; import { getToastDuration } from "./toast.utils.mjs"; var { not } = createGuards(); var machine = createMachine({ props({ props }) { ensureProps(props, ["id", "type", "parent", "removeDelay"], "toast"); return { closable: true, ...props, translations: { closeTriggerLabel: "Dismiss notification", ...props.translations }, duration: getToastDuration(props.duration, props.type) }; }, initialState({ prop }) { const persist = prop("type") === "loading" || prop("duration") === Infinity; return persist ? "visible:persist" : "visible"; }, context({ prop, bindable }) { return { remainingTime: bindable(() => ({ defaultValue: getToastDuration(prop("duration"), prop("type")) })), createdAt: bindable(() => ({ defaultValue: Date.now() })), mounted: bindable(() => ({ defaultValue: false })), initialHeight: bindable(() => ({ defaultValue: 0 })) }; }, refs() { return { closeTimerStartTime: Date.now(), lastCloseStartTimerStartTime: 0 }; }, computed: { zIndex: ({ prop }) => { const toasts = prop("parent").context.get("toasts"); const index = toasts.findIndex((toast) => toast.id === prop("id")); return toasts.length - index; }, height: ({ prop }) => { const heights = prop("parent").context.get("heights"); const height = heights.find((height2) => height2.id === prop("id")); return height?.height ?? 0; }, heightIndex: ({ prop }) => { const heights = prop("parent").context.get("heights"); return heights.findIndex((height) => height.id === prop("id")); }, frontmost: ({ prop }) => prop("index") === 0, heightBefore: ({ prop }) => { const heights = prop("parent").context.get("heights"); const heightIndex = heights.findIndex((height) => height.id === prop("id")); return heights.reduce((prev, curr, reducerIndex) => { if (reducerIndex >= heightIndex) return prev; return prev + curr.height; }, 0); }, shouldPersist: ({ prop }) => prop("type") === "loading" || prop("duration") === Infinity }, watch({ track, prop, send }) { track([() => prop("message")], () => { const message = prop("message"); if (message) send({ type: message, src: "programmatic" }); }); track([() => prop("type"), () => prop("duration")], () => { send({ type: "UPDATE" }); }); }, on: { UPDATE: [ { guard: "shouldPersist", target: "visible:persist", actions: ["resetCloseTimer"] }, { target: "visible:updating", actions: ["resetCloseTimer"] } ], MEASURE: { actions: ["measureHeight"] } }, entry: ["setMounted", "measureHeight", "invokeOnVisible"], effects: ["trackHeight"], states: { "visible:updating": { tags: ["visible", "updating"], effects: ["waitForNextTick"], on: { SHOW: { target: "visible" } } }, "visible:persist": { tags: ["visible", "paused"], on: { RESUME: { guard: not("isLoadingType"), target: "visible", actions: ["setCloseTimer"] }, DISMISS: { target: "dismissing" } } }, visible: { tags: ["visible"], effects: ["waitForDuration"], on: { DISMISS: { target: "dismissing" }, PAUSE: { target: "visible:persist", actions: ["syncRemainingTime"] } } }, dismissing: { entry: ["invokeOnDismiss"], effects: ["waitForRemoveDelay"], on: { REMOVE: { target: "unmounted", actions: ["notifyParentToRemove"] } } }, unmounted: { entry: ["invokeOnUnmount"] } }, implementations: { effects: { waitForRemoveDelay({ prop, send }) { return setRafTimeout(() => { send({ type: "REMOVE", src: "timer" }); }, prop("removeDelay")); }, waitForDuration({ send, context, computed }) { if (computed("shouldPersist")) return; return setRafTimeout(() => { send({ type: "DISMISS", src: "timer" }); }, context.get("remainingTime")); }, waitForNextTick({ send }) { return setRafTimeout(() => { send({ type: "SHOW", src: "timer" }); }, 0); }, trackHeight({ scope, prop }) { let cleanup; raf(() => { const rootEl = dom.getRootEl(scope); if (!rootEl) return; const syncHeight = () => { const height = measureLayoutHeight(rootEl); const item = { id: prop("id"), height }; setHeight(prop("parent"), item); }; const win = scope.getWin(); const observer = new win.MutationObserver(syncHeight); observer.observe(rootEl, { childList: true, subtree: true, characterData: true }); cleanup = () => observer.disconnect(); }); return () => cleanup?.(); } }, guards: { isLoadingType: ({ prop }) => prop("type") === "loading", shouldPersist: ({ computed }) => computed("shouldPersist") }, actions: { setMounted({ context }) { raf(() => { context.set("mounted", true); }); }, measureHeight({ scope, prop, context }) { queueMicrotask(() => { const rootEl = dom.getRootEl(scope); if (!rootEl) return; const height = measureLayoutHeight(rootEl); context.set("initialHeight", height); const item = { id: prop("id"), height }; setHeight(prop("parent"), item); }); }, setCloseTimer({ refs }) { refs.set("closeTimerStartTime", Date.now()); }, resetCloseTimer({ context, refs, prop }) { refs.set("closeTimerStartTime", Date.now()); context.set("remainingTime", getToastDuration(prop("duration"), prop("type"))); }, syncRemainingTime({ context, refs }) { context.set("remainingTime", (prev) => { const closeTimerStartTime = refs.get("closeTimerStartTime"); const elapsedTime = Date.now() - closeTimerStartTime; refs.set("lastCloseStartTimerStartTime", Date.now()); return prev - elapsedTime; }); }, notifyParentToRemove({ prop }) { const parent = prop("parent"); parent.send({ type: "TOAST.REMOVE", id: prop("id") }); }, invokeOnDismiss({ prop, event }) { prop("onStatusChange")?.({ status: "dismissing", src: event.src }); }, invokeOnUnmount({ prop }) { prop("onStatusChange")?.({ status: "unmounted" }); }, invokeOnVisible({ prop }) { prop("onStatusChange")?.({ status: "visible" }); } } } }); function measureLayoutHeight(el) { const prevHeight = el.style.height; el.style.height = "auto"; const height = el.offsetHeight; el.style.height = prevHeight; return height; } function setHeight(parent, item) { const { id, height } = item; parent.context.set("heights", (prev) => { const alreadyExists = prev.find((i) => i.id === id); if (!alreadyExists) { return [{ id, height }, ...prev]; } else { return prev.map((i) => i.id === id ? { ...i, height } : i); } }); } export { machine };