Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 109 additions & 19 deletions docs/guide/multiple-tours.md
Original file line number Diff line number Diff line change
@@ -1,42 +1,50 @@
# Multiple Tours
To create multiple tours, you can use the `steps` and `name` prop to switch between different tours.

To create multiple tours, use the `steps` and `name` props to switch between different tours.

### Defining the tours
First you need to define the steps for each tour.

```vue{3-7,12}
<script setup lang='ts'>
...
First define the steps for each tour.

Option 1 — recommended when the component has the “prop‑change watcher” enabled (auto‑restart on `name`/`steps` change). No manual start needed except the initial start.

```vue{3-8,12}
<script setup lang="ts">
import { ref, onMounted } from 'vue';

const tourSteps1 = [...];
const tourSteps2 = [...];
const tourSteps = ref(tourSteps1);
const tourName = ref('tour1');

// Start the first tour once mounted (or use autoStart on the component)
const vTour = ref();
vTour.value.startTour();
onMounted(() => vTour.value?.startTour());
</script>

<template>
<VTour :steps="tourSteps" :name="tourName" ref="vTour"/>
<VTour :steps="tourSteps" :name="tourName" ref="vTour" />
...
</template>
```
In this case we are creating two tours `tour1` and `tour2`.
Each with their corresponding steps `tourSteps1` and `tourSteps2`.

The `tourSteps` and `tourName` variables are reactive and can be changed at runtime.
In this case we’re creating two tours, `tour1` and `tour2`, each with their corresponding steps `tourSteps1` and `tourSteps2`. The `tourSteps` and `tourName` refs are reactive and can be changed at runtime.

### Switching between tours
To switch between tours, you just switch the `tourSteps` and `tourName` values.
```vue{10-19}
<script setup lang='ts'>
...

If the watcher is enabled, simply switch the reactive values; the tour will auto‑restart.

```vue{14-22}
<script setup lang="ts">
import { ref, onMounted } from 'vue';

const tourSteps1 = [...];
const tourSteps2 = [...];
const tourSteps = ref(tourSteps1);
const tourName = ref('tour1');
const vTour = ref();
vTour.value.startTour();
onMounted(() => vTour.value?.startTour());

function switchTour() {
if (tourName.value === 'tour1') {
tourSteps.value = tourSteps2;
Expand All @@ -45,13 +53,95 @@ To switch between tours, you just switch the `tourSteps` and `tourName` values.
tourSteps.value = tourSteps1;
tourName.value = 'tour1';
}
vTour.value.startTour();
// No need to call startTour() here when the watcher is enabled.
}
</script>

<template>
<VTour :steps="tourSteps" :name="tourName" ref="vTour"/>
<VTour :steps="tourSteps" :name="tourName" ref="vTour" />
...
</template>
```
Now everytime you call the `switchTour` function, the tour will switch between `tour1` and `tour2`.

If you do NOT use the watcher, you can still support hot‑switching by calling `startTour()` after changing props:

```vue{16}
function switchTour() {
if (tourName.value === 'tour1') {
tourSteps.value = tourSteps2;
tourName.value = 'tour2';
} else {
tourSteps.value = tourSteps1;
tourName.value = 'tour1';
}
vTour.value?.startTour(); // manual restart when watcher is disabled
}
```

Notes

- Prefer replacing the steps array (immutable update) over mutating it in place so the change is detected reliably.
- For multiple, fully independent tours on the same page, give each `VTour` a unique `name`. IDs, highlight classes, and localStorage keys are scoped by `name`, so tours won’t collide.

# Saving Progress

You can save a user’s progress in the browser’s localStorage so they can resume later.

## Using the `saveToLocalStorage` prop

Set the `saveToLocalStorage` prop on `VTour` to control if/when progress is saved. Accepted values: `never`, `step`, `end`. The default is `never`.

```vue
<script setup lang="ts">
// ...
const steps = [...];
</script>

<template>
<!-- No persistence (default) -->
<VTour :steps="steps" autoStart />

<!-- Save current step index after each step -->
<VTour :steps="steps" name="onboarding" autoStart saveToLocalStorage="step" />

<!-- Mark tour as completed only at the end -->
<VTour :steps="steps" name="tips" autoStart saveToLocalStorage="end" />
</template>
```

Notes

- Keys are scoped by the tour’s `name`. The storage key is `vjt-${name}`. If `name` is empty, the key is `vjt-tour`.
- With multiple tours, give each tour a unique `name` so their progress is isolated.

### `never`

No progress is saved. Each start begins from the first step (unless you manually control steps).

### `step`

Saves the current step index after each step. If the user leaves mid‑tour, the next start resumes at the saved step for that tour’s `name`.

- Key format: `localStorage.setItem('vjt-<name>', '<stepIndex>')`
- Works per tour. Switching `name` switches the storage key, so tours don’t affect each other.

### `end`

Saves only when the tour completes. If the user exits before completion, the next start begins at the first step.

- Completion flag: `localStorage.setItem('vjt-<name>', 'true')`
- When this flag is present, subsequent `startTour()` calls will no‑op unless you reset.

### Resetting or clearing progress

- Programmatic reset (recommended): call `resetTour()` to clear state and, if desired, restart.
- Manual clear: `localStorage.removeItem('vjt-<name>')`

### Multiple tours and hot‑switching

- Multiple independent tours: Use distinct `name` values to keep DOM ids, highlight classes, and storage keys separate.
- Swapping tours at runtime (changing `name` and/or `steps`):
- Each tour resumes from its own saved state (for `step`) or completion flag (for `end`), based on the new `name`.
- If you keep the watcher that restarts on prop changes, switching `name`/`steps` will auto‑restart the visible tour using the correct per‑name key.

Small copy edit: “everytime” → “every time”.
79 changes: 70 additions & 9 deletions src/components/VTour.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
<script setup lang="ts">
import { createPopper, type NanoPop } from 'nanopop';
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
import {
computed,
nextTick,
onMounted,
onUnmounted,
reactive,
ref,
watch,
} from 'vue';
import jump from 'jump.js';
import type {
VTourProps,
Expand All @@ -11,7 +19,10 @@ import type {
import { easeInOutQuad, easingFunctions } from '../easing';

// Props with defaults
const props = withDefaults(defineProps<VTourProps>(), {
// Note: widen the local type so this compiles even if VTourProps isn't updated yet.
type LocalVTourProps = VTourProps & { restartOnPropChange?: boolean };

const props = withDefaults(defineProps<LocalVTourProps>(), {
name: '',
backdrop: false,
autoStart: false,
Expand All @@ -28,6 +39,8 @@ const props = withDefaults(defineProps<VTourProps>(), {
keyboardNav: true,
ariaLabel: 'Guided tour',
teleportDelay: 100,
// New flag to control hot-switch behavior (on by default for backward compatibility)
restartOnPropChange: true,
});

// Emit definitions using standardized VTourEmits type
Expand Down Expand Up @@ -102,12 +115,29 @@ const isTourCompleted = (): boolean => {
return localStorage.getItem(saveKey.value) === 'true';
};

// Track the props used when the tour last started, to allow hot-switch restarts
const lastStarted = reactive<{
name: string | undefined;
stepsRef: unknown | null;
}>({
name: undefined,
stepsRef: null,
});

const startTour = async (): Promise<void> => {
if (isTourCompleted()) return;

// If tour is already active, do nothing (prevents restart)
// If tour is visible, allow "hot-switch" restart only when incoming props changed
if (tourVisible.value) {
return;
const nameChanged = props.name !== lastStarted.name;
const stepsChanged = props.steps !== lastStarted.stepsRef;
if (!(nameChanged || stepsChanged)) {
// No meaningful change -> no-op for backward behavior
return;
}
// Cleanly stop current tour, then proceed to start with new props
stopTour();
await nextTick();
}

if (props.saveToLocalStorage === 'step') {
Expand Down Expand Up @@ -146,6 +176,7 @@ const startTour = async (): Promise<void> => {
teleportDelayTimer = setTimeout(resolve, props.teleportDelay);
});

// Vue ref is preferred, but if not yet bound, fall back to query (defensive)
if (!_Tooltip.value) {
_Tooltip.value = document.querySelector(
`#${tooltipId.value}`
Expand Down Expand Up @@ -175,8 +206,8 @@ const startTour = async (): Promise<void> => {
// Wait for Vue to render the content in the DOM with proper dimensions
await nextTick();

if (!vTour.value) {
// Calculate margin: use prop margin, or increase to 14px if highlighting is enabled
// (Re)create popper for this run
{
const shouldHighlight = props.highlight || currentStepData.highlight;
const calculatedMargin = props.margin || (shouldHighlight ? 14 : 8);

Expand All @@ -197,6 +228,10 @@ const startTour = async (): Promise<void> => {
_Tooltip.value?.focus();
}

// Record the props used for this start so we can detect future hot-switches
lastStarted.name = props.name;
lastStarted.stepsRef = props.steps;

emit('onTourStart');
}, props.startDelay);
};
Expand All @@ -207,7 +242,6 @@ const stopTour = (): void => {
clearTimeout(teleportDelayTimer);

// Hide tour and backdrop immediately
// Both must be set to prevent CSS visibility conflicts with fixed-position elements
tourVisible.value = false;
backdropVisible.value = false;
isTransitioning.value = false;
Expand All @@ -228,6 +262,7 @@ const stopTour = (): void => {
previousFocus = null;
}
};

const resetTour = (shouldRestart = false): void => {
stopTour();
currentStepIndex.value = 0;
Expand Down Expand Up @@ -306,6 +341,7 @@ const goToStep = async (stepIndex: number): Promise<void> => {
// Show tooltip after positioning is complete
isTransitioning.value = false;
};

const beforeStep = async (step: number): Promise<void> => {
const stepData = props.steps[step];
if (stepData?.onBefore) {
Expand Down Expand Up @@ -338,8 +374,6 @@ const updateTooltipPosition = (): void => {
updateBackdrop();

// Update popper position and set placement attribute
// Nanopop automatically handles viewport edge detection and will flip placement if needed
// Nanopop v2.x returns the actual placement but doesn't automatically set the data-arrow attribute
const actualPlacement = vTour.value.update({
reference: targetElement,
position: currentStepData.placement || props.defaultPlacement,
Expand Down Expand Up @@ -559,6 +593,33 @@ onUnmounted(() => {
}
});

// Seamless auto-switching, gated by restartOnPropChange
watch(
() => [props.name, props.steps, props.restartOnPropChange],
async ([newName, newSteps, enabled], [oldName, oldSteps]) => {
if (!enabled) return; // opt-out supported
if (!tourVisible.value) return; // only when tour is currently visible

const nameChanged = newName !== oldName;
const stepsChanged = newSteps !== oldSteps;
if (!(nameChanged || stepsChanged)) return;

// Clean up old highlights manually if name changed
if (nameChanged && oldName !== undefined) {
const oldHighlightClass = oldName
? `vjt-highlight-${oldName}`
: 'vjt-highlight';
document
.querySelectorAll(`.${oldHighlightClass}`)
.forEach((element) => element.classList.remove(oldHighlightClass));
}

stopTour();
await nextTick();
startTour(); // uses current props
}
);

// Expose public API
defineExpose<VTourExposedMethods>({
startTour,
Expand Down
Loading
Loading