@zag-js/tour
Version:
Core logic for the tour widget implemented as a state machine
286 lines (285 loc) • 8.9 kB
JavaScript
// src/tour.connect.ts
import { mergeProps } from "@zag-js/core";
import { dataAttr } from "@zag-js/dom-query";
import { getPlacementSide, getPlacementStyles } from "@zag-js/popper";
import { toPx } from "@zag-js/utils";
import { parts } from "./tour.anatomy.mjs";
import * as dom from "./tour.dom.mjs";
import { getClipPath } from "./utils/clip-path.mjs";
import { getEffectiveStepIndex, getEffectiveSteps, isDialogStep, isTooltipPlacement, isTooltipStep } from "./utils/step.mjs";
function connect(service, normalize) {
const { state, context, computed, send, prop, scope } = service;
const open = state.hasTag("open");
const steps = Array.from(context.get("steps"));
const stepIndex = computed("stepIndex");
const step = computed("step");
const hasTarget = typeof step?.target?.() !== "undefined";
const hasNextStep = computed("hasNextStep");
const hasPrevStep = computed("hasPrevStep");
const firstStep = computed("isFirstStep");
const lastStep = computed("isLastStep");
const placement = context.get("currentPlacement");
const placementSide = isTooltipPlacement(placement) ? getPlacementSide(placement) : void 0;
const targetRect = context.get("targetRect");
const popperStyles = getPlacementStyles({
strategy: "absolute",
placement: isTooltipPlacement(placement) ? placement : void 0
});
const clipPath = getClipPath({
enabled: isTooltipStep(step),
rect: targetRect,
rootSize: context.get("boundarySize"),
radius: prop("spotlightRadius")
});
const actionMap = {
next() {
send({ type: "STEP.NEXT", src: "actionTrigger" });
},
prev() {
send({ type: "STEP.PREV", src: "actionTrigger" });
},
dismiss() {
send({ type: "DISMISS", src: "actionTrigger" });
},
skip() {
send({ type: "SKIP", src: "actionTrigger" });
},
goto(id) {
send({ type: "STEP.SET", value: id, src: "actionTrigger" });
}
};
return {
open,
totalSteps: steps.length,
stepIndex,
step,
hasNextStep,
hasPrevStep,
firstStep,
lastStep,
addStep(step2) {
const next = steps.concat(step2);
send({ type: "STEPS.SET", value: next, src: "addStep" });
},
removeStep(id) {
const next = steps.filter((step2) => step2.id !== id);
send({ type: "STEPS.SET", value: next, src: "removeStep" });
},
updateStep(id, stepOverrides) {
const next = steps.map((step2) => step2.id === id ? mergeProps(step2, stepOverrides) : step2);
send({ type: "STEPS.SET", value: next, src: "updateStep" });
},
setSteps(steps2) {
send({ type: "STEPS.SET", value: steps2, src: "setSteps" });
},
setStep(id) {
send({ type: "STEP.SET", value: id });
},
start(id) {
send({ type: "START", value: id });
},
isValidStep(id) {
return steps.some((step2) => step2.id === id);
},
isCurrentStep(id) {
return Boolean(step?.id === id);
},
next() {
send({ type: "STEP.NEXT" });
},
prev() {
send({ type: "STEP.PREV" });
},
getProgressPercent() {
const index = getEffectiveStepIndex(steps, step?.id);
const total = getEffectiveSteps(steps).length;
return (index + 1) / total * 100;
},
getProgressText() {
const index = getEffectiveStepIndex(steps, step?.id);
const total = getEffectiveSteps(steps).length;
const details = { current: index, total };
return prop("translations").progressText?.(details) ?? "";
},
getBackdropProps() {
return normalize.element({
...parts.backdrop.attrs,
id: dom.getBackdropId(scope),
dir: prop("dir"),
hidden: !open,
"data-state": open ? "open" : "closed",
"data-type": step?.type,
style: {
"--tour-layer": 0,
clipPath: isTooltipStep(step) ? `path("${clipPath}")` : void 0,
position: isDialogStep(step) ? "fixed" : "absolute",
inset: "0",
willChange: isTooltipStep(step) ? "clip-path" : void 0
}
});
},
getSpotlightProps() {
return normalize.element({
...parts.spotlight.attrs,
hidden: !open || !step?.target?.(),
style: {
"--tour-layer": 1,
position: "absolute",
width: toPx(targetRect.width),
height: toPx(targetRect.height),
left: toPx(targetRect.x),
top: toPx(targetRect.y),
borderRadius: toPx(prop("spotlightRadius")),
pointerEvents: "none"
}
});
},
getProgressTextProps() {
return normalize.element({
...parts.progressText.attrs
});
},
getPositionerProps() {
return normalize.element({
...parts.positioner.attrs,
dir: prop("dir"),
id: dom.getPositionerId(scope),
"data-type": step?.type,
"data-placement": placement,
"data-side": placementSide,
style: {
"--tour-layer": 2,
...step?.type === "tooltip" && popperStyles.floating
}
});
},
getArrowProps() {
return normalize.element({
id: dom.getArrowId(scope),
...parts.arrow.attrs,
dir: prop("dir"),
hidden: step?.type !== "tooltip",
style: step?.type === "tooltip" ? popperStyles.arrow : void 0,
opacity: hasTarget ? void 0 : 0
});
},
getArrowTipProps() {
return normalize.element({
...parts.arrowTip.attrs,
dir: prop("dir"),
style: popperStyles.arrowTip
});
},
getContentProps() {
return normalize.element({
...parts.content.attrs,
id: dom.getContentId(scope),
dir: prop("dir"),
role: "alertdialog",
"aria-modal": "true",
"aria-live": "polite",
"aria-atomic": "true",
hidden: !open,
"data-state": open ? "open" : "closed",
"data-type": step?.type,
"data-placement": placement,
"data-side": placementSide,
"data-step": step?.id,
"aria-labelledby": dom.getTitleId(scope),
"aria-describedby": dom.getDescriptionId(scope),
tabIndex: -1,
onKeyDown(event) {
if (event.defaultPrevented) return;
if (!prop("keyboardNavigation")) return;
const isRtl = prop("dir") === "rtl";
switch (event.key) {
case "ArrowRight":
if (!hasNextStep) return;
send({ type: isRtl ? "STEP.PREV" : "STEP.NEXT", src: "keydown" });
break;
case "ArrowLeft":
if (!hasPrevStep) return;
send({ type: isRtl ? "STEP.NEXT" : "STEP.PREV", src: "keydown" });
break;
default:
break;
}
}
});
},
getTitleProps() {
return normalize.element({
...parts.title.attrs,
id: dom.getTitleId(scope),
"data-placement": hasTarget ? placement : "center",
"data-side": hasTarget ? placementSide : void 0
});
},
getDescriptionProps() {
return normalize.element({
...parts.description.attrs,
id: dom.getDescriptionId(scope),
"data-placement": hasTarget ? placement : "center",
"data-side": hasTarget ? placementSide : void 0
});
},
getCloseTriggerProps() {
return normalize.element({
...parts.closeTrigger.attrs,
"data-type": step?.type,
"aria-label": prop("translations").close,
onClick: actionMap.dismiss
});
},
getActionTriggerProps(props) {
const { action, attrs } = props.action;
let actionProps = {};
switch (action) {
case "next":
actionProps = {
"data-type": "next",
disabled: !hasNextStep,
"data-disabled": dataAttr(!hasNextStep),
"aria-label": prop("translations").nextStep,
onClick: actionMap.next
};
break;
case "prev":
actionProps = {
"data-type": "prev",
disabled: !hasPrevStep,
"data-disabled": dataAttr(!hasPrevStep),
"aria-label": prop("translations").prevStep,
onClick: actionMap.prev
};
break;
case "dismiss":
actionProps = {
"data-type": "close",
"aria-label": prop("translations").close,
onClick: actionMap.dismiss
};
break;
default:
actionProps = {
"data-type": "custom",
onClick() {
if (typeof action === "function") {
action(actionMap);
}
}
};
break;
}
return normalize.button({
...parts.actionTrigger.attrs,
type: "button",
...attrs,
...actionProps
});
}
};
}
export {
connect
};