UNPKG

@zag-js/presence

Version:

Core logic for the presence widget implemented as a state machine

172 lines (171 loc) • 5.44 kB
// src/presence.machine.ts import { createMachine } from "@zag-js/core"; import { getComputedStyle, getEventTarget, getWindow, nextTick, raf, setStyle } from "@zag-js/dom-query"; var machine = createMachine({ props({ props }) { return { ...props, present: !!props.present }; }, initialState({ prop }) { return prop("present") ? "mounted" : "unmounted"; }, refs() { return { node: null, styles: null }; }, context({ bindable }) { return { unmountAnimationName: bindable(() => ({ defaultValue: null })), prevAnimationName: bindable(() => ({ defaultValue: null })), present: bindable(() => ({ defaultValue: false })), initial: bindable(() => ({ sync: true, defaultValue: false })) }; }, exit: ["cleanupNode"], watch({ track, prop, send }) { track([() => prop("present")], () => { send({ type: "PRESENCE.CHANGED" }); }); }, on: { "NODE.SET": { actions: ["setupNode"] }, "PRESENCE.CHANGED": { actions: ["setInitial", "syncPresence"] } }, states: { mounted: { on: { UNMOUNT: { target: "unmounted", actions: ["clearPrevAnimationName", "invokeOnExitComplete"] }, "UNMOUNT.SUSPEND": { target: "unmountSuspended" } } }, unmountSuspended: { effects: ["trackAnimationEvents"], on: { MOUNT: { target: "mounted", actions: ["setPrevAnimationName"] }, UNMOUNT: { target: "unmounted", actions: ["clearPrevAnimationName", "invokeOnExitComplete"] } } }, unmounted: { on: { MOUNT: { target: "mounted", actions: ["setPrevAnimationName"] } } } }, implementations: { actions: { setInitial: ({ context }) => { if (context.get("initial")) return; queueMicrotask(() => { context.set("initial", true); }); }, invokeOnExitComplete: ({ prop, refs }) => { prop("onExitComplete")?.(); const node = refs.get("node"); if (!node) return; const win = getWindow(node); const event = new win.CustomEvent("exitcomplete", { bubbles: false }); node.dispatchEvent(event); }, setupNode: ({ refs, event }) => { if (refs.get("node") === event.node) return; refs.set("node", event.node); refs.set("styles", getComputedStyle(event.node)); }, cleanupNode: ({ refs }) => { refs.set("node", null); refs.set("styles", null); }, syncPresence: ({ context, refs, send, prop }) => { const presentProp = prop("present"); if (presentProp) { return send({ type: "MOUNT", src: "presence.changed" }); } const node = refs.get("node"); if (!presentProp && node?.ownerDocument.visibilityState === "hidden") { return send({ type: "UNMOUNT", src: "visibilitychange" }); } raf(() => { const animationName = getAnimationName(refs.get("styles")); context.set("unmountAnimationName", animationName); if (animationName === "none" || animationName === context.get("prevAnimationName") || refs.get("styles")?.display === "none" || refs.get("styles")?.animationDuration === "0s") { send({ type: "UNMOUNT", src: "presence.changed" }); } else { send({ type: "UNMOUNT.SUSPEND" }); } }); }, setPrevAnimationName: ({ context, refs }) => { raf(() => { context.set("prevAnimationName", getAnimationName(refs.get("styles"))); }); }, clearPrevAnimationName: ({ context }) => { context.set("prevAnimationName", null); } }, effects: { trackAnimationEvents: ({ context, refs, send, prop }) => { const node = refs.get("node"); if (!node) return; const onStart = (event) => { const target = event.composedPath?.()?.[0] ?? event.target; if (target === node) { context.set("prevAnimationName", getAnimationName(refs.get("styles"))); } }; const onEnd = (event) => { const animationName = getAnimationName(refs.get("styles")); const target = getEventTarget(event); if (target === node && animationName === context.get("unmountAnimationName") && !prop("present")) { send({ type: "UNMOUNT", src: "animationend" }); } }; const onCancel = (event) => { const target = getEventTarget(event); if (target === node && !prop("present")) { send({ type: "UNMOUNT", src: "animationcancel" }); } }; node.addEventListener("animationstart", onStart); node.addEventListener("animationcancel", onCancel); node.addEventListener("animationend", onEnd); const cleanupStyles = setStyle(node, { animationFillMode: "forwards" }); return () => { node.removeEventListener("animationstart", onStart); node.removeEventListener("animationcancel", onCancel); node.removeEventListener("animationend", onEnd); nextTick(() => cleanupStyles()); }; } } } }); function getAnimationName(styles) { return styles?.animationName || "none"; } export { machine };