@zag-js/toast
Version:
Core logic for the toast widget implemented as a state machine
262 lines (261 loc) • 7.77 kB
JavaScript
// 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
};