@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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalkMe Plugin Environment Configuration
|
|
3
|
+
*
|
|
4
|
+
* Uses centralized plugin environment loader from core.
|
|
5
|
+
* Provides type-safe access to WalkMe configuration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getPluginEnv } from '@nextsparkjs/core/lib/plugins/env-loader'
|
|
9
|
+
|
|
10
|
+
interface WalkmePluginEnvConfig {
|
|
11
|
+
WALKME_ENABLED?: string
|
|
12
|
+
WALKME_DEBUG?: string
|
|
13
|
+
WALKME_AUTO_START?: string
|
|
14
|
+
WALKME_AUTO_START_DELAY?: string
|
|
15
|
+
WALKME_PERSIST_STATE?: string
|
|
16
|
+
WALKME_ANALYTICS_ENABLED?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class PluginEnvironment {
|
|
20
|
+
private static instance: PluginEnvironment
|
|
21
|
+
private config: WalkmePluginEnvConfig = {}
|
|
22
|
+
private loaded = false
|
|
23
|
+
|
|
24
|
+
private constructor() {
|
|
25
|
+
this.loadEnvironment()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public static getInstance(): PluginEnvironment {
|
|
29
|
+
if (!PluginEnvironment.instance) {
|
|
30
|
+
PluginEnvironment.instance = new PluginEnvironment()
|
|
31
|
+
}
|
|
32
|
+
return PluginEnvironment.instance
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private loadEnvironment(forceReload: boolean = false): void {
|
|
36
|
+
if (this.loaded && !forceReload) return
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const env = getPluginEnv('walkme')
|
|
40
|
+
|
|
41
|
+
this.config = {
|
|
42
|
+
WALKME_ENABLED: env.WALKME_ENABLED || 'true',
|
|
43
|
+
WALKME_DEBUG: env.WALKME_DEBUG || 'false',
|
|
44
|
+
WALKME_AUTO_START: env.WALKME_AUTO_START || 'true',
|
|
45
|
+
WALKME_AUTO_START_DELAY: env.WALKME_AUTO_START_DELAY || '1000',
|
|
46
|
+
WALKME_PERSIST_STATE: env.WALKME_PERSIST_STATE || 'true',
|
|
47
|
+
WALKME_ANALYTICS_ENABLED: env.WALKME_ANALYTICS_ENABLED || 'false',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.loaded = true
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error('[WalkMe Plugin] Failed to load environment:', error)
|
|
53
|
+
this.loaded = true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public isPluginEnabled(): boolean {
|
|
58
|
+
return this.config.WALKME_ENABLED !== 'false'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public isDebugEnabled(): boolean {
|
|
62
|
+
return this.config.WALKME_DEBUG === 'true'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public isAutoStartEnabled(): boolean {
|
|
66
|
+
return this.config.WALKME_AUTO_START === 'true'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public getAutoStartDelay(): number {
|
|
70
|
+
return parseInt(this.config.WALKME_AUTO_START_DELAY || '1000', 10)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public isPersistStateEnabled(): boolean {
|
|
74
|
+
return this.config.WALKME_PERSIST_STATE !== 'false'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public isAnalyticsEnabled(): boolean {
|
|
78
|
+
return this.config.WALKME_ANALYTICS_ENABLED === 'true'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public reload(): void {
|
|
82
|
+
this.loaded = false
|
|
83
|
+
this.loadEnvironment(true)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const pluginEnv = PluginEnvironment.getInstance()
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalkMe Positioning Module
|
|
3
|
+
*
|
|
4
|
+
* Wrapper around @floating-ui/react for smart element positioning.
|
|
5
|
+
* Handles auto-flip, scroll tracking, viewport containment, and arrow placement.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client'
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useState } from 'react'
|
|
11
|
+
import {
|
|
12
|
+
useFloating,
|
|
13
|
+
autoUpdate,
|
|
14
|
+
offset,
|
|
15
|
+
flip,
|
|
16
|
+
shift,
|
|
17
|
+
arrow,
|
|
18
|
+
type Placement,
|
|
19
|
+
} from '@floating-ui/react'
|
|
20
|
+
import type { StepPosition } from '../types/walkme.types'
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Types
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface PositionConfig {
|
|
27
|
+
placement: Placement
|
|
28
|
+
offset?: number
|
|
29
|
+
padding?: number
|
|
30
|
+
fallbackPlacements?: Placement[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StepPositioningResult {
|
|
34
|
+
refs: {
|
|
35
|
+
setReference: (el: HTMLElement | null) => void
|
|
36
|
+
setFloating: (el: HTMLElement | null) => void
|
|
37
|
+
}
|
|
38
|
+
floatingStyles: React.CSSProperties
|
|
39
|
+
placement: Placement
|
|
40
|
+
isPositioned: boolean
|
|
41
|
+
arrowRef: React.RefObject<HTMLDivElement | null>
|
|
42
|
+
middlewareData: Record<string, unknown>
|
|
43
|
+
/** Force a position recalculation (useful after scroll/resize settles) */
|
|
44
|
+
update: () => void
|
|
45
|
+
/** Whether floating-ui has had time to stabilize after scroll */
|
|
46
|
+
isStable: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Utilities
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/** Map StepPosition to @floating-ui Placement */
|
|
54
|
+
export function getPlacementFromPosition(position: StepPosition): Placement {
|
|
55
|
+
switch (position) {
|
|
56
|
+
case 'top':
|
|
57
|
+
return 'top'
|
|
58
|
+
case 'bottom':
|
|
59
|
+
return 'bottom'
|
|
60
|
+
case 'left':
|
|
61
|
+
return 'left'
|
|
62
|
+
case 'right':
|
|
63
|
+
return 'right'
|
|
64
|
+
case 'auto':
|
|
65
|
+
default:
|
|
66
|
+
return 'bottom'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Get current viewport information */
|
|
71
|
+
export function getViewportInfo(): {
|
|
72
|
+
width: number
|
|
73
|
+
height: number
|
|
74
|
+
scrollX: number
|
|
75
|
+
scrollY: number
|
|
76
|
+
} {
|
|
77
|
+
if (typeof window === 'undefined') {
|
|
78
|
+
return { width: 0, height: 0, scrollX: 0, scrollY: 0 }
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
width: window.innerWidth,
|
|
82
|
+
height: window.innerHeight,
|
|
83
|
+
scrollX: window.scrollX,
|
|
84
|
+
scrollY: window.scrollY,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Hook
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Hook for positioning a floating step element relative to a target.
|
|
94
|
+
* Wraps @floating-ui/react with sensible defaults for WalkMe steps.
|
|
95
|
+
*
|
|
96
|
+
* IMPORTANT: Does NOT pass targetElement directly to useFloating's
|
|
97
|
+
* `elements.reference`. Instead, sets the reference imperatively after
|
|
98
|
+
* a short delay so that any scrollIntoView triggered by the provider
|
|
99
|
+
* has fully settled. This prevents stale position computation on
|
|
100
|
+
* backward navigation.
|
|
101
|
+
*/
|
|
102
|
+
export function useStepPositioning(
|
|
103
|
+
targetElement: HTMLElement | null,
|
|
104
|
+
config: PositionConfig,
|
|
105
|
+
): StepPositioningResult {
|
|
106
|
+
const arrowRef = useRef<HTMLDivElement>(null)
|
|
107
|
+
const [isStable, setIsStable] = useState(false)
|
|
108
|
+
|
|
109
|
+
const { refs, floatingStyles, placement, isPositioned, middlewareData, update } =
|
|
110
|
+
useFloating({
|
|
111
|
+
placement: config.placement,
|
|
112
|
+
// DO NOT set elements.reference here — we set it imperatively below
|
|
113
|
+
// so floating-ui computes position AFTER scroll has settled.
|
|
114
|
+
whileElementsMounted: autoUpdate,
|
|
115
|
+
middleware: [
|
|
116
|
+
offset(config.offset ?? 8),
|
|
117
|
+
flip({
|
|
118
|
+
fallbackPlacements: config.fallbackPlacements,
|
|
119
|
+
padding: config.padding ?? 8,
|
|
120
|
+
}),
|
|
121
|
+
shift({ padding: config.padding ?? 8 }),
|
|
122
|
+
arrow({ element: arrowRef }),
|
|
123
|
+
],
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Set reference imperatively after scroll settles
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
setIsStable(false)
|
|
129
|
+
|
|
130
|
+
if (!targetElement) {
|
|
131
|
+
refs.setReference(null)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Double rAF ensures any scrollIntoView (even 'instant') has
|
|
136
|
+
// fully reflowed the layout before we hand the element to floating-ui.
|
|
137
|
+
let cancelled = false
|
|
138
|
+
const rafIds = { outer: 0, inner: 0, stable: 0 }
|
|
139
|
+
|
|
140
|
+
rafIds.outer = requestAnimationFrame(() => {
|
|
141
|
+
rafIds.inner = requestAnimationFrame(() => {
|
|
142
|
+
if (cancelled) return
|
|
143
|
+
refs.setReference(targetElement)
|
|
144
|
+
// One more rAF for floating-ui to compute, then mark stable
|
|
145
|
+
rafIds.stable = requestAnimationFrame(() => {
|
|
146
|
+
if (!cancelled) setIsStable(true)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
return () => {
|
|
152
|
+
cancelled = true
|
|
153
|
+
cancelAnimationFrame(rafIds.outer)
|
|
154
|
+
cancelAnimationFrame(rafIds.inner)
|
|
155
|
+
cancelAnimationFrame(rafIds.stable)
|
|
156
|
+
}
|
|
157
|
+
}, [targetElement, refs])
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
refs: {
|
|
161
|
+
setReference: refs.setReference,
|
|
162
|
+
setFloating: refs.setFloating,
|
|
163
|
+
},
|
|
164
|
+
floatingStyles,
|
|
165
|
+
placement,
|
|
166
|
+
isPositioned,
|
|
167
|
+
arrowRef,
|
|
168
|
+
middlewareData,
|
|
169
|
+
update,
|
|
170
|
+
isStable,
|
|
171
|
+
}
|
|
172
|
+
}
|
package/lib/storage.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalkMe Storage Module
|
|
3
|
+
*
|
|
4
|
+
* localStorage persistence adapter for tour state.
|
|
5
|
+
* Handles save/load, schema versioning, and graceful SSR fallbacks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TourState, StorageSchema } from '../types/walkme.types'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Constants
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const STORAGE_KEY_PREFIX = 'walkme-state'
|
|
15
|
+
const STORAGE_VERSION = 1
|
|
16
|
+
|
|
17
|
+
/** Build the localStorage key, optionally scoped to a userId */
|
|
18
|
+
function getStorageKey(userId?: string): string {
|
|
19
|
+
return userId ? `${STORAGE_KEY_PREFIX}-${userId}` : STORAGE_KEY_PREFIX
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Types
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface StorageAdapter {
|
|
27
|
+
load(): StorageSchema | null
|
|
28
|
+
save(state: StorageSchema): void
|
|
29
|
+
reset(): void
|
|
30
|
+
resetTour(tourId: string): void
|
|
31
|
+
getCompletedTours(): string[]
|
|
32
|
+
getSkippedTours(): string[]
|
|
33
|
+
getVisitCount(): number
|
|
34
|
+
incrementVisitCount(): void
|
|
35
|
+
getFirstVisitDate(): string | null
|
|
36
|
+
setFirstVisitDate(date: string): void
|
|
37
|
+
getActiveTour(): TourState | null
|
|
38
|
+
setActiveTour(tour: TourState | null): void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Utilities
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Check if localStorage is available */
|
|
46
|
+
export function isStorageAvailable(): boolean {
|
|
47
|
+
if (typeof window === 'undefined') return false
|
|
48
|
+
try {
|
|
49
|
+
const testKey = '__walkme_test__'
|
|
50
|
+
window.localStorage.setItem(testKey, '1')
|
|
51
|
+
window.localStorage.removeItem(testKey)
|
|
52
|
+
return true
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Create a default empty storage schema */
|
|
59
|
+
function createDefaultSchema(): StorageSchema {
|
|
60
|
+
return {
|
|
61
|
+
version: STORAGE_VERSION,
|
|
62
|
+
completedTours: [],
|
|
63
|
+
skippedTours: [],
|
|
64
|
+
activeTour: null,
|
|
65
|
+
tourHistory: {},
|
|
66
|
+
visitCount: 0,
|
|
67
|
+
firstVisitDate: new Date().toISOString(),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Migrate storage data from older versions */
|
|
72
|
+
export function migrateStorage(data: unknown): StorageSchema {
|
|
73
|
+
if (!data || typeof data !== 'object') {
|
|
74
|
+
return createDefaultSchema()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const record = data as Record<string, unknown>
|
|
78
|
+
|
|
79
|
+
// Version 1 (current) - no migration needed, just validate shape
|
|
80
|
+
return {
|
|
81
|
+
version: STORAGE_VERSION,
|
|
82
|
+
completedTours: Array.isArray(record.completedTours)
|
|
83
|
+
? (record.completedTours as string[])
|
|
84
|
+
: [],
|
|
85
|
+
skippedTours: Array.isArray(record.skippedTours)
|
|
86
|
+
? (record.skippedTours as string[])
|
|
87
|
+
: [],
|
|
88
|
+
activeTour: record.activeTour as TourState | null ?? null,
|
|
89
|
+
tourHistory:
|
|
90
|
+
(record.tourHistory as Record<string, TourState>) ?? {},
|
|
91
|
+
visitCount:
|
|
92
|
+
typeof record.visitCount === 'number' ? record.visitCount : 0,
|
|
93
|
+
firstVisitDate:
|
|
94
|
+
typeof record.firstVisitDate === 'string'
|
|
95
|
+
? record.firstVisitDate
|
|
96
|
+
: new Date().toISOString(),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Factory
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Create a storage adapter backed by localStorage, optionally scoped to a user */
|
|
105
|
+
export function createStorageAdapter(userId?: string): StorageAdapter {
|
|
106
|
+
const available = isStorageAvailable()
|
|
107
|
+
const storageKey = getStorageKey(userId)
|
|
108
|
+
|
|
109
|
+
function read(): StorageSchema {
|
|
110
|
+
if (!available) return createDefaultSchema()
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const raw = window.localStorage.getItem(storageKey)
|
|
114
|
+
if (!raw) return createDefaultSchema()
|
|
115
|
+
|
|
116
|
+
const parsed = JSON.parse(raw)
|
|
117
|
+
return migrateStorage(parsed)
|
|
118
|
+
} catch {
|
|
119
|
+
return createDefaultSchema()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function write(schema: StorageSchema): void {
|
|
124
|
+
if (!available) return
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
window.localStorage.setItem(storageKey, JSON.stringify(schema))
|
|
128
|
+
} catch {
|
|
129
|
+
// Storage full or unavailable - silently ignore
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
load(): StorageSchema | null {
|
|
135
|
+
if (!available) return null
|
|
136
|
+
return read()
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
save(state: StorageSchema): void {
|
|
140
|
+
write(state)
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
reset(): void {
|
|
144
|
+
if (!available) return
|
|
145
|
+
try {
|
|
146
|
+
window.localStorage.removeItem(storageKey)
|
|
147
|
+
} catch {
|
|
148
|
+
// Ignore
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
resetTour(tourId: string): void {
|
|
153
|
+
const state = read()
|
|
154
|
+
state.completedTours = state.completedTours.filter((id) => id !== tourId)
|
|
155
|
+
state.skippedTours = state.skippedTours.filter((id) => id !== tourId)
|
|
156
|
+
const { [tourId]: _, ...remainingHistory } = state.tourHistory
|
|
157
|
+
state.tourHistory = remainingHistory
|
|
158
|
+
if (state.activeTour?.tourId === tourId) {
|
|
159
|
+
state.activeTour = null
|
|
160
|
+
}
|
|
161
|
+
write(state)
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
getCompletedTours(): string[] {
|
|
165
|
+
return read().completedTours
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
getSkippedTours(): string[] {
|
|
169
|
+
return read().skippedTours
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
getVisitCount(): number {
|
|
173
|
+
return read().visitCount
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
incrementVisitCount(): void {
|
|
177
|
+
const state = read()
|
|
178
|
+
state.visitCount += 1
|
|
179
|
+
write(state)
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
getFirstVisitDate(): string | null {
|
|
183
|
+
const state = read()
|
|
184
|
+
return state.firstVisitDate || null
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
setFirstVisitDate(date: string): void {
|
|
188
|
+
const state = read()
|
|
189
|
+
state.firstVisitDate = date
|
|
190
|
+
write(state)
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
getActiveTour(): TourState | null {
|
|
194
|
+
return read().activeTour
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
setActiveTour(tour: TourState | null): void {
|
|
198
|
+
const state = read()
|
|
199
|
+
state.activeTour = tour
|
|
200
|
+
write(state)
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
}
|
package/lib/targeting.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalkMe Targeting Module
|
|
3
|
+
*
|
|
4
|
+
* DOM element targeting utilities for finding, waiting for,
|
|
5
|
+
* and interacting with target elements for tour steps.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Types
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export interface TargetResult {
|
|
13
|
+
element: HTMLElement | null
|
|
14
|
+
found: boolean
|
|
15
|
+
selector: string
|
|
16
|
+
method: 'css' | 'data-walkme' | 'data-cy'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WaitForTargetOptions {
|
|
20
|
+
/** Maximum time to wait in ms (default: 5000) */
|
|
21
|
+
timeout?: number
|
|
22
|
+
/** Polling interval in ms (default: 200) */
|
|
23
|
+
interval?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Element Finding
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Find a target element using various selector strategies.
|
|
32
|
+
*
|
|
33
|
+
* Supports:
|
|
34
|
+
* 1. CSS selectors: `#id`, `.class`, `[attribute="value"]`
|
|
35
|
+
* 2. Data-walkme attribute shorthand: if selector has no CSS special chars,
|
|
36
|
+
* tries `[data-walkme-target="selector"]` first
|
|
37
|
+
* 3. Data-cy attribute: `[data-cy="value"]`
|
|
38
|
+
*/
|
|
39
|
+
export function findTarget(selector: string): TargetResult {
|
|
40
|
+
if (typeof window === 'undefined') {
|
|
41
|
+
return { element: null, found: false, selector, method: 'css' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Try data-walkme-target first if selector looks like a plain name
|
|
45
|
+
if (/^[a-zA-Z0-9_-]+$/.test(selector)) {
|
|
46
|
+
const walkmeEl = document.querySelector<HTMLElement>(
|
|
47
|
+
`[data-walkme-target="${selector}"]`,
|
|
48
|
+
)
|
|
49
|
+
if (walkmeEl) {
|
|
50
|
+
return { element: walkmeEl, found: true, selector, method: 'data-walkme' }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Try as CSS selector
|
|
55
|
+
try {
|
|
56
|
+
const el = document.querySelector<HTMLElement>(selector)
|
|
57
|
+
if (el) {
|
|
58
|
+
const method = selector.includes('data-cy') ? 'data-cy' : 'css'
|
|
59
|
+
return { element: el, found: true, selector, method }
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Invalid selector - return not found
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { element: null, found: false, selector, method: 'css' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wait for a target element to appear in the DOM.
|
|
70
|
+
* Uses MutationObserver for efficient watching.
|
|
71
|
+
*/
|
|
72
|
+
export function waitForTarget(
|
|
73
|
+
selector: string,
|
|
74
|
+
options: WaitForTargetOptions = {},
|
|
75
|
+
): Promise<TargetResult> {
|
|
76
|
+
const { timeout = 5000, interval = 200 } = options
|
|
77
|
+
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
// Try immediately first
|
|
80
|
+
const immediate = findTarget(selector)
|
|
81
|
+
if (immediate.found) {
|
|
82
|
+
resolve(immediate)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof window === 'undefined') {
|
|
87
|
+
resolve({ element: null, found: false, selector, method: 'css' })
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let resolved = false
|
|
92
|
+
let observer: MutationObserver | null = null
|
|
93
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
94
|
+
let intervalId: ReturnType<typeof setInterval> | null = null
|
|
95
|
+
|
|
96
|
+
const cleanup = () => {
|
|
97
|
+
resolved = true
|
|
98
|
+
observer?.disconnect()
|
|
99
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
100
|
+
if (intervalId) clearInterval(intervalId)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Set up timeout
|
|
104
|
+
timeoutId = setTimeout(() => {
|
|
105
|
+
if (!resolved) {
|
|
106
|
+
cleanup()
|
|
107
|
+
resolve({ element: null, found: false, selector, method: 'css' })
|
|
108
|
+
}
|
|
109
|
+
}, timeout)
|
|
110
|
+
|
|
111
|
+
// Poll as a fallback (MutationObserver doesn't catch everything)
|
|
112
|
+
intervalId = setInterval(() => {
|
|
113
|
+
if (resolved) return
|
|
114
|
+
const result = findTarget(selector)
|
|
115
|
+
if (result.found) {
|
|
116
|
+
cleanup()
|
|
117
|
+
resolve(result)
|
|
118
|
+
}
|
|
119
|
+
}, interval)
|
|
120
|
+
|
|
121
|
+
// MutationObserver for immediate detection
|
|
122
|
+
observer = new MutationObserver(() => {
|
|
123
|
+
if (resolved) return
|
|
124
|
+
const result = findTarget(selector)
|
|
125
|
+
if (result.found) {
|
|
126
|
+
cleanup()
|
|
127
|
+
resolve(result)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
observer.observe(document.body, {
|
|
132
|
+
childList: true,
|
|
133
|
+
subtree: true,
|
|
134
|
+
attributes: true,
|
|
135
|
+
attributeFilter: ['data-walkme-target', 'data-cy', 'id', 'class'],
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Element Utilities
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** Check if an element is visible (not hidden by CSS) */
|
|
145
|
+
export function isElementVisible(element: HTMLElement): boolean {
|
|
146
|
+
if (typeof window === 'undefined') return false
|
|
147
|
+
|
|
148
|
+
const style = window.getComputedStyle(element)
|
|
149
|
+
return (
|
|
150
|
+
style.display !== 'none' &&
|
|
151
|
+
style.visibility !== 'hidden' &&
|
|
152
|
+
style.opacity !== '0' &&
|
|
153
|
+
element.offsetParent !== null
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Check if an element is within the current viewport */
|
|
158
|
+
export function isElementInViewport(element: HTMLElement): boolean {
|
|
159
|
+
if (typeof window === 'undefined') return false
|
|
160
|
+
|
|
161
|
+
const rect = element.getBoundingClientRect()
|
|
162
|
+
return (
|
|
163
|
+
rect.top >= 0 &&
|
|
164
|
+
rect.left >= 0 &&
|
|
165
|
+
rect.bottom <= window.innerHeight &&
|
|
166
|
+
rect.right <= window.innerWidth
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Scroll the viewport to make an element visible */
|
|
171
|
+
export function scrollToElement(
|
|
172
|
+
element: HTMLElement,
|
|
173
|
+
options: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition } = {},
|
|
174
|
+
): void {
|
|
175
|
+
if (typeof window === 'undefined') return
|
|
176
|
+
|
|
177
|
+
element.scrollIntoView({
|
|
178
|
+
behavior: options.behavior ?? 'instant',
|
|
179
|
+
block: options.block ?? 'center',
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Get the bounding rect of an element */
|
|
184
|
+
export function getElementRect(element: HTMLElement): DOMRect {
|
|
185
|
+
return element.getBoundingClientRect()
|
|
186
|
+
}
|