@zag-js/tour
Version:
Core logic for the tour widget implemented as a state machine
639 lines (637 loc) • 21.9 kB
JavaScript
;
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/tour.machine.ts
var tour_machine_exports = {};
__export(tour_machine_exports, {
machine: () => machine
});
module.exports = __toCommonJS(tour_machine_exports);
var import_core = require("@zag-js/core");
var import_dismissable = require("@zag-js/dismissable");
var import_dom_query = require("@zag-js/dom-query");
var import_focus_trap = require("@zag-js/focus-trap");
var import_interact_outside = require("@zag-js/interact-outside");
var import_popper = require("@zag-js/popper");
var import_utils = require("@zag-js/utils");
var dom = __toESM(require("./tour.dom.js"));
var import_rect = require("./utils/rect.js");
var import_step = require("./utils/step.js");
var { and } = (0, import_core.createGuards)();
var machine = (0, import_core.createMachine)({
props({ props }) {
return {
preventInteraction: false,
closeOnInteractOutside: true,
closeOnEscape: true,
keyboardNavigation: true,
spotlightOffset: { x: 10, y: 10 },
spotlightRadius: 4,
...props,
translations: {
nextStep: "next step",
prevStep: "previous step",
close: "close tour",
progressText: ({ current, total }) => `${current + 1} of ${total}`,
skip: "skip tour",
...props.translations
}
};
},
initialState() {
return "tourInactive";
},
context({ prop, bindable, getContext }) {
return {
steps: bindable(() => ({
defaultValue: prop("steps") ?? [],
onChange(value) {
prop("onStepsChange")?.({ steps: value });
}
})),
stepId: bindable(() => ({
defaultValue: prop("stepId"),
sync: true,
onChange(value) {
const context = getContext();
const steps = context.get("steps");
const stepIndex = (0, import_step.findStepIndex)(steps, value);
const progress = (0, import_step.getProgress)(steps, stepIndex);
const complete = stepIndex == steps.length - 1;
prop("onStepChange")?.({ stepId: value, stepIndex, totalSteps: steps.length, complete, progress });
}
})),
resolvedTarget: bindable(() => ({
sync: true,
defaultValue: null
})),
targetRect: bindable(() => ({
defaultValue: { width: 0, height: 0, x: 0, y: 0 }
})),
boundarySize: bindable(() => ({
defaultValue: { width: 0, height: 0 }
})),
currentPlacement: bindable(() => ({
defaultValue: void 0
}))
};
},
computed: {
stepIndex: ({ context }) => (0, import_step.findStepIndex)(context.get("steps"), context.get("stepId")),
step: ({ context }) => (0, import_step.findStep)(context.get("steps"), context.get("stepId")),
hasNextStep: ({ context, computed }) => computed("stepIndex") < context.get("steps").length - 1,
hasPrevStep: ({ computed }) => computed("stepIndex") > 0,
isFirstStep: ({ computed }) => computed("stepIndex") === 0,
isLastStep: ({ context, computed }) => computed("stepIndex") === context.get("steps").length - 1,
progress: ({ context, computed }) => {
const effectiveLength = (0, import_step.getEffectiveSteps)(context.get("steps")).length;
return (computed("stepIndex") + 1) / effectiveLength;
}
},
// Watch for external stepId changes (via sync: true bindable).
// Internal changes set _internalChange flag to skip this.
watch({ track, context, refs, send }) {
track([() => context.get("stepId")], () => {
if (refs.get("_internalChange")) {
refs.set("_internalChange", false);
return;
}
const step = (0, import_step.findStep)(context.get("steps"), context.get("stepId"));
context.set("resolvedTarget", step?.target?.() ?? null);
syncTargetAttrsFromContext({ context, refs });
queueMicrotask(() => {
send({ type: "STEP.CHANGED" });
});
});
},
effects: ["trackBoundarySize"],
exit: ["cleanupAll"],
on: {
"STEPS.SET": {
actions: ["setSteps", "validateSteps"]
},
// External step change (from watch): cleans up previous effect
"STEP.CHANGED": [
{
guard: and("isValidStep", "hasResolvedTarget"),
target: "running.scrolling",
reenter: true,
actions: ["cleanupStepEffect"]
},
{
guard: and("isValidStep", "hasTarget"),
target: "running.resolving",
reenter: true,
actions: ["cleanupStepEffect"]
},
{
guard: and("isValidStep", "isWaitingStep"),
target: "running.waiting",
reenter: true,
actions: ["cleanupStepEffect"]
},
{
guard: "isValidStep",
target: "running.active",
reenter: true,
actions: ["cleanupStepEffect"]
}
],
// Internal step change (from performStepTransition/show): no effect cleanup
// because performStepTransition already cleaned up the previous effect
"STEP.ROUTE": [
{
guard: and("isValidStep", "hasResolvedTarget"),
target: "running.scrolling",
reenter: true
},
{
guard: and("isValidStep", "hasTarget"),
target: "running.resolving",
reenter: true
},
{
guard: and("isValidStep", "isWaitingStep"),
target: "running.waiting",
reenter: true
},
{
guard: "isValidStep",
target: "running.active",
reenter: true
}
]
},
states: {
tourInactive: {
tags: ["closed"],
entry: ["validateSteps"],
on: {
START: {
actions: ["setInitialStep", "invokeOnStart"]
}
}
},
running: {
initial: "resolving",
on: {
"STEP.SET": {
actions: ["setStep"]
},
"STEP.NEXT": {
actions: ["setNextStep"]
},
"STEP.PREV": {
actions: ["setPrevStep"]
},
DISMISS: [
{
guard: "isLastStep",
target: "tourInactive",
actions: ["cleanupAll", "invokeOnDismiss", "invokeOnComplete", "clearStep"]
},
{
target: "tourInactive",
actions: ["cleanupAll", "invokeOnDismiss", "clearStep"]
}
],
SKIP: {
target: "tourInactive",
actions: ["cleanupAll", "invokeOnSkip", "clearStep"]
}
},
states: {
resolving: {
tags: ["closed"],
effects: ["waitForTarget", "waitForTargetTimeout"],
on: {
"TARGET.NOT_FOUND": {
target: "tourInactive",
actions: ["invokeOnNotFound", "clearStep"]
},
"TARGET.RESOLVED": {
target: "scrolling",
actions: ["setResolvedTarget"]
}
}
},
scrolling: {
tags: ["open"],
entry: ["scrollToTarget"],
effects: [
"waitForScrollEnd",
"trapFocus",
"trackPlacement",
"trackDismissableBranch",
"trackInteractOutside",
"trackEscapeKeydown"
],
on: {
"SCROLL.END": {
target: "active"
}
}
},
waiting: {
tags: ["closed"]
},
active: {
tags: ["open"],
effects: [
"trapFocus",
"trackPlacement",
"trackDismissableBranch",
"trackInteractOutside",
"trackEscapeKeydown"
]
}
}
}
},
implementations: {
guards: {
isLastStep: ({ computed, context }) => computed("stepIndex") === context.get("steps").length - 1,
isValidStep: ({ context }) => context.get("stepId") != null,
hasTarget: ({ computed }) => computed("step")?.target != null,
hasResolvedTarget: ({ context }) => context.get("resolvedTarget") != null,
isWaitingStep: ({ computed }) => computed("step")?.type === "wait"
},
actions: {
scrollToTarget({ context }) {
const node = context.get("resolvedTarget");
node?.scrollIntoView({ behavior: "instant", block: "nearest", inline: "nearest" });
},
setSteps(params) {
const { event, context } = params;
context.set("steps", event.value);
},
setStep(params) {
const { event } = params;
if (event.value == null) return;
const steps = params.context.get("steps");
const idx = (0, import_utils.isString)(event.value) ? (0, import_step.findStepIndex)(steps, event.value) : event.value;
performStepTransition(params, idx);
},
clearStep({ context, refs }) {
refs.get("_targetCleanup")?.();
refs.set("_targetCleanup", void 0);
context.set("targetRect", { width: 0, height: 0, x: 0, y: 0 });
context.set("resolvedTarget", null);
refs.set("_internalChange", true);
context.set("stepId", null);
},
setInitialStep(params) {
const { context, event } = params;
const steps = context.get("steps");
if (steps.length === 0) return;
const idx = (0, import_utils.isString)(event.value) ? (0, import_step.findStepIndex)(steps, event.value) : event.value ?? 0;
performStepTransition(params, idx);
},
setNextStep(params) {
const steps = params.context.get("steps");
const idx = (0, import_utils.nextIndex)(steps, params.computed("stepIndex"));
performStepTransition(params, idx);
},
setPrevStep(params) {
const steps = params.context.get("steps");
const idx = (0, import_utils.prevIndex)(steps, params.computed("stepIndex"));
performStepTransition(params, idx);
},
invokeOnStart({ prop, context, computed }) {
prop("onStatusChange")?.({
status: "started",
stepId: context.get("stepId"),
stepIndex: computed("stepIndex")
});
},
invokeOnDismiss({ prop, context, computed }) {
prop("onStatusChange")?.({
status: "dismissed",
stepId: context.get("stepId"),
stepIndex: computed("stepIndex")
});
},
invokeOnComplete({ prop, context, computed }) {
prop("onStatusChange")?.({
status: "completed",
stepId: context.get("stepId"),
stepIndex: computed("stepIndex")
});
},
invokeOnSkip({ prop, context, computed }) {
prop("onStatusChange")?.({
status: "skipped",
stepId: context.get("stepId"),
stepIndex: computed("stepIndex")
});
},
invokeOnNotFound({ prop, context, computed }) {
prop("onStatusChange")?.({
status: "not-found",
stepId: context.get("stepId"),
stepIndex: computed("stepIndex")
});
},
setResolvedTarget({ context, event, computed }) {
const node = event.node ?? computed("step")?.target?.();
context.set("resolvedTarget", node ?? null);
},
cleanupAll({ refs }) {
refs.get("_targetCleanup")?.();
refs.set("_targetCleanup", void 0);
refs.set("_prevTarget", void 0);
refs.get("_effectCleanup")?.();
refs.set("_effectCleanup", void 0);
},
cleanupStepEffect({ refs }) {
refs.get("_effectCleanup")?.();
refs.set("_effectCleanup", void 0);
},
validateSteps({ context }) {
const ids = /* @__PURE__ */ new Set();
context.get("steps").forEach((step) => {
if (ids.has(step.id)) {
throw new Error(`[zag-js/tour] Duplicate step id: ${step.id}`);
}
if (step.target == null && step.type == null) {
throw new Error(`[zag-js/tour] Step ${step.id} has no target or type. At least one of those is required.`);
}
ids.add(step.id);
});
}
},
effects: {
waitForScrollEnd({ send }) {
const id = setTimeout(() => {
send({ type: "SCROLL.END" });
}, 100);
return () => clearTimeout(id);
},
waitForTargetTimeout({ send }) {
const id = setTimeout(() => {
send({ type: "TARGET.NOT_FOUND" });
}, 3e3);
return () => clearTimeout(id);
},
waitForTarget({ scope, computed, send }) {
const step = computed("step");
if (!step) return;
const targetEl = step.target;
const win = scope.getWin();
const rootNode = scope.getRootNode();
const observer = new win.MutationObserver(() => {
const node = targetEl?.();
if (node) {
send({ type: "TARGET.RESOLVED", node });
observer.disconnect();
}
});
observer.observe(rootNode, {
childList: true,
subtree: true,
characterData: true
});
return () => {
observer.disconnect();
};
},
trackBoundarySize({ context, scope }) {
const win = scope.getWin();
const doc = scope.getDoc();
const onResize = () => {
const width = visualViewport?.width ?? win.innerWidth;
const height = doc.documentElement.scrollHeight;
context.set("boundarySize", { width, height });
};
onResize();
const viewport = win.visualViewport ?? win;
viewport.addEventListener("resize", onResize);
return () => viewport.removeEventListener("resize", onResize);
},
trackEscapeKeydown({ scope, send, prop }) {
if (!prop("closeOnEscape")) return;
const doc = scope.getDoc();
const onKeyDown = (event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
send({ type: "DISMISS", src: "esc" });
}
};
doc.addEventListener("keydown", onKeyDown, true);
return () => {
doc.removeEventListener("keydown", onKeyDown, true);
};
},
trackInteractOutside({ context, computed, scope, send, prop }) {
const step = computed("step");
if (step == null) return;
const contentEl = () => dom.getContentEl(scope);
return (0, import_interact_outside.trackInteractOutside)(contentEl, {
defer: true,
exclude(target) {
return (0, import_dom_query.contains)(step.target?.(), target);
},
onFocusOutside(event) {
prop("onFocusOutside")?.(event);
if (!prop("closeOnInteractOutside")) {
event.preventDefault();
}
},
onPointerDownOutside(event) {
prop("onPointerDownOutside")?.(event);
const isWithin = (0, import_rect.isEventInRect)(context.get("targetRect"), event.detail.originalEvent);
if (isWithin) {
event.preventDefault();
return;
}
if (!prop("closeOnInteractOutside")) {
event.preventDefault();
}
},
onInteractOutside(event) {
prop("onInteractOutside")?.(event);
if (event.defaultPrevented) return;
send({ type: "DISMISS", src: "interact-outside" });
}
});
},
trackDismissableBranch({ computed, scope }) {
const step = computed("step");
if (step == null) return;
const contentEl = () => dom.getContentEl(scope);
return (0, import_dismissable.trackDismissableBranch)(contentEl, { defer: true });
},
trapFocus({ computed, scope, context }) {
const step = computed("step");
if (step == null) return;
const contentEl = () => dom.getContentEl(scope);
const targetEl = () => context.get("resolvedTarget");
return (0, import_focus_trap.trapFocus)([contentEl, targetEl], {
escapeDeactivates: false,
allowOutsideClick: true,
preventScroll: true,
returnFocusOnDeactivate: false,
getShadowRoot: true
});
},
trackPlacement({ context, computed, scope, prop }) {
const step = computed("step");
if (step == null) return;
context.set("currentPlacement", step.placement ?? "bottom");
if ((0, import_step.isDialogStep)(step)) {
return dom.syncZIndex(scope);
}
if (!(0, import_step.isTooltipStep)(step)) {
return;
}
const positionerEl = () => dom.getPositionerEl(scope);
return (0, import_popper.getPlacement)(context.get("resolvedTarget"), positionerEl, {
defer: true,
placement: step.placement ?? "bottom",
strategy: "absolute",
gutter: 10,
offset: step.offset,
restoreStyles: true,
getAnchorRect(el) {
if (!(0, import_dom_query.isHTMLElement)(el)) return null;
const rect = el.getBoundingClientRect();
return (0, import_rect.offset)(rect, prop("spotlightOffset"));
},
onComplete(data) {
const { rects } = data.middlewareData;
context.set("currentPlacement", data.placement);
context.set("targetRect", rects.reference);
}
});
}
}
}
});
function syncTargetAttrsFromContext(params) {
const { context, refs, prop } = params;
const targetEl = context.get("resolvedTarget");
const prevTarget = refs.get("_prevTarget");
if (targetEl !== prevTarget) {
refs.get("_targetCleanup")?.();
refs.set("_targetCleanup", void 0);
}
if (!targetEl) {
refs.set("_prevTarget", null);
return;
}
if (targetEl === prevTarget) return;
if (prop?.("preventInteraction")) targetEl.inert = true;
targetEl.setAttribute("data-tour-highlighted", "");
refs.set("_targetCleanup", () => {
if (prop?.("preventInteraction")) targetEl.inert = false;
targetEl.removeAttribute("data-tour-highlighted");
});
refs.set("_prevTarget", targetEl);
}
function performStepTransition(params, idx) {
const { context, refs, send } = params;
const steps = context.get("steps");
const step = steps[idx];
if (!step) {
refs.set("_internalChange", true);
context.set("stepId", null);
return;
}
if ((0, import_utils.isEqual)(context.get("stepId"), step.id)) {
return;
}
refs.get("_effectCleanup")?.();
refs.set("_effectCleanup", void 0);
refs.get("_targetCleanup")?.();
refs.set("_targetCleanup", void 0);
if (step.effect) {
executeStepEffect(params, step, idx);
return;
}
const resolvedTarget = step.target?.() ?? null;
context.set("resolvedTarget", resolvedTarget);
refs.set("_internalChange", true);
context.set("stepId", step.id);
syncTargetAttrsFromContext(params);
send({ type: "STEP.ROUTE" });
}
function createEffectUtilities(params, step, idx) {
const { context, computed, refs, send, prop } = params;
const steps = context.get("steps");
return {
show: () => {
const resolvedTarget = step.target?.() ?? null;
context.set("resolvedTarget", resolvedTarget);
refs.set("_internalChange", true);
context.set("stepId", step.id);
syncTargetAttrsFromContext(params);
send({ type: "STEP.ROUTE" });
},
update: (data) => {
context.set("steps", (prev) => prev.map((s, i) => i === idx ? { ...s, ...data } : s));
},
next: () => {
const nextIdx = (0, import_utils.nextIndex)(steps, computed("stepIndex"));
performStepTransition(params, nextIdx);
},
goto: (id) => {
const targetIdx = (0, import_step.findStepIndex)(steps, id);
if (targetIdx === -1) {
(0, import_utils.warn)(`[zag-js/tour] Step with id "${id}" not found`);
return;
}
performStepTransition(params, targetIdx);
},
dismiss: () => {
refs.set("_internalChange", true);
context.set("stepId", null);
prop("onStatusChange")?.({ status: "dismissed", stepId: null, stepIndex: -1 });
},
target: step.target
};
}
function executeStepEffect(params, step, idx) {
const { refs } = params;
const utilities = createEffectUtilities(params, step, idx);
let cleanup;
try {
cleanup = step.effect(utilities);
} catch (error) {
console.error(error);
return;
}
refs.set("_effectCleanup", cleanup);
if ((0, import_step.isWaitStep)(step)) {
utilities.show();
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
machine
});