UNPKG

@zag-js/tour

Version:

Core logic for the tour widget implemented as a state machine

286 lines (285 loc) 8.9 kB
// 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 };