Step Actions & Demo Data
A tour should demo the product, not just point at it. Steps stay serializable, so a step references actions by name — the host registers the handlers with useTourActions, and built-ins (click, scroll, wait) work straight from admin-authored JSON. useTourDemo completes the picture: fully-typed sample data on data-backed surfaces, so a fresh signup can be toured on an empty account for free.
#Shared Controller
useTourController(storageKey) returns a singleton per storage key (default 'absolute.tour'). In an MPA each page is its own mount, so moving between steps is a full page reload — the controller persists active, index, and isReplay in sessionStorage (one key per field, no JSON) and the tour resumes on the next page at the right step.
| Member | Type | Description |
|---|---|---|
active | Ref<boolean> | True while a tour is running. Persisted so a cross-page step resumes after the full reload. |
index | Ref<number> | The current step index, persisted per keystroke of navigation. |
isReplay | Ref<boolean> | True when the run is an explicit replay, so the host can choose NOT to re-stamp its "seen" marker on finish. |
start(replay?) | (replay?: boolean) => void | Resets the index to 0 and activates. Pass true to mark the run a replay. |
stop() | () => void | Deactivates and resets the index to 0. |
import { useTourController } from '@absolutejs/tour';
// Singleton per storage key — the overlay component and a "replay" button
// in a completely different part of the tree share one switch.
const tour = useTourController('myapp.tour');
// Reactive state, persisted to sessionStorage (one key per field, no
// JSON) so a cross-page step's full reload resumes at the right index.
tour.active; // Ref<boolean>
tour.index; // Ref<number>
tour.isReplay; // Ref<boolean>
// Auto-play once on first visit, then stamp your own "seen" marker.
if (!account.tourSeenAt) tour.start();
// Replay from anywhere. isReplay stays true for the whole run, so the
// host can choose NOT to re-stamp its "seen" marker on finish.
tour.start(true);
// Stop — deactivates and resets the index to 0.
tour.stop();start(true) does not — check isReplay in your finish handler.#Action Registry
Steps are serializable JSON — they can live in a database or an admin editor — so a step can't hold a function. It holds an action name, and useTourActions() maps names to handlers. Like the controller it's a keyed singleton: the page that owns a surface registers its demo handler, while the tour overlay — mounted in a completely different part of the tree — resolves it by name.
import { onBeforeUnmount } from 'vue';
import { useTourActions } from '@absolutejs/tour';
// The page that OWNS the surface registers its demo handler. The tour
// overlay — mounted in a completely different part of the tree —
// resolves it by name through the same keyed singleton.
const actions = useTourActions(); // default registry key 'absolute.tour'
const unregister = actions.register('matches.demo-swipe', async (ctx) => {
const direction = ctx.args.direction === 'left' ? 'left' : 'right';
await swiper.value?.demoSwipe(direction); // ctx.signal aborts long demos
});
// Or several at once — the return unregisters them all.
const unregisterAll = actions.registerAll({
'matches.demo-filter': (ctx) => filters.value?.demo(ctx.args),
'matches.demo-sort': (ctx) => sorter.value?.demo(ctx.args)
});
// Unregister on unmount so a dead page's handler can't be resolved.
// Safe against remount races: the disposer only removes the entry if
// it still points at THIS handler.
onBeforeUnmount(unregister);
onBeforeUnmount(unregisterAll);register returns an unregister function and registerAll returns one that disposes the whole batch — call them on unmount so a dead page's handler can't be resolved. The disposer only removes the entry if it still points at the same handler, so a remount that re-registered before the old cleanup ran is never clobbered.
#Built-in Actions
Three generic built-ins ship with the engine, so simple demos — click a tab, pause, scroll a panel into view — work from admin-authored JSON with no host code at all. Each takes an optional selector and defaults to the step's own target.
// Built-ins run straight from admin-authored JSON — no host code at all.
// Each takes an optional selector (default: the step's own target).
const step = {
body: 'Filters, sort, and export live up here.',
onEnter: [
// Open the panel the step is about…
{ action: 'click', args: { selector: "[data-tour='filters-toggle']" } },
// …let the open animation finish (ms defaults to 600 when omitted)…
{ action: 'wait', args: { ms: 700 } },
// …and bring the panel into view (block: 'center' by default).
{ action: 'scroll', args: { block: 'start' } }
],
// onExit runs when the step is left — cleanup / restore.
onExit: [
{ action: 'click', args: { selector: "[data-tour='filters-toggle']" } }
],
route: '/portal/matches',
target: "[data-tour='filters']",
title: 'Powerful filters'
};
// delayMs waits before an individual action runs:
// { action: 'matches.demo-swipe', args: { direction: 'left' }, delayMs: 400 }Each entry in onEnter / onExit is a TourActionRef:
action_failed funnel event carrying the action name.#Handler Context
Every handler receives a TourActionContext — enough to run a demo against the highlighted element and even drive the tour itself:
import { tourWait, useTourActions } from '@absolutejs/tour';
// Handlers receive the full context — a handler can drive the tour
// itself (e.g. auto-advance when its demo finishes).
useTourActions().register('pipeline.demo-drag', async (ctx) => {
// ctx.step — the step being shown
// ctx.target — the step's resolved target element (or null)
// ctx.args — plain-JSON args from the serialized ref
// ctx.index — the current step index
// ctx.signal — fires when the step changes or the tour stops
// ctx.next / ctx.back / ctx.stop — tour navigation
await board.value?.demoDrag(ctx.args.dealId, ctx.signal);
if (ctx.signal.aborted) return;
// tourWait is an abortable sleep — resolves early (never rejects)
// when the signal fires.
await tourWait(500, ctx.signal);
// Auto-advance once the demo finishes.
ctx.next();
});What runs when a step changes, in order:
#CTA Buttons
A step's cta renders a call-to-action button in the card ("Try it now"). The engine's runCta runs the CTA's action refs through the same registry as onEnter, then advances to the next step unless the step opts out with advance: false.
// A call-to-action button rendered in the step card. Its action refs run
// through the same registry as onEnter, then the tour advances unless
// advance: false.
const step = {
body: 'Send your first invite without leaving the tour.',
cta: {
actions: [
{ action: 'click', args: { selector: "[data-tour='invite']" } }
],
advance: false,
label: 'Try it now'
},
target: "[data-tour='invite']",
title: 'Invite your team'
};
// The host template renders it and calls the engine's runCta:
// <button v-if="t.step.value.cta" @click="t.runCta">
// {{ t.step.value.cta.label }}
// </button>#Demo Data
A tour must be able to show a data-backed surface (matches, pipeline, metrics) to a viewer who has no data yet — a fresh signup, an unsubscribed user — without the host paying to source anything. useTourDemo swaps in a constant, fully-typed sample dataset while the tour plays and passes the real data through untouched otherwise. The demo's type is the contract: it must be the same shape the surface renders.
import { useTourController, useTourDemo } from '@absolutejs/tour';
const controller = useTourController('myapp.tour');
const { data: matches, isDemo } = useTourDemo({
controller,
// The constant sample dataset. Its type IS the contract — the same
// shape as the live data, so the surface renders it unchanged.
demo: DEMO_MATCHES,
// Optional: does this live value count as "has data"? Default:
// non-nullish, and non-empty when it's an array.
hasLive: (value) => value.length > 2,
// Getter for the real (reactive) data; null/undefined while absent.
live: () => realMatches.value,
// Optional per-tutorial override, wired to Tutorial.dataMode.
mode: () => activeTutorial.value?.dataMode
});
// matches → what the surface should render right now
// isDemo → true while the sample is showing; badge the surface
// ("Sample data") so it is never mistaken for real dataisDemo is true ("Sample data") so the sample is never mistaken for the viewer's own. Outside an active tour isDemo is always false and the live data passes through.#Data Modes
Resolution is per TourDataMode. Tutorial.dataMode carries the choice in the serialized tutorial, and useTourDemo's mode getter wires it through:
In "auto", "has data" defaults to non-nullish — and non-empty when the value is an array — and is overridable per surface with hasLive.