AbsoluteJS

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.

MemberTypeDescription
activeRef<boolean>True while a tour is running. Persisted so a cross-page step resumes after the full reload.
indexRef<number>The current step index, persisted per keystroke of navigation.
isReplayRef<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) => voidResets the index to 0 and activates. Pass true to mark the run a replay.
stop()() => voidDeactivates and resets the index to 0.
TS
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();
First visit vs replay
The host owns where the "seen" marker lives. Finishing a first-visit run stamps it; a replay started with 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.

TS
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.

clickselector?Clicks the resolved element. Defaults to the step’s own target.
scrollselector?, block?Smooth scrollIntoView. block is "center" unless set to "start".
waitms?Abortable sleep (default 600ms) — resolves early when the step changes or the tour stops.
TS
// 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:

actionNames a built-in ("click" | "wait" | "scroll") or a handler the host registered.
argsPlain-JSON scalars (string | number | boolean | null) passed to the handler.
delayMsWait this long (ms) before running this action.
Degrade, never break
Unknown action names warn and skip, and handler errors are contained the same way — a tutorial authored against a page that isn't mounted degrades instead of breaking the tour. Both paths emit an 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:

stepThe step being shown.
targetThe step’s resolved target element, or null for a centered / missing-target step.
argsThe raw args from the serialized ref.
indexThe current step index.
signalAn AbortSignal that fires when the step changes or the tour stops mid-run.
next / back / stopTour navigation — a handler can drive the tour itself, e.g. auto-advance when its demo finishes.
TS
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:

1
engineThe step changes; any running actions abort
The in-flight onEnter sequence is cancelled — its AbortSignal fires, and built-ins like wait resolve immediately.
2
engineThe previous step’s onExit actions run
Cleanup runs to completion before the next step positions, because it may restore UI the next step depends on. Closing the tour still runs the last step’s onExit.
3
engineNavigate and wait for the target
For cross-page steps the engine pushes the route, then waits for the page (and the target) to render before positioning.
4
engineonEnter actions fire once the step is positioned
They run sequentially, fire-and-forget, so the card shows while the demo plays.
5
analyticsThe step is on screen
step_viewed is emitted to your onEvent sink.

#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.

TS
// 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.

TS
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 data
Badge sample data
Badge the surface when isDemo 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:

"auto"defaultThe viewer’s real data when they have it — a member with sourced matches is toured on their literal matches — and the sample when they don’t.
"demo"Always the sample while the tour plays, even when real data exists.
"live"Always the real data, even mid-tour — the sample never shows.

In "auto", "has data" defaults to non-nullish — and non-empty when the value is an array — and is overridable per surface with hasLive.