Skip to content

Commit ce5f9ac

Browse files
authored
Merge pull request #105 from AdamDrewsTR/mutiple-tours
feat: Re-implement hot-switching for tour name and steps in VTour component
2 parents b82f7a4 + 7cbb2e0 commit ce5f9ac

3 files changed

Lines changed: 296 additions & 28 deletions

File tree

docs/guide/multiple-tours.md

Lines changed: 109 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,50 @@
11
# Multiple Tours
2-
To create multiple tours, you can use the `steps` and `name` prop to switch between different tours.
2+
3+
To create multiple tours, use the `steps` and `name` props to switch between different tours.
34

45
### Defining the tours
5-
First you need to define the steps for each tour.
66

7-
```vue{3-7,12}
8-
<script setup lang='ts'>
9-
...
7+
First define the steps for each tour.
8+
9+
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.
10+
11+
```vue{3-8,12}
12+
<script setup lang="ts">
13+
import { ref, onMounted } from 'vue';
14+
1015
const tourSteps1 = [...];
1116
const tourSteps2 = [...];
1217
const tourSteps = ref(tourSteps1);
1318
const tourName = ref('tour1');
19+
20+
// Start the first tour once mounted (or use autoStart on the component)
1421
const vTour = ref();
15-
vTour.value.startTour();
22+
onMounted(() => vTour.value?.startTour());
1623
</script>
1724
1825
<template>
19-
<VTour :steps="tourSteps" :name="tourName" ref="vTour"/>
26+
<VTour :steps="tourSteps" :name="tourName" ref="vTour" />
2027
...
2128
</template>
2229
```
23-
In this case we are creating two tours `tour1` and `tour2`.
24-
Each with their corresponding steps `tourSteps1` and `tourSteps2`.
2530

26-
The `tourSteps` and `tourName` variables are reactive and can be changed at runtime.
31+
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.
2732

2833
### Switching between tours
29-
To switch between tours, you just switch the `tourSteps` and `tourName` values.
30-
```vue{10-19}
31-
<script setup lang='ts'>
32-
...
34+
35+
If the watcher is enabled, simply switch the reactive values; the tour will auto‑restart.
36+
37+
```vue{14-22}
38+
<script setup lang="ts">
39+
import { ref, onMounted } from 'vue';
40+
3341
const tourSteps1 = [...];
3442
const tourSteps2 = [...];
3543
const tourSteps = ref(tourSteps1);
3644
const tourName = ref('tour1');
3745
const vTour = ref();
38-
vTour.value.startTour();
39-
46+
onMounted(() => vTour.value?.startTour());
47+
4048
function switchTour() {
4149
if (tourName.value === 'tour1') {
4250
tourSteps.value = tourSteps2;
@@ -45,13 +53,95 @@ To switch between tours, you just switch the `tourSteps` and `tourName` values.
4553
tourSteps.value = tourSteps1;
4654
tourName.value = 'tour1';
4755
}
48-
vTour.value.startTour();
56+
// No need to call startTour() here when the watcher is enabled.
4957
}
5058
</script>
5159
5260
<template>
53-
<VTour :steps="tourSteps" :name="tourName" ref="vTour"/>
61+
<VTour :steps="tourSteps" :name="tourName" ref="vTour" />
5462
...
5563
</template>
5664
```
57-
Now everytime you call the `switchTour` function, the tour will switch between `tour1` and `tour2`.
65+
66+
If you do NOT use the watcher, you can still support hot‑switching by calling `startTour()` after changing props:
67+
68+
```vue{16}
69+
function switchTour() {
70+
if (tourName.value === 'tour1') {
71+
tourSteps.value = tourSteps2;
72+
tourName.value = 'tour2';
73+
} else {
74+
tourSteps.value = tourSteps1;
75+
tourName.value = 'tour1';
76+
}
77+
vTour.value?.startTour(); // manual restart when watcher is disabled
78+
}
79+
```
80+
81+
Notes
82+
83+
- Prefer replacing the steps array (immutable update) over mutating it in place so the change is detected reliably.
84+
- 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.
85+
86+
# Saving Progress
87+
88+
You can save a user’s progress in the browser’s localStorage so they can resume later.
89+
90+
## Using the `saveToLocalStorage` prop
91+
92+
Set the `saveToLocalStorage` prop on `VTour` to control if/when progress is saved. Accepted values: `never`, `step`, `end`. The default is `never`.
93+
94+
```vue
95+
<script setup lang="ts">
96+
// ...
97+
const steps = [...];
98+
</script>
99+
100+
<template>
101+
<!-- No persistence (default) -->
102+
<VTour :steps="steps" autoStart />
103+
104+
<!-- Save current step index after each step -->
105+
<VTour :steps="steps" name="onboarding" autoStart saveToLocalStorage="step" />
106+
107+
<!-- Mark tour as completed only at the end -->
108+
<VTour :steps="steps" name="tips" autoStart saveToLocalStorage="end" />
109+
</template>
110+
```
111+
112+
Notes
113+
114+
- Keys are scoped by the tour’s `name`. The storage key is `vjt-${name}`. If `name` is empty, the key is `vjt-tour`.
115+
- With multiple tours, give each tour a unique `name` so their progress is isolated.
116+
117+
### `never`
118+
119+
No progress is saved. Each start begins from the first step (unless you manually control steps).
120+
121+
### `step`
122+
123+
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`.
124+
125+
- Key format: `localStorage.setItem('vjt-<name>', '<stepIndex>')`
126+
- Works per tour. Switching `name` switches the storage key, so tours don’t affect each other.
127+
128+
### `end`
129+
130+
Saves only when the tour completes. If the user exits before completion, the next start begins at the first step.
131+
132+
- Completion flag: `localStorage.setItem('vjt-<name>', 'true')`
133+
- When this flag is present, subsequent `startTour()` calls will no‑op unless you reset.
134+
135+
### Resetting or clearing progress
136+
137+
- Programmatic reset (recommended): call `resetTour()` to clear state and, if desired, restart.
138+
- Manual clear: `localStorage.removeItem('vjt-<name>')`
139+
140+
### Multiple tours and hot‑switching
141+
142+
- Multiple independent tours: Use distinct `name` values to keep DOM ids, highlight classes, and storage keys separate.
143+
- Swapping tours at runtime (changing `name` and/or `steps`):
144+
- Each tour resumes from its own saved state (for `step`) or completion flag (for `end`), based on the new `name`.
145+
- 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.
146+
147+
Small copy edit: “everytime” → “every time”.

src/components/VTour.vue

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
<script setup lang="ts">
22
import { createPopper, type NanoPop } from 'nanopop';
3-
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
3+
import {
4+
computed,
5+
nextTick,
6+
onMounted,
7+
onUnmounted,
8+
reactive,
9+
ref,
10+
watch,
11+
} from 'vue';
412
import jump from 'jump.js';
513
import type {
614
VTourProps,
@@ -11,7 +19,10 @@ import type {
1119
import { easeInOutQuad, easingFunctions } from '../easing';
1220
1321
// Props with defaults
14-
const props = withDefaults(defineProps<VTourProps>(), {
22+
// Note: widen the local type so this compiles even if VTourProps isn't updated yet.
23+
type LocalVTourProps = VTourProps & { restartOnPropChange?: boolean };
24+
25+
const props = withDefaults(defineProps<LocalVTourProps>(), {
1526
name: '',
1627
backdrop: false,
1728
autoStart: false,
@@ -28,6 +39,8 @@ const props = withDefaults(defineProps<VTourProps>(), {
2839
keyboardNav: true,
2940
ariaLabel: 'Guided tour',
3041
teleportDelay: 100,
42+
// New flag to control hot-switch behavior (on by default for backward compatibility)
43+
restartOnPropChange: true,
3144
});
3245
3346
// Emit definitions using standardized VTourEmits type
@@ -102,12 +115,29 @@ const isTourCompleted = (): boolean => {
102115
return localStorage.getItem(saveKey.value) === 'true';
103116
};
104117
118+
// Track the props used when the tour last started, to allow hot-switch restarts
119+
const lastStarted = reactive<{
120+
name: string | undefined;
121+
stepsRef: unknown | null;
122+
}>({
123+
name: undefined,
124+
stepsRef: null,
125+
});
126+
105127
const startTour = async (): Promise<void> => {
106128
if (isTourCompleted()) return;
107129
108-
// If tour is already active, do nothing (prevents restart)
130+
// If tour is visible, allow "hot-switch" restart only when incoming props changed
109131
if (tourVisible.value) {
110-
return;
132+
const nameChanged = props.name !== lastStarted.name;
133+
const stepsChanged = props.steps !== lastStarted.stepsRef;
134+
if (!(nameChanged || stepsChanged)) {
135+
// No meaningful change -> no-op for backward behavior
136+
return;
137+
}
138+
// Cleanly stop current tour, then proceed to start with new props
139+
stopTour();
140+
await nextTick();
111141
}
112142
113143
if (props.saveToLocalStorage === 'step') {
@@ -146,6 +176,7 @@ const startTour = async (): Promise<void> => {
146176
teleportDelayTimer = setTimeout(resolve, props.teleportDelay);
147177
});
148178
179+
// Vue ref is preferred, but if not yet bound, fall back to query (defensive)
149180
if (!_Tooltip.value) {
150181
_Tooltip.value = document.querySelector(
151182
`#${tooltipId.value}`
@@ -175,8 +206,8 @@ const startTour = async (): Promise<void> => {
175206
// Wait for Vue to render the content in the DOM with proper dimensions
176207
await nextTick();
177208
178-
if (!vTour.value) {
179-
// Calculate margin: use prop margin, or increase to 14px if highlighting is enabled
209+
// (Re)create popper for this run
210+
{
180211
const shouldHighlight = props.highlight || currentStepData.highlight;
181212
const calculatedMargin = props.margin || (shouldHighlight ? 14 : 8);
182213
@@ -197,6 +228,10 @@ const startTour = async (): Promise<void> => {
197228
_Tooltip.value?.focus();
198229
}
199230
231+
// Record the props used for this start so we can detect future hot-switches
232+
lastStarted.name = props.name;
233+
lastStarted.stepsRef = props.steps;
234+
200235
emit('onTourStart');
201236
}, props.startDelay);
202237
};
@@ -207,7 +242,6 @@ const stopTour = (): void => {
207242
clearTimeout(teleportDelayTimer);
208243
209244
// Hide tour and backdrop immediately
210-
// Both must be set to prevent CSS visibility conflicts with fixed-position elements
211245
tourVisible.value = false;
212246
backdropVisible.value = false;
213247
isTransitioning.value = false;
@@ -228,6 +262,7 @@ const stopTour = (): void => {
228262
previousFocus = null;
229263
}
230264
};
265+
231266
const resetTour = (shouldRestart = false): void => {
232267
stopTour();
233268
currentStepIndex.value = 0;
@@ -306,6 +341,7 @@ const goToStep = async (stepIndex: number): Promise<void> => {
306341
// Show tooltip after positioning is complete
307342
isTransitioning.value = false;
308343
};
344+
309345
const beforeStep = async (step: number): Promise<void> => {
310346
const stepData = props.steps[step];
311347
if (stepData?.onBefore) {
@@ -338,8 +374,6 @@ const updateTooltipPosition = (): void => {
338374
updateBackdrop();
339375
340376
// Update popper position and set placement attribute
341-
// Nanopop automatically handles viewport edge detection and will flip placement if needed
342-
// Nanopop v2.x returns the actual placement but doesn't automatically set the data-arrow attribute
343377
const actualPlacement = vTour.value.update({
344378
reference: targetElement,
345379
position: currentStepData.placement || props.defaultPlacement,
@@ -559,6 +593,33 @@ onUnmounted(() => {
559593
}
560594
});
561595
596+
// Seamless auto-switching, gated by restartOnPropChange
597+
watch(
598+
() => [props.name, props.steps, props.restartOnPropChange],
599+
async ([newName, newSteps, enabled], [oldName, oldSteps]) => {
600+
if (!enabled) return; // opt-out supported
601+
if (!tourVisible.value) return; // only when tour is currently visible
602+
603+
const nameChanged = newName !== oldName;
604+
const stepsChanged = newSteps !== oldSteps;
605+
if (!(nameChanged || stepsChanged)) return;
606+
607+
// Clean up old highlights manually if name changed
608+
if (nameChanged && oldName !== undefined) {
609+
const oldHighlightClass = oldName
610+
? `vjt-highlight-${oldName}`
611+
: 'vjt-highlight';
612+
document
613+
.querySelectorAll(`.${oldHighlightClass}`)
614+
.forEach((element) => element.classList.remove(oldHighlightClass));
615+
}
616+
617+
stopTour();
618+
await nextTick();
619+
startTour(); // uses current props
620+
}
621+
);
622+
562623
// Expose public API
563624
defineExpose<VTourExposedMethods>({
564625
startTour,

0 commit comments

Comments
 (0)