@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.
Files changed (43) hide show
  1. package/.env.example +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +625 -0
  4. package/components/WalkmeBeacon.tsx +64 -0
  5. package/components/WalkmeControls.tsx +111 -0
  6. package/components/WalkmeModal.tsx +144 -0
  7. package/components/WalkmeOverlay.tsx +107 -0
  8. package/components/WalkmeProgress.tsx +53 -0
  9. package/components/WalkmeProvider.tsx +674 -0
  10. package/components/WalkmeSpotlight.tsx +188 -0
  11. package/components/WalkmeTooltip.tsx +152 -0
  12. package/examples/basic-tour.ts +38 -0
  13. package/examples/conditional-tour.ts +56 -0
  14. package/examples/cross-window-tour.ts +54 -0
  15. package/hooks/useTour.ts +52 -0
  16. package/hooks/useTourProgress.ts +38 -0
  17. package/hooks/useTourState.ts +146 -0
  18. package/hooks/useWalkme.ts +52 -0
  19. package/jest.config.cjs +27 -0
  20. package/lib/conditions.ts +113 -0
  21. package/lib/core.ts +323 -0
  22. package/lib/plugin-env.ts +87 -0
  23. package/lib/positioning.ts +172 -0
  24. package/lib/storage.ts +203 -0
  25. package/lib/targeting.ts +186 -0
  26. package/lib/triggers.ts +127 -0
  27. package/lib/validation.ts +122 -0
  28. package/messages/en.json +21 -0
  29. package/messages/es.json +21 -0
  30. package/package.json +18 -0
  31. package/plugin.config.ts +26 -0
  32. package/providers/walkme-context.ts +17 -0
  33. package/tests/lib/conditions.test.ts +172 -0
  34. package/tests/lib/core.test.ts +514 -0
  35. package/tests/lib/positioning.test.ts +43 -0
  36. package/tests/lib/storage.test.ts +232 -0
  37. package/tests/lib/targeting.test.ts +191 -0
  38. package/tests/lib/triggers.test.ts +198 -0
  39. package/tests/lib/validation.test.ts +249 -0
  40. package/tests/setup.ts +52 -0
  41. package/tests/tsconfig.json +32 -0
  42. package/tsconfig.json +47 -0
  43. package/types/walkme.types.ts +316 -0
@@ -0,0 +1,188 @@
1
+ 'use client'
2
+
3
+ import { memo, useCallback, useEffect, useRef, useState } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import type { TourStep } from '../types/walkme.types'
6
+ import { useStepPositioning, getPlacementFromPosition } from '../lib/positioning'
7
+ import { WalkmeOverlay } from './WalkmeOverlay'
8
+ import { WalkmeProgress } from './WalkmeProgress'
9
+ import { WalkmeControls } from './WalkmeControls'
10
+
11
+ interface WalkmeSpotlightProps {
12
+ step: TourStep
13
+ targetElement: HTMLElement | null
14
+ onNext: () => void
15
+ onPrev: () => void
16
+ onSkip: () => void
17
+ onComplete: () => void
18
+ isFirst: boolean
19
+ isLast: boolean
20
+ currentIndex: number
21
+ totalSteps: number
22
+ labels?: {
23
+ next?: string
24
+ prev?: string
25
+ skip?: string
26
+ complete?: string
27
+ progress?: string
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Spotlight/highlight that illuminates a specific element
33
+ * with an overlay cutout, a subtle glow ring, and a tooltip explanation.
34
+ * Theme-aware with CSS variables for premium dark/light mode.
35
+ */
36
+ export const WalkmeSpotlight = memo(function WalkmeSpotlight({
37
+ step,
38
+ targetElement,
39
+ onNext,
40
+ onPrev,
41
+ onSkip,
42
+ onComplete,
43
+ isFirst,
44
+ isLast,
45
+ currentIndex,
46
+ totalSteps,
47
+ labels,
48
+ }: WalkmeSpotlightProps) {
49
+ const containerRef = useRef<HTMLDivElement>(null)
50
+
51
+ // Single source of truth for target position — shared by overlay and glow ring
52
+ const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
53
+
54
+ const updateRect = useCallback(() => {
55
+ if (!targetElement) {
56
+ setTargetRect(null)
57
+ return
58
+ }
59
+ setTargetRect(targetElement.getBoundingClientRect())
60
+ }, [targetElement])
61
+
62
+ useEffect(() => {
63
+ updateRect()
64
+ if (!targetElement) return
65
+
66
+ const timer = setTimeout(updateRect, 100)
67
+ const handler = () => updateRect()
68
+ window.addEventListener('scroll', handler, true)
69
+ window.addEventListener('resize', handler)
70
+ return () => {
71
+ clearTimeout(timer)
72
+ window.removeEventListener('scroll', handler, true)
73
+ window.removeEventListener('resize', handler)
74
+ }
75
+ }, [targetElement, updateRect])
76
+
77
+ const { refs, floatingStyles, isStable } = useStepPositioning(targetElement, {
78
+ placement: getPlacementFromPosition(step.position ?? 'bottom'),
79
+ offset: 16,
80
+ padding: 8,
81
+ })
82
+
83
+ useEffect(() => {
84
+ if (isStable) containerRef.current?.focus()
85
+ }, [isStable])
86
+
87
+ if (typeof window === 'undefined') return null
88
+
89
+ return (
90
+ <>
91
+ {/* Overlay with cutout around target */}
92
+ <WalkmeOverlay
93
+ visible
94
+ spotlightTarget={targetElement}
95
+ spotlightPadding={8}
96
+ spotlightRect={targetRect}
97
+ />
98
+
99
+ {/* Glow ring around the spotlighted target */}
100
+ {targetRect && <SpotlightRing rect={targetRect} padding={8} />}
101
+
102
+ {/* Tooltip near the target — only render when target is resolved */}
103
+ {targetElement && createPortal(
104
+ <div
105
+ ref={(el) => {
106
+ refs.setFloating(el)
107
+ ;(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el
108
+ }}
109
+ data-cy="walkme-spotlight"
110
+ data-walkme
111
+ role="dialog"
112
+ aria-label={step.title}
113
+ aria-describedby={`walkme-spotlight-content-${step.id}`}
114
+ tabIndex={-1}
115
+ className="w-80 max-w-[calc(100vw-2rem)] rounded-xl p-4 outline-none"
116
+ style={{
117
+ ...floatingStyles,
118
+ zIndex: 9999,
119
+ backgroundColor: 'var(--walkme-bg, #ffffff)',
120
+ color: 'var(--walkme-text, #111827)',
121
+ border: '1px solid var(--walkme-border, #e5e7eb)',
122
+ boxShadow: 'var(--walkme-shadow, 0 20px 25px -5px rgba(0,0,0,.1), 0 8px 10px -6px rgba(0,0,0,.1))',
123
+ // Hide until floating-ui has stabilized after scroll
124
+ opacity: isStable ? 1 : 0,
125
+ transition: 'opacity 150ms ease-out',
126
+ }}
127
+ >
128
+ <h3 className="mb-1 text-sm font-semibold tracking-tight">{step.title}</h3>
129
+
130
+ <p
131
+ id={`walkme-spotlight-content-${step.id}`}
132
+ className="mb-3 text-sm leading-relaxed"
133
+ style={{ color: 'var(--walkme-text-muted, #6b7280)' }}
134
+ >
135
+ {step.content}
136
+ </p>
137
+
138
+ <div className="mb-3">
139
+ <WalkmeProgress current={currentIndex} total={totalSteps} progressTemplate={labels?.progress} />
140
+ </div>
141
+
142
+ <WalkmeControls
143
+ actions={step.actions}
144
+ onNext={onNext}
145
+ onPrev={onPrev}
146
+ onSkip={onSkip}
147
+ onComplete={onComplete}
148
+ isFirst={isFirst}
149
+ isLast={isLast}
150
+ labels={labels}
151
+ />
152
+ </div>,
153
+ document.body,
154
+ )}
155
+ </>
156
+ )
157
+ })
158
+
159
+ /**
160
+ * Subtle animated glow ring rendered around the spotlighted element.
161
+ * Creates a visual "pulse" that draws the eye to the highlighted area.
162
+ * Receives pre-computed DOMRect from parent to stay in sync with overlay.
163
+ */
164
+ function SpotlightRing({
165
+ rect,
166
+ padding,
167
+ }: {
168
+ rect: DOMRect
169
+ padding: number
170
+ }) {
171
+ return createPortal(
172
+ <div
173
+ data-walkme
174
+ className="pointer-events-none fixed animate-in fade-in-0 duration-300"
175
+ style={{
176
+ zIndex: 9998,
177
+ top: rect.top - padding,
178
+ left: rect.left - padding,
179
+ width: rect.width + padding * 2,
180
+ height: rect.height + padding * 2,
181
+ borderRadius: 8,
182
+ boxShadow: '0 0 0 2px var(--walkme-primary, oklch(0.588 0.243 264.376)), 0 0 16px 4px var(--walkme-primary, oklch(0.588 0.243 264.376 / 0.3))',
183
+ }}
184
+ aria-hidden="true"
185
+ />,
186
+ document.body,
187
+ )
188
+ }
@@ -0,0 +1,152 @@
1
+ 'use client'
2
+
3
+ import { memo, useEffect, useRef } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import type { TourStep } from '../types/walkme.types'
6
+ import { useStepPositioning, getPlacementFromPosition } from '../lib/positioning'
7
+ import { WalkmeProgress } from './WalkmeProgress'
8
+ import { WalkmeControls } from './WalkmeControls'
9
+
10
+ interface WalkmeTooltipProps {
11
+ step: TourStep
12
+ targetElement: HTMLElement | null
13
+ onNext: () => void
14
+ onPrev: () => void
15
+ onSkip: () => void
16
+ onComplete: () => void
17
+ isFirst: boolean
18
+ isLast: boolean
19
+ currentIndex: number
20
+ totalSteps: number
21
+ labels?: {
22
+ next?: string
23
+ prev?: string
24
+ skip?: string
25
+ complete?: string
26
+ progress?: string
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Floating tooltip anchored to a target element.
32
+ * Uses @floating-ui/react for smart positioning.
33
+ * Theme-aware with CSS variables for premium dark/light mode.
34
+ */
35
+ export const WalkmeTooltip = memo(function WalkmeTooltip({
36
+ step,
37
+ targetElement,
38
+ onNext,
39
+ onPrev,
40
+ onSkip,
41
+ onComplete,
42
+ isFirst,
43
+ isLast,
44
+ currentIndex,
45
+ totalSteps,
46
+ labels,
47
+ }: WalkmeTooltipProps) {
48
+ const containerRef = useRef<HTMLDivElement>(null)
49
+
50
+ const { refs, floatingStyles, arrowRef, placement, isStable } = useStepPositioning(
51
+ targetElement,
52
+ {
53
+ placement: getPlacementFromPosition(step.position ?? 'auto'),
54
+ offset: 12,
55
+ padding: 8,
56
+ },
57
+ )
58
+
59
+ // Focus the tooltip when positioning has stabilized
60
+ useEffect(() => {
61
+ if (isStable) containerRef.current?.focus()
62
+ }, [isStable])
63
+
64
+ if (typeof window === 'undefined') return null
65
+ if (!targetElement) return null
66
+
67
+ return createPortal(
68
+ <div
69
+ ref={(el) => {
70
+ refs.setFloating(el)
71
+ ;(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el
72
+ }}
73
+ data-cy="walkme-tooltip"
74
+ data-walkme
75
+ role="dialog"
76
+ aria-label={step.title}
77
+ aria-describedby={`walkme-tooltip-content-${step.id}`}
78
+ tabIndex={-1}
79
+ className="w-80 max-w-[calc(100vw-2rem)] rounded-xl p-4 outline-none"
80
+ style={{
81
+ ...floatingStyles,
82
+ zIndex: 9999,
83
+ backgroundColor: 'var(--walkme-bg, #ffffff)',
84
+ color: 'var(--walkme-text, #111827)',
85
+ border: '1px solid var(--walkme-border, #e5e7eb)',
86
+ boxShadow: 'var(--walkme-shadow, 0 20px 25px -5px rgba(0,0,0,.1), 0 8px 10px -6px rgba(0,0,0,.1))',
87
+ // Hide until floating-ui has stabilized after scroll
88
+ opacity: isStable ? 1 : 0,
89
+ transition: 'opacity 150ms ease-out',
90
+ }}
91
+ >
92
+ {/* Arrow */}
93
+ <div
94
+ ref={arrowRef}
95
+ className="absolute h-2 w-2 rotate-45"
96
+ style={{
97
+ backgroundColor: 'var(--walkme-bg, #ffffff)',
98
+ ...getArrowBorders(placement),
99
+ }}
100
+ />
101
+
102
+ {/* Title */}
103
+ <h3 className="mb-1 text-sm font-semibold tracking-tight">{step.title}</h3>
104
+
105
+ {/* Content */}
106
+ <p
107
+ id={`walkme-tooltip-content-${step.id}`}
108
+ className="mb-3 text-sm leading-relaxed"
109
+ style={{ color: 'var(--walkme-text-muted, #6b7280)' }}
110
+ >
111
+ {step.content}
112
+ </p>
113
+
114
+ {/* Progress */}
115
+ <div className="mb-3">
116
+ <WalkmeProgress current={currentIndex} total={totalSteps} progressTemplate={labels?.progress} />
117
+ </div>
118
+
119
+ {/* Controls */}
120
+ <WalkmeControls
121
+ actions={step.actions}
122
+ onNext={onNext}
123
+ onPrev={onPrev}
124
+ onSkip={onSkip}
125
+ onComplete={onComplete}
126
+ isFirst={isFirst}
127
+ isLast={isLast}
128
+ labels={labels}
129
+ />
130
+ </div>,
131
+ document.body,
132
+ )
133
+ })
134
+
135
+ /** Build individual border-* styles to avoid mixing shorthand + individual properties */
136
+ function getArrowBorders(placement: string): React.CSSProperties {
137
+ const b = '1px solid var(--walkme-border, #e5e7eb)'
138
+ if (placement.startsWith('bottom')) {
139
+ // Arrow points up — show top + left borders
140
+ return { top: -5, borderTop: b, borderLeft: b, borderBottom: 'none', borderRight: 'none' }
141
+ }
142
+ if (placement.startsWith('top')) {
143
+ // Arrow points down — show bottom + right borders
144
+ return { bottom: -5, borderBottom: b, borderRight: b, borderTop: 'none', borderLeft: 'none' }
145
+ }
146
+ if (placement.startsWith('left')) {
147
+ // Arrow points right — show right + bottom borders
148
+ return { right: -5, borderRight: b, borderBottom: b, borderTop: 'none', borderLeft: 'none' }
149
+ }
150
+ // Arrow points left — show top + left borders
151
+ return { left: -5, borderTop: b, borderLeft: b, borderBottom: 'none', borderRight: 'none' }
152
+ }
@@ -0,0 +1,38 @@
1
+ import type { Tour } from '../types/walkme.types'
2
+
3
+ /**
4
+ * Basic single-page tour example.
5
+ * Shows a welcome modal, then highlights sidebar navigation and create button.
6
+ */
7
+ export const basicTour: Tour = {
8
+ id: 'getting-started',
9
+ name: 'Getting Started',
10
+ description: 'Learn the basics of the application',
11
+ trigger: { type: 'onFirstVisit', delay: 1000 },
12
+ steps: [
13
+ {
14
+ id: 'welcome',
15
+ type: 'modal',
16
+ title: 'Welcome!',
17
+ content: 'Let us show you around the application. This quick tour will help you get started.',
18
+ actions: ['next', 'skip'],
19
+ },
20
+ {
21
+ id: 'sidebar',
22
+ type: 'tooltip',
23
+ target: '[data-cy="sidebar-nav"]',
24
+ title: 'Navigation',
25
+ content: 'Use the sidebar to navigate between different sections of the app.',
26
+ position: 'right',
27
+ actions: ['next', 'prev', 'skip'],
28
+ },
29
+ {
30
+ id: 'create-btn',
31
+ type: 'spotlight',
32
+ target: '[data-cy="create-button"]',
33
+ title: 'Create New Item',
34
+ content: 'Click here to create your first item. Give it a try!',
35
+ actions: ['complete', 'prev'],
36
+ },
37
+ ],
38
+ }
@@ -0,0 +1,56 @@
1
+ import type { Tour } from '../types/walkme.types'
2
+
3
+ /**
4
+ * Conditional tour example.
5
+ * Only shown to users with 'admin' role after they've completed the basic tour.
6
+ */
7
+ export const adminFeaturesTour: Tour = {
8
+ id: 'admin-features',
9
+ name: 'Admin Features',
10
+ description: 'Tour of admin-only functionality',
11
+ priority: 10,
12
+ trigger: {
13
+ type: 'onRouteEnter',
14
+ route: '/admin/*',
15
+ delay: 500,
16
+ },
17
+ conditions: {
18
+ userRole: ['admin', 'superadmin'],
19
+ completedTours: ['getting-started'],
20
+ featureFlags: ['admin-panel-v2'],
21
+ },
22
+ steps: [
23
+ {
24
+ id: 'admin-welcome',
25
+ type: 'modal',
26
+ title: 'Admin Dashboard',
27
+ content: 'Welcome to the admin area. Here are the key features available to you.',
28
+ actions: ['next', 'skip'],
29
+ },
30
+ {
31
+ id: 'user-management',
32
+ type: 'tooltip',
33
+ target: '[data-cy="admin-users"]',
34
+ title: 'User Management',
35
+ content: 'Manage all user accounts, roles, and permissions from here.',
36
+ position: 'right',
37
+ actions: ['next', 'prev', 'skip'],
38
+ },
39
+ {
40
+ id: 'analytics',
41
+ type: 'spotlight',
42
+ target: '[data-cy="admin-analytics"]',
43
+ title: 'Analytics',
44
+ content: 'View detailed analytics and reports about app usage.',
45
+ actions: ['next', 'prev', 'skip'],
46
+ },
47
+ {
48
+ id: 'system-config',
49
+ type: 'beacon',
50
+ target: '[data-cy="admin-config"]',
51
+ title: 'System Configuration',
52
+ content: 'Advanced system settings are available here.',
53
+ actions: ['complete'],
54
+ },
55
+ ],
56
+ }
@@ -0,0 +1,54 @@
1
+ import type { Tour } from '../types/walkme.types'
2
+
3
+ /**
4
+ * Cross-window (multi-page) tour example.
5
+ * Navigates between dashboard and settings pages.
6
+ */
7
+ export const crossWindowTour: Tour = {
8
+ id: 'explore-app',
9
+ name: 'Explore the App',
10
+ description: 'Tour across multiple pages',
11
+ trigger: { type: 'manual' },
12
+ conditions: {
13
+ completedTours: ['getting-started'],
14
+ },
15
+ steps: [
16
+ {
17
+ id: 'dashboard-overview',
18
+ type: 'modal',
19
+ title: 'Explore the App',
20
+ content: "Now that you know the basics, let's explore the key areas of the app.",
21
+ route: '/dashboard',
22
+ actions: ['next', 'skip'],
23
+ },
24
+ {
25
+ id: 'dashboard-stats',
26
+ type: 'tooltip',
27
+ target: '[data-cy="dashboard-stats"]',
28
+ title: 'Your Stats',
29
+ content: 'Here you can see your key metrics at a glance.',
30
+ position: 'bottom',
31
+ route: '/dashboard',
32
+ actions: ['next', 'prev', 'skip'],
33
+ },
34
+ {
35
+ id: 'settings-nav',
36
+ type: 'tooltip',
37
+ target: '[data-cy="nav-settings"]',
38
+ title: 'Settings',
39
+ content: "Let's head to settings to configure your profile.",
40
+ position: 'right',
41
+ route: '/dashboard',
42
+ actions: ['next', 'prev', 'skip'],
43
+ },
44
+ {
45
+ id: 'profile-settings',
46
+ type: 'spotlight',
47
+ target: '[data-cy="profile-form"]',
48
+ title: 'Your Profile',
49
+ content: 'Complete your profile information to get the most out of the app.',
50
+ route: '/settings/profile',
51
+ actions: ['complete', 'prev'],
52
+ },
53
+ ],
54
+ }
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import { useWalkmeContext } from '../providers/walkme-context'
4
+
5
+ /**
6
+ * Hook for accessing the state of a specific tour.
7
+ *
8
+ * @param tourId - The ID of the tour to track
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * const { isCompleted, progress, start } = useTour('onboarding')
13
+ *
14
+ * if (!isCompleted) {
15
+ * return <button onClick={start}>Start Onboarding</button>
16
+ * }
17
+ * ```
18
+ */
19
+ export function useTour(tourId: string) {
20
+ const ctx = useWalkmeContext()
21
+ const tour = ctx.state.tours[tourId] ?? null
22
+ const isActive = ctx.state.activeTour?.tourId === tourId
23
+ const isCompleted = ctx.state.completedTours.includes(tourId)
24
+ const isSkipped = ctx.state.skippedTours.includes(tourId)
25
+ const currentStep = isActive ? (ctx.state.activeTour?.currentStepIndex ?? -1) : -1
26
+ const totalSteps = tour?.steps.length ?? 0
27
+ const progress =
28
+ totalSteps > 0 && currentStep >= 0
29
+ ? Math.round(((currentStep + 1) / totalSteps) * 100)
30
+ : 0
31
+
32
+ return {
33
+ /** The full tour definition (null if not found) */
34
+ tour,
35
+ /** Whether this tour is currently active */
36
+ isActive,
37
+ /** Whether this tour has been completed */
38
+ isCompleted,
39
+ /** Whether this tour has been skipped */
40
+ isSkipped,
41
+ /** Current step index (-1 if not active) */
42
+ currentStep,
43
+ /** Total number of steps */
44
+ totalSteps,
45
+ /** Progress percentage (0-100) */
46
+ progress,
47
+ /** Start this tour */
48
+ start: () => ctx.startTour(tourId),
49
+ /** Reset this tour (remove from completed/skipped) */
50
+ reset: () => ctx.resetTour(tourId),
51
+ }
52
+ }
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { useWalkmeContext } from '../providers/walkme-context'
4
+
5
+ /**
6
+ * Hook for tracking global tour completion progress.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * const { completedTours, totalTours, percentage } = useTourProgress()
11
+ *
12
+ * return (
13
+ * <div>Onboarding: {percentage}% complete ({completedTours}/{totalTours})</div>
14
+ * )
15
+ * ```
16
+ */
17
+ export function useTourProgress() {
18
+ const ctx = useWalkmeContext()
19
+ const totalTours = Object.keys(ctx.state.tours).length
20
+ const completedTours = ctx.state.completedTours.length
21
+ const percentage =
22
+ totalTours > 0 ? Math.round((completedTours / totalTours) * 100) : 0
23
+
24
+ return {
25
+ /** Number of completed tours */
26
+ completedTours,
27
+ /** Total number of registered tours */
28
+ totalTours,
29
+ /** Completion percentage (0-100) */
30
+ percentage,
31
+ /** IDs of completed tours */
32
+ completedTourIds: ctx.state.completedTours,
33
+ /** IDs of skipped tours */
34
+ skippedTourIds: ctx.state.skippedTours,
35
+ /** Number of remaining tours */
36
+ remainingTours: totalTours - completedTours,
37
+ }
38
+ }