@nextsparkjs/plugin-walkme 0.1.0-beta.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +23 -0
- package/LICENSE +21 -0
- package/README.md +625 -0
- package/components/WalkmeBeacon.tsx +64 -0
- package/components/WalkmeControls.tsx +111 -0
- package/components/WalkmeModal.tsx +144 -0
- package/components/WalkmeOverlay.tsx +107 -0
- package/components/WalkmeProgress.tsx +53 -0
- package/components/WalkmeProvider.tsx +674 -0
- package/components/WalkmeSpotlight.tsx +188 -0
- package/components/WalkmeTooltip.tsx +152 -0
- package/examples/basic-tour.ts +38 -0
- package/examples/conditional-tour.ts +56 -0
- package/examples/cross-window-tour.ts +54 -0
- package/hooks/useTour.ts +52 -0
- package/hooks/useTourProgress.ts +38 -0
- package/hooks/useTourState.ts +146 -0
- package/hooks/useWalkme.ts +52 -0
- package/jest.config.cjs +27 -0
- package/lib/conditions.ts +113 -0
- package/lib/core.ts +323 -0
- package/lib/plugin-env.ts +87 -0
- package/lib/positioning.ts +172 -0
- package/lib/storage.ts +203 -0
- package/lib/targeting.ts +186 -0
- package/lib/triggers.ts +127 -0
- package/lib/validation.ts +122 -0
- package/messages/en.json +21 -0
- package/messages/es.json +21 -0
- package/package.json +18 -0
- package/plugin.config.ts +26 -0
- package/providers/walkme-context.ts +17 -0
- package/tests/lib/conditions.test.ts +172 -0
- package/tests/lib/core.test.ts +514 -0
- package/tests/lib/positioning.test.ts +43 -0
- package/tests/lib/storage.test.ts +232 -0
- package/tests/lib/targeting.test.ts +191 -0
- package/tests/lib/triggers.test.ts +198 -0
- package/tests/lib/validation.test.ts +249 -0
- package/tests/setup.ts +52 -0
- package/tests/tsconfig.json +32 -0
- package/tsconfig.json +47 -0
- package/types/walkme.types.ts +316 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import { usePathname, useRouter } from 'next/navigation'
|
|
12
|
+
import { WalkmeContext } from '../providers/walkme-context'
|
|
13
|
+
import { useTourState } from '../hooks/useTourState'
|
|
14
|
+
import { validateTours } from '../lib/validation'
|
|
15
|
+
import { shouldTriggerTour, type TriggerEvaluationContext } from '../lib/triggers'
|
|
16
|
+
import { evaluateConditions } from '../lib/conditions'
|
|
17
|
+
import {
|
|
18
|
+
getActiveTour,
|
|
19
|
+
getActiveStep,
|
|
20
|
+
isFirstStep,
|
|
21
|
+
isLastStep,
|
|
22
|
+
} from '../lib/core'
|
|
23
|
+
import { findTarget, waitForTarget, scrollToElement } from '../lib/targeting'
|
|
24
|
+
import type {
|
|
25
|
+
WalkmeProviderProps,
|
|
26
|
+
WalkmeContextValue,
|
|
27
|
+
TourEvent,
|
|
28
|
+
ConditionContext,
|
|
29
|
+
WalkmeLabels,
|
|
30
|
+
} from '../types/walkme.types'
|
|
31
|
+
import { WalkmeOverlay } from './WalkmeOverlay'
|
|
32
|
+
import { WalkmeTooltip } from './WalkmeTooltip'
|
|
33
|
+
import { WalkmeModal } from './WalkmeModal'
|
|
34
|
+
import { WalkmeSpotlight } from './WalkmeSpotlight'
|
|
35
|
+
import { WalkmeBeacon } from './WalkmeBeacon'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* WalkMe Provider Component
|
|
39
|
+
*
|
|
40
|
+
* Wraps the application (or a section) to provide guided tour functionality.
|
|
41
|
+
* Manages tour state, trigger evaluation, cross-page navigation, and rendering.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* <WalkmeProvider tours={[introTour, featureTour]} autoStart>
|
|
46
|
+
* <App />
|
|
47
|
+
* </WalkmeProvider>
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_LABELS: WalkmeLabels = {
|
|
51
|
+
next: 'Next',
|
|
52
|
+
prev: 'Previous',
|
|
53
|
+
skip: 'Skip',
|
|
54
|
+
complete: 'Complete',
|
|
55
|
+
close: 'Close',
|
|
56
|
+
progress: 'Step {current} of {total}',
|
|
57
|
+
tourAvailable: 'Tour available',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function WalkmeProvider({
|
|
61
|
+
tours: rawTours,
|
|
62
|
+
children,
|
|
63
|
+
debug = false,
|
|
64
|
+
autoStart = true,
|
|
65
|
+
autoStartDelay = 1000,
|
|
66
|
+
persistState = true,
|
|
67
|
+
onTourStart,
|
|
68
|
+
onTourComplete,
|
|
69
|
+
onTourSkip,
|
|
70
|
+
onStepChange,
|
|
71
|
+
conditionContext: externalConditionContext,
|
|
72
|
+
labels: userLabels,
|
|
73
|
+
userId,
|
|
74
|
+
}: WalkmeProviderProps) {
|
|
75
|
+
const labels = useMemo<WalkmeLabels>(
|
|
76
|
+
() => ({ ...DEFAULT_LABELS, ...userLabels }),
|
|
77
|
+
[userLabels],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const pathname = usePathname()
|
|
81
|
+
const router = useRouter()
|
|
82
|
+
const prevPathRef = useRef(pathname)
|
|
83
|
+
const customEventsRef = useRef<Set<string>>(new Set())
|
|
84
|
+
const previousFocusRef = useRef<HTMLElement | null>(null)
|
|
85
|
+
const triggerTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
86
|
+
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null)
|
|
87
|
+
|
|
88
|
+
// Validate tours on mount
|
|
89
|
+
const validatedTours = useMemo(() => {
|
|
90
|
+
const result = validateTours(rawTours)
|
|
91
|
+
if (!result.valid && debug) {
|
|
92
|
+
console.warn(
|
|
93
|
+
'[WalkMe] Some tours failed validation and were excluded:',
|
|
94
|
+
result.errors,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
return result.validTours
|
|
98
|
+
}, [rawTours, debug])
|
|
99
|
+
|
|
100
|
+
// Core state management
|
|
101
|
+
const { state, dispatch, storage } = useTourState(validatedTours, {
|
|
102
|
+
persistState,
|
|
103
|
+
debug,
|
|
104
|
+
userId,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Context value helpers
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
const startTour = useCallback(
|
|
112
|
+
(tourId: string) => {
|
|
113
|
+
const tour = state.tours[tourId]
|
|
114
|
+
if (!tour) {
|
|
115
|
+
if (debug) console.warn(`[WalkMe] Tour "${tourId}" not found`)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
previousFocusRef.current = document.activeElement as HTMLElement | null
|
|
120
|
+
dispatch({ type: 'START_TOUR', tourId })
|
|
121
|
+
|
|
122
|
+
onTourStart?.({
|
|
123
|
+
type: 'tour_started',
|
|
124
|
+
tourId,
|
|
125
|
+
tourName: tour.name,
|
|
126
|
+
stepIndex: 0,
|
|
127
|
+
totalSteps: tour.steps.length,
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
})
|
|
130
|
+
},
|
|
131
|
+
[state.tours, dispatch, debug, onTourStart],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const pauseTour = useCallback(() => {
|
|
135
|
+
dispatch({ type: 'PAUSE_TOUR' })
|
|
136
|
+
}, [dispatch])
|
|
137
|
+
|
|
138
|
+
const resumeTour = useCallback(() => {
|
|
139
|
+
dispatch({ type: 'RESUME_TOUR' })
|
|
140
|
+
}, [dispatch])
|
|
141
|
+
|
|
142
|
+
const skipTour = useCallback(() => {
|
|
143
|
+
const tour = getActiveTour(state)
|
|
144
|
+
if (tour) {
|
|
145
|
+
onTourSkip?.({
|
|
146
|
+
type: 'tour_skipped',
|
|
147
|
+
tourId: tour.id,
|
|
148
|
+
tourName: tour.name,
|
|
149
|
+
stepIndex: state.activeTour?.currentStepIndex,
|
|
150
|
+
totalSteps: tour.steps.length,
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
})
|
|
153
|
+
tour.onSkip?.()
|
|
154
|
+
}
|
|
155
|
+
dispatch({ type: 'SKIP_TOUR' })
|
|
156
|
+
restoreFocus()
|
|
157
|
+
}, [state, dispatch, onTourSkip])
|
|
158
|
+
|
|
159
|
+
const completeTour = useCallback(() => {
|
|
160
|
+
const tour = getActiveTour(state)
|
|
161
|
+
if (tour) {
|
|
162
|
+
onTourComplete?.({
|
|
163
|
+
type: 'tour_completed',
|
|
164
|
+
tourId: tour.id,
|
|
165
|
+
tourName: tour.name,
|
|
166
|
+
totalSteps: tour.steps.length,
|
|
167
|
+
timestamp: Date.now(),
|
|
168
|
+
})
|
|
169
|
+
tour.onComplete?.()
|
|
170
|
+
}
|
|
171
|
+
dispatch({ type: 'COMPLETE_TOUR' })
|
|
172
|
+
restoreFocus()
|
|
173
|
+
}, [state, dispatch, onTourComplete])
|
|
174
|
+
|
|
175
|
+
const nextStep = useCallback(() => {
|
|
176
|
+
if (isLastStep(state)) {
|
|
177
|
+
completeTour()
|
|
178
|
+
} else {
|
|
179
|
+
dispatch({ type: 'NEXT_STEP' })
|
|
180
|
+
}
|
|
181
|
+
}, [state, dispatch, completeTour])
|
|
182
|
+
|
|
183
|
+
const prevStep = useCallback(() => {
|
|
184
|
+
dispatch({ type: 'PREV_STEP' })
|
|
185
|
+
}, [dispatch])
|
|
186
|
+
|
|
187
|
+
const goToStep = useCallback(
|
|
188
|
+
(stepIndex: number) => {
|
|
189
|
+
dispatch({ type: 'NAVIGATE_TO_STEP', stepIndex })
|
|
190
|
+
},
|
|
191
|
+
[dispatch],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const resetTour = useCallback(
|
|
195
|
+
(tourId: string) => {
|
|
196
|
+
dispatch({ type: 'RESET_TOUR', tourId })
|
|
197
|
+
},
|
|
198
|
+
[dispatch],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const resetAllTours = useCallback(() => {
|
|
202
|
+
dispatch({ type: 'RESET_ALL' })
|
|
203
|
+
}, [dispatch])
|
|
204
|
+
|
|
205
|
+
const isTourCompleted = useCallback(
|
|
206
|
+
(tourId: string) => state.completedTours.includes(tourId),
|
|
207
|
+
[state.completedTours],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
const isTourActive = useCallback(
|
|
211
|
+
(tourId: string) => state.activeTour?.tourId === tourId,
|
|
212
|
+
[state.activeTour],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
const emitEvent = useCallback(
|
|
216
|
+
(eventName: string) => {
|
|
217
|
+
customEventsRef.current.add(eventName)
|
|
218
|
+
// Re-evaluate triggers after event emission
|
|
219
|
+
evaluateTriggers()
|
|
220
|
+
},
|
|
221
|
+
[], // eslint-disable-line react-hooks/exhaustive-deps
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
function restoreFocus() {
|
|
225
|
+
if (previousFocusRef.current) {
|
|
226
|
+
previousFocusRef.current.focus()
|
|
227
|
+
previousFocusRef.current = null
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Trigger Evaluation
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
const evaluateTriggers = useCallback(() => {
|
|
236
|
+
if (!state.initialized || state.activeTour || !autoStart) return
|
|
237
|
+
|
|
238
|
+
const triggerContext: TriggerEvaluationContext = {
|
|
239
|
+
currentRoute: pathname,
|
|
240
|
+
visitCount: storage.getVisitCount(),
|
|
241
|
+
firstVisitDate: storage.getFirstVisitDate(),
|
|
242
|
+
completedTourIds: state.completedTours,
|
|
243
|
+
customEvents: customEventsRef.current,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const conditionCtx: ConditionContext = {
|
|
247
|
+
...externalConditionContext,
|
|
248
|
+
completedTourIds: state.completedTours,
|
|
249
|
+
visitCount: storage.getVisitCount(),
|
|
250
|
+
firstVisitDate: storage.getFirstVisitDate() ?? undefined,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Sort tours by priority (lower number = higher priority)
|
|
254
|
+
const sortedTours = Object.values(state.tours).sort(
|
|
255
|
+
(a, b) => (a.priority ?? 999) - (b.priority ?? 999),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
for (const tour of sortedTours) {
|
|
259
|
+
if (state.completedTours.includes(tour.id)) continue
|
|
260
|
+
if (state.skippedTours.includes(tour.id)) continue
|
|
261
|
+
|
|
262
|
+
if (shouldTriggerTour(tour, triggerContext)) {
|
|
263
|
+
if (evaluateConditions(tour.conditions, conditionCtx)) {
|
|
264
|
+
const delay = tour.trigger.delay ?? autoStartDelay ?? 0
|
|
265
|
+
|
|
266
|
+
if (triggerTimeoutRef.current) {
|
|
267
|
+
clearTimeout(triggerTimeoutRef.current)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
triggerTimeoutRef.current = setTimeout(() => {
|
|
271
|
+
startTour(tour.id)
|
|
272
|
+
}, delay)
|
|
273
|
+
|
|
274
|
+
break // Only trigger one tour at a time
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}, [
|
|
279
|
+
state.initialized,
|
|
280
|
+
state.activeTour,
|
|
281
|
+
state.tours,
|
|
282
|
+
state.completedTours,
|
|
283
|
+
state.skippedTours,
|
|
284
|
+
autoStart,
|
|
285
|
+
pathname,
|
|
286
|
+
storage,
|
|
287
|
+
externalConditionContext,
|
|
288
|
+
autoStartDelay,
|
|
289
|
+
startTour,
|
|
290
|
+
])
|
|
291
|
+
|
|
292
|
+
// Evaluate triggers on init and route change
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
evaluateTriggers()
|
|
295
|
+
}, [evaluateTriggers])
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Cross-Page Navigation
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (!state.activeTour || state.activeTour.status !== 'active') return
|
|
303
|
+
|
|
304
|
+
const activeStep = getActiveStep(state)
|
|
305
|
+
if (!activeStep?.route) return
|
|
306
|
+
|
|
307
|
+
// If step requires a different route, navigate
|
|
308
|
+
if (activeStep.route !== pathname) {
|
|
309
|
+
try {
|
|
310
|
+
router.push(activeStep.route)
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (debug) console.error('[WalkMe] Navigation failed:', err)
|
|
313
|
+
// Skip to next step on navigation failure
|
|
314
|
+
dispatch({ type: 'NEXT_STEP' })
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}, [state.activeTour?.currentStepIndex, Object.keys(state.tours).length]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
318
|
+
|
|
319
|
+
// Wait for target after route change
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
if (prevPathRef.current === pathname) return
|
|
322
|
+
prevPathRef.current = pathname
|
|
323
|
+
|
|
324
|
+
if (!state.activeTour || state.activeTour.status !== 'active') return
|
|
325
|
+
|
|
326
|
+
const activeStep = getActiveStep(state)
|
|
327
|
+
if (!activeStep?.target) return
|
|
328
|
+
|
|
329
|
+
waitForTarget(activeStep.target, { timeout: 5000 }).then((result) => {
|
|
330
|
+
if (result.found && result.element) {
|
|
331
|
+
setTargetElement(result.element)
|
|
332
|
+
scrollToElement(result.element)
|
|
333
|
+
} else if (debug) {
|
|
334
|
+
console.warn(
|
|
335
|
+
`[WalkMe] Target "${activeStep.target}" not found after navigation`,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
}, [pathname, Object.keys(state.tours).length]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Target Element Resolution
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
const toursCount = Object.keys(state.tours).length
|
|
346
|
+
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
// Clear stale target so floating tooltip unmounts and remounts fresh
|
|
349
|
+
setTargetElement(null)
|
|
350
|
+
|
|
351
|
+
if (!state.activeTour || state.activeTour.status !== 'active') return
|
|
352
|
+
|
|
353
|
+
const activeStep = getActiveStep(state)
|
|
354
|
+
if (!activeStep?.target) return
|
|
355
|
+
|
|
356
|
+
let cancelled = false
|
|
357
|
+
|
|
358
|
+
const applyTarget = (element: HTMLElement) => {
|
|
359
|
+
if (cancelled) return
|
|
360
|
+
scrollToElement(element)
|
|
361
|
+
setTargetElement(element)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Short delay lets React flush the null state (unmounts tooltip)
|
|
365
|
+
const findTimer = setTimeout(() => {
|
|
366
|
+
if (cancelled) return
|
|
367
|
+
const result = findTarget(activeStep.target!)
|
|
368
|
+
if (result.found && result.element) {
|
|
369
|
+
applyTarget(result.element)
|
|
370
|
+
} else {
|
|
371
|
+
waitForTarget(activeStep.target!, { timeout: 5000 }).then((waitResult) => {
|
|
372
|
+
if (waitResult.found && waitResult.element) {
|
|
373
|
+
applyTarget(waitResult.element)
|
|
374
|
+
} else if (debug) {
|
|
375
|
+
console.warn(
|
|
376
|
+
`[WalkMe] Target "${activeStep.target}" not found, step may display without anchor`,
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
}, 50)
|
|
382
|
+
|
|
383
|
+
return () => {
|
|
384
|
+
cancelled = true
|
|
385
|
+
clearTimeout(findTimer)
|
|
386
|
+
}
|
|
387
|
+
}, [state.activeTour?.currentStepIndex, state.activeTour?.status, toursCount]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Step Change Analytics
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
if (!state.activeTour || state.activeTour.status !== 'active') return
|
|
395
|
+
|
|
396
|
+
const tour = state.tours[state.activeTour.tourId]
|
|
397
|
+
if (!tour) return
|
|
398
|
+
|
|
399
|
+
const activeStep = getActiveStep(state)
|
|
400
|
+
|
|
401
|
+
onStepChange?.({
|
|
402
|
+
type: 'step_changed',
|
|
403
|
+
tourId: tour.id,
|
|
404
|
+
tourName: tour.name,
|
|
405
|
+
stepId: activeStep?.id,
|
|
406
|
+
stepIndex: state.activeTour.currentStepIndex,
|
|
407
|
+
totalSteps: tour.steps.length,
|
|
408
|
+
timestamp: Date.now(),
|
|
409
|
+
})
|
|
410
|
+
}, [state.activeTour?.currentStepIndex]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Keyboard Navigation
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
if (!state.activeTour || state.activeTour.status !== 'active') return
|
|
418
|
+
|
|
419
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
420
|
+
switch (e.key) {
|
|
421
|
+
case 'ArrowRight':
|
|
422
|
+
e.preventDefault()
|
|
423
|
+
nextStep()
|
|
424
|
+
break
|
|
425
|
+
case 'ArrowLeft':
|
|
426
|
+
e.preventDefault()
|
|
427
|
+
prevStep()
|
|
428
|
+
break
|
|
429
|
+
case 'Escape':
|
|
430
|
+
e.preventDefault()
|
|
431
|
+
skipTour()
|
|
432
|
+
break
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
437
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
438
|
+
}, [state.activeTour, nextStep, prevStep, skipTour])
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Body Scroll Lock (only for modal/floating steps that cover the page)
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
const activeStepType = getActiveStep(state)?.type
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
const isActive = state.activeTour?.status === 'active'
|
|
447
|
+
if (!isActive) return
|
|
448
|
+
|
|
449
|
+
// Only lock scroll for step types that cover the viewport
|
|
450
|
+
if (activeStepType !== 'modal' && activeStepType !== 'floating') return
|
|
451
|
+
|
|
452
|
+
const { style } = document.body
|
|
453
|
+
const originalOverflow = style.overflow
|
|
454
|
+
const originalPaddingRight = style.paddingRight
|
|
455
|
+
|
|
456
|
+
// Compensate for scrollbar width to prevent layout shift
|
|
457
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
|
458
|
+
style.overflow = 'hidden'
|
|
459
|
+
if (scrollbarWidth > 0) {
|
|
460
|
+
style.paddingRight = `${scrollbarWidth}px`
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return () => {
|
|
464
|
+
style.overflow = originalOverflow
|
|
465
|
+
style.paddingRight = originalPaddingRight
|
|
466
|
+
}
|
|
467
|
+
}, [state.activeTour?.status, activeStepType])
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Cleanup
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
return () => {
|
|
475
|
+
if (triggerTimeoutRef.current) {
|
|
476
|
+
clearTimeout(triggerTimeoutRef.current)
|
|
477
|
+
}
|
|
478
|
+
setTargetElement(null)
|
|
479
|
+
previousFocusRef.current = null
|
|
480
|
+
}
|
|
481
|
+
}, [])
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// Context Value
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
const contextValue = useMemo<WalkmeContextValue>(
|
|
488
|
+
() => ({
|
|
489
|
+
state,
|
|
490
|
+
startTour,
|
|
491
|
+
pauseTour,
|
|
492
|
+
resumeTour,
|
|
493
|
+
skipTour,
|
|
494
|
+
completeTour,
|
|
495
|
+
resetTour,
|
|
496
|
+
resetAllTours,
|
|
497
|
+
nextStep,
|
|
498
|
+
prevStep,
|
|
499
|
+
goToStep,
|
|
500
|
+
isTourCompleted,
|
|
501
|
+
isTourActive,
|
|
502
|
+
getActiveTour: () => getActiveTour(state),
|
|
503
|
+
getActiveStep: () => getActiveStep(state),
|
|
504
|
+
emitEvent,
|
|
505
|
+
}),
|
|
506
|
+
[
|
|
507
|
+
state,
|
|
508
|
+
startTour,
|
|
509
|
+
pauseTour,
|
|
510
|
+
resumeTour,
|
|
511
|
+
skipTour,
|
|
512
|
+
completeTour,
|
|
513
|
+
resetTour,
|
|
514
|
+
resetAllTours,
|
|
515
|
+
nextStep,
|
|
516
|
+
prevStep,
|
|
517
|
+
goToStep,
|
|
518
|
+
isTourCompleted,
|
|
519
|
+
isTourActive,
|
|
520
|
+
emitEvent,
|
|
521
|
+
],
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// Render
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
const activeTour = getActiveTour(state)
|
|
529
|
+
const activeStep = getActiveStep(state)
|
|
530
|
+
const showStep = activeTour && activeStep && state.activeTour?.status === 'active'
|
|
531
|
+
|
|
532
|
+
// Build screen reader announcement
|
|
533
|
+
const srAnnouncement = showStep && state.activeTour
|
|
534
|
+
? labels.progress
|
|
535
|
+
.replace('{current}', String(state.activeTour.currentStepIndex + 1))
|
|
536
|
+
.replace('{total}', String(activeTour!.steps.length))
|
|
537
|
+
+ ': ' + activeStep.title
|
|
538
|
+
: ''
|
|
539
|
+
|
|
540
|
+
return (
|
|
541
|
+
<WalkmeContext.Provider value={contextValue}>
|
|
542
|
+
{children}
|
|
543
|
+
|
|
544
|
+
{/* Screen reader live region for step announcements */}
|
|
545
|
+
<div
|
|
546
|
+
aria-live="polite"
|
|
547
|
+
aria-atomic="true"
|
|
548
|
+
className="sr-only"
|
|
549
|
+
style={{
|
|
550
|
+
position: 'absolute',
|
|
551
|
+
width: '1px',
|
|
552
|
+
height: '1px',
|
|
553
|
+
padding: 0,
|
|
554
|
+
margin: '-1px',
|
|
555
|
+
overflow: 'hidden',
|
|
556
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
557
|
+
whiteSpace: 'nowrap',
|
|
558
|
+
borderWidth: 0,
|
|
559
|
+
}}
|
|
560
|
+
>
|
|
561
|
+
{srAnnouncement}
|
|
562
|
+
</div>
|
|
563
|
+
|
|
564
|
+
{showStep && state.activeTour && (
|
|
565
|
+
<StepRenderer
|
|
566
|
+
step={activeStep}
|
|
567
|
+
targetElement={targetElement}
|
|
568
|
+
onNext={nextStep}
|
|
569
|
+
onPrev={prevStep}
|
|
570
|
+
onSkip={skipTour}
|
|
571
|
+
onComplete={completeTour}
|
|
572
|
+
isFirst={isFirstStep(state)}
|
|
573
|
+
isLast={isLastStep(state)}
|
|
574
|
+
currentIndex={state.activeTour.currentStepIndex}
|
|
575
|
+
totalSteps={activeTour.steps.length}
|
|
576
|
+
labels={labels}
|
|
577
|
+
/>
|
|
578
|
+
)}
|
|
579
|
+
</WalkmeContext.Provider>
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// Step Renderer (internal)
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
interface StepRendererProps {
|
|
588
|
+
step: NonNullable<ReturnType<typeof getActiveStep>>
|
|
589
|
+
targetElement: HTMLElement | null
|
|
590
|
+
onNext: () => void
|
|
591
|
+
onPrev: () => void
|
|
592
|
+
onSkip: () => void
|
|
593
|
+
onComplete: () => void
|
|
594
|
+
isFirst: boolean
|
|
595
|
+
isLast: boolean
|
|
596
|
+
currentIndex: number
|
|
597
|
+
totalSteps: number
|
|
598
|
+
labels: WalkmeLabels
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function StepRenderer({
|
|
602
|
+
step,
|
|
603
|
+
targetElement,
|
|
604
|
+
onNext,
|
|
605
|
+
onPrev,
|
|
606
|
+
onSkip,
|
|
607
|
+
onComplete,
|
|
608
|
+
isFirst,
|
|
609
|
+
isLast,
|
|
610
|
+
currentIndex,
|
|
611
|
+
totalSteps,
|
|
612
|
+
labels,
|
|
613
|
+
}: StepRendererProps) {
|
|
614
|
+
const commonProps = {
|
|
615
|
+
step,
|
|
616
|
+
onNext,
|
|
617
|
+
onPrev,
|
|
618
|
+
onSkip,
|
|
619
|
+
onComplete,
|
|
620
|
+
isFirst,
|
|
621
|
+
isLast,
|
|
622
|
+
currentIndex,
|
|
623
|
+
totalSteps,
|
|
624
|
+
labels,
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
switch (step.type) {
|
|
628
|
+
case 'modal':
|
|
629
|
+
return (
|
|
630
|
+
<>
|
|
631
|
+
<WalkmeOverlay visible />
|
|
632
|
+
<WalkmeModal {...commonProps} />
|
|
633
|
+
</>
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
case 'tooltip':
|
|
637
|
+
return (
|
|
638
|
+
<>
|
|
639
|
+
<WalkmeOverlay
|
|
640
|
+
visible
|
|
641
|
+
spotlightTarget={targetElement}
|
|
642
|
+
spotlightPadding={8}
|
|
643
|
+
/>
|
|
644
|
+
<WalkmeTooltip {...commonProps} targetElement={targetElement} />
|
|
645
|
+
</>
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
case 'spotlight':
|
|
649
|
+
return (
|
|
650
|
+
<WalkmeSpotlight {...commonProps} targetElement={targetElement} />
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
case 'beacon':
|
|
654
|
+
return (
|
|
655
|
+
<WalkmeBeacon
|
|
656
|
+
step={step}
|
|
657
|
+
targetElement={targetElement}
|
|
658
|
+
onClick={onNext}
|
|
659
|
+
labels={labels}
|
|
660
|
+
/>
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
case 'floating':
|
|
664
|
+
return (
|
|
665
|
+
<>
|
|
666
|
+
<WalkmeOverlay visible />
|
|
667
|
+
<WalkmeModal {...commonProps} />
|
|
668
|
+
</>
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
default:
|
|
672
|
+
return null
|
|
673
|
+
}
|
|
674
|
+
}
|