@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,64 @@
1
+ 'use client'
2
+
3
+ import { memo } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import type { TourStep } from '../types/walkme.types'
6
+ import { useStepPositioning } from '../lib/positioning'
7
+
8
+ interface WalkmeBeaconProps {
9
+ step: TourStep
10
+ targetElement: HTMLElement | null
11
+ onClick: () => void
12
+ labels?: {
13
+ tourAvailable?: string
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Pulsing beacon/hotspot indicator near a target element.
19
+ * Clicking it starts or advances the tour.
20
+ */
21
+ export const WalkmeBeacon = memo(function WalkmeBeacon({
22
+ step,
23
+ targetElement,
24
+ onClick,
25
+ labels,
26
+ }: WalkmeBeaconProps) {
27
+ const { refs, floatingStyles } = useStepPositioning(targetElement, {
28
+ placement: 'top-end',
29
+ offset: 4,
30
+ padding: 8,
31
+ })
32
+
33
+ if (typeof window === 'undefined') return null
34
+
35
+ return createPortal(
36
+ <button
37
+ ref={refs.setFloating}
38
+ data-cy="walkme-beacon"
39
+ data-walkme
40
+ onClick={onClick}
41
+ type="button"
42
+ role="button"
43
+ aria-label={step.title || labels?.tourAvailable || 'Tour available'}
44
+ tabIndex={0}
45
+ className="cursor-pointer relative flex h-6 w-6 items-center justify-center rounded-full outline-none"
46
+ style={{
47
+ ...floatingStyles,
48
+ zIndex: 9999,
49
+ }}
50
+ >
51
+ {/* Pulse ring */}
52
+ <span
53
+ className="absolute inset-0 animate-ping rounded-full opacity-75"
54
+ style={{ backgroundColor: 'var(--walkme-beacon-color, #3b82f6)' }}
55
+ />
56
+ {/* Core dot */}
57
+ <span
58
+ className="relative h-3 w-3 rounded-full"
59
+ style={{ backgroundColor: 'var(--walkme-beacon-color, #3b82f6)' }}
60
+ />
61
+ </button>,
62
+ document.body,
63
+ )
64
+ })
@@ -0,0 +1,111 @@
1
+ 'use client'
2
+
3
+ import { memo } from 'react'
4
+ import type { StepAction } from '../types/walkme.types'
5
+
6
+ interface WalkmeControlsProps {
7
+ actions: StepAction[]
8
+ onNext: () => void
9
+ onPrev: () => void
10
+ onSkip: () => void
11
+ onComplete: () => void
12
+ isFirst: boolean
13
+ isLast: boolean
14
+ labels?: {
15
+ next?: string
16
+ prev?: string
17
+ skip?: string
18
+ complete?: string
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Navigation button group for tour steps.
24
+ * Premium styling with hover/active states and proper spacing.
25
+ */
26
+ export const WalkmeControls = memo(function WalkmeControls({
27
+ actions,
28
+ onNext,
29
+ onPrev,
30
+ onSkip,
31
+ onComplete,
32
+ isFirst,
33
+ isLast,
34
+ labels,
35
+ }: WalkmeControlsProps) {
36
+ const showPrev = actions.includes('prev') && !isFirst
37
+ const showNext = actions.includes('next') && !isLast
38
+ const showSkip = actions.includes('skip')
39
+ const showComplete =
40
+ actions.includes('complete') || (isLast && actions.includes('next'))
41
+
42
+ return (
43
+ <div
44
+ data-cy="walkme-controls"
45
+ data-walkme
46
+ className="flex items-center justify-between gap-2"
47
+ >
48
+ <div className="flex gap-2">
49
+ {showSkip && (
50
+ <button
51
+ data-cy="walkme-btn-skip"
52
+ onClick={onSkip}
53
+ type="button"
54
+ className="cursor-pointer rounded-lg px-3 py-1.5 text-sm transition-all duration-150 hover:opacity-80 active:scale-95"
55
+ style={{
56
+ color: 'var(--walkme-text-muted, #6b7280)',
57
+ backgroundColor: 'transparent',
58
+ }}
59
+ >
60
+ {labels?.skip ?? 'Skip'}
61
+ </button>
62
+ )}
63
+ </div>
64
+
65
+ <div className="flex gap-2">
66
+ {showPrev && (
67
+ <button
68
+ data-cy="walkme-btn-prev"
69
+ onClick={onPrev}
70
+ type="button"
71
+ className="cursor-pointer rounded-lg px-3.5 py-1.5 text-sm font-medium transition-all duration-150 hover:opacity-90 active:scale-95"
72
+ style={{
73
+ color: 'var(--walkme-text, #111827)',
74
+ backgroundColor: 'var(--walkme-border, #e5e7eb)',
75
+ }}
76
+ >
77
+ {labels?.prev ?? 'Previous'}
78
+ </button>
79
+ )}
80
+
81
+ {showNext && (
82
+ <button
83
+ data-cy="walkme-btn-next"
84
+ onClick={onNext}
85
+ type="button"
86
+ className="cursor-pointer rounded-lg px-4 py-1.5 text-sm font-semibold text-white transition-all duration-150 hover:brightness-110 active:scale-95"
87
+ style={{
88
+ backgroundColor: 'var(--walkme-primary, #3b82f6)',
89
+ }}
90
+ >
91
+ {labels?.next ?? 'Next'}
92
+ </button>
93
+ )}
94
+
95
+ {showComplete && (
96
+ <button
97
+ data-cy="walkme-btn-complete"
98
+ onClick={onComplete}
99
+ type="button"
100
+ className="cursor-pointer rounded-lg px-4 py-1.5 text-sm font-semibold text-white transition-all duration-150 hover:brightness-110 active:scale-95"
101
+ style={{
102
+ backgroundColor: 'var(--walkme-primary, #3b82f6)',
103
+ }}
104
+ >
105
+ {labels?.complete ?? 'Complete'}
106
+ </button>
107
+ )}
108
+ </div>
109
+ </div>
110
+ )
111
+ })
@@ -0,0 +1,144 @@
1
+ 'use client'
2
+
3
+ import { memo, useEffect, useRef } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import { X } from '@phosphor-icons/react'
6
+ import type { TourStep } from '../types/walkme.types'
7
+ import { WalkmeProgress } from './WalkmeProgress'
8
+ import { WalkmeControls } from './WalkmeControls'
9
+
10
+ interface WalkmeModalProps {
11
+ step: TourStep
12
+ onNext: () => void
13
+ onPrev: () => void
14
+ onSkip: () => void
15
+ onComplete: () => void
16
+ isFirst: boolean
17
+ isLast: boolean
18
+ currentIndex: number
19
+ totalSteps: number
20
+ labels?: {
21
+ close?: string
22
+ next?: string
23
+ prev?: string
24
+ skip?: string
25
+ complete?: string
26
+ progress?: string
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Centered modal dialog for tour steps.
32
+ * Includes focus trap and keyboard handling.
33
+ * Uses theme-aware CSS variables for premium dark/light mode support.
34
+ */
35
+ export const WalkmeModal = memo(function WalkmeModal({
36
+ step,
37
+ onNext,
38
+ onPrev,
39
+ onSkip,
40
+ onComplete,
41
+ isFirst,
42
+ isLast,
43
+ currentIndex,
44
+ totalSteps,
45
+ labels,
46
+ }: WalkmeModalProps) {
47
+ const containerRef = useRef<HTMLDivElement>(null)
48
+
49
+ // Focus trap: keep focus within the modal
50
+ useEffect(() => {
51
+ containerRef.current?.focus()
52
+
53
+ const handleTab = (e: KeyboardEvent) => {
54
+ if (e.key !== 'Tab' || !containerRef.current) return
55
+
56
+ const focusable = containerRef.current.querySelectorAll<HTMLElement>(
57
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
58
+ )
59
+ if (focusable.length === 0) return
60
+
61
+ const first = focusable[0]
62
+ const last = focusable[focusable.length - 1]
63
+
64
+ if (e.shiftKey && document.activeElement === first) {
65
+ e.preventDefault()
66
+ last.focus()
67
+ } else if (!e.shiftKey && document.activeElement === last) {
68
+ e.preventDefault()
69
+ first.focus()
70
+ }
71
+ }
72
+
73
+ document.addEventListener('keydown', handleTab)
74
+ return () => document.removeEventListener('keydown', handleTab)
75
+ }, [step.id])
76
+
77
+ if (typeof window === 'undefined') return null
78
+
79
+ return createPortal(
80
+ <div
81
+ ref={containerRef}
82
+ data-cy="walkme-modal"
83
+ data-walkme
84
+ role="dialog"
85
+ aria-modal="true"
86
+ aria-label={step.title}
87
+ aria-describedby={`walkme-modal-content-${step.id}`}
88
+ tabIndex={-1}
89
+ className="fixed left-1/2 top-1/2 w-[28rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl p-6 outline-none animate-in fade-in-0 zoom-in-95 duration-300"
90
+ style={{
91
+ zIndex: 9999,
92
+ backgroundColor: 'var(--walkme-bg, #ffffff)',
93
+ color: 'var(--walkme-text, #111827)',
94
+ border: '1px solid var(--walkme-border, #e5e7eb)',
95
+ boxShadow: 'var(--walkme-shadow, 0 20px 25px -5px rgba(0,0,0,.1), 0 8px 10px -6px rgba(0,0,0,.1))',
96
+ }}
97
+ >
98
+ {/* Close button */}
99
+ <button
100
+ data-cy="walkme-btn-close"
101
+ onClick={onSkip}
102
+ type="button"
103
+ className="cursor-pointer absolute right-3 top-3 rounded-lg p-1.5 transition-all duration-150 hover:scale-110 hover:bg-black/5 active:scale-95"
104
+ style={{
105
+ color: 'var(--walkme-text-muted, #6b7280)',
106
+ backgroundColor: 'transparent',
107
+ }}
108
+ aria-label={labels?.close ?? 'Close'}
109
+ >
110
+ <X size={16} weight="bold" />
111
+ </button>
112
+
113
+ {/* Title */}
114
+ <h2 className="mb-2 pr-8 text-lg font-semibold tracking-tight">{step.title}</h2>
115
+
116
+ {/* Content */}
117
+ <p
118
+ id={`walkme-modal-content-${step.id}`}
119
+ className="mb-5 text-sm leading-relaxed"
120
+ style={{ color: 'var(--walkme-text-muted, #6b7280)' }}
121
+ >
122
+ {step.content}
123
+ </p>
124
+
125
+ {/* Progress */}
126
+ <div className="mb-4">
127
+ <WalkmeProgress current={currentIndex} total={totalSteps} progressTemplate={labels?.progress} />
128
+ </div>
129
+
130
+ {/* Controls */}
131
+ <WalkmeControls
132
+ actions={step.actions}
133
+ onNext={onNext}
134
+ onPrev={onPrev}
135
+ onSkip={onSkip}
136
+ onComplete={onComplete}
137
+ isFirst={isFirst}
138
+ isLast={isLast}
139
+ labels={labels}
140
+ />
141
+ </div>,
142
+ document.body,
143
+ )
144
+ })
@@ -0,0 +1,107 @@
1
+ 'use client'
2
+
3
+ import { memo, useEffect, useState, useCallback } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+
6
+ interface WalkmeOverlayProps {
7
+ visible: boolean
8
+ onClick?: () => void
9
+ spotlightTarget?: HTMLElement | null
10
+ spotlightPadding?: number
11
+ /** Pre-computed target rect — when provided, skip internal scroll/resize tracking */
12
+ spotlightRect?: DOMRect | null
13
+ }
14
+
15
+ /**
16
+ * Full-screen dark backdrop overlay.
17
+ * Supports an optional spotlight cutout to highlight a target element.
18
+ * Dynamically tracks target position on scroll/resize.
19
+ */
20
+ export const WalkmeOverlay = memo(function WalkmeOverlay({
21
+ visible,
22
+ onClick,
23
+ spotlightTarget,
24
+ spotlightPadding = 8,
25
+ spotlightRect: externalRect,
26
+ }: WalkmeOverlayProps) {
27
+ const [internalClipPath, setInternalClipPath] = useState<string | undefined>(undefined)
28
+
29
+ // When parent provides a pre-computed rect, derive clip-path from it directly
30
+ const externalClipPath = externalRect
31
+ ? getSpotlightClipPathFromRect(externalRect, spotlightPadding)
32
+ : undefined
33
+
34
+ const recalculate = useCallback(() => {
35
+ if (!spotlightTarget) {
36
+ setInternalClipPath(undefined)
37
+ return
38
+ }
39
+ setInternalClipPath(getSpotlightClipPathFromRect(spotlightTarget.getBoundingClientRect(), spotlightPadding))
40
+ }, [spotlightTarget, spotlightPadding])
41
+
42
+ // Self-tracking mode: only active when no external rect is provided
43
+ useEffect(() => {
44
+ if (externalRect !== undefined) return
45
+ recalculate()
46
+ }, [recalculate, externalRect])
47
+
48
+ useEffect(() => {
49
+ if (externalRect !== undefined) return // skip self-tracking when parent provides rect
50
+ if (!spotlightTarget) return
51
+
52
+ const initialTimer = setTimeout(recalculate, 100)
53
+
54
+ const handler = () => recalculate()
55
+ window.addEventListener('scroll', handler, true)
56
+ window.addEventListener('resize', handler)
57
+
58
+ return () => {
59
+ clearTimeout(initialTimer)
60
+ window.removeEventListener('scroll', handler, true)
61
+ window.removeEventListener('resize', handler)
62
+ }
63
+ }, [spotlightTarget, recalculate, externalRect])
64
+
65
+ const clipPath = externalRect !== undefined ? externalClipPath : internalClipPath
66
+
67
+ if (typeof window === 'undefined') return null
68
+ if (!visible) return null
69
+
70
+ return createPortal(
71
+ <div
72
+ data-cy="walkme-overlay"
73
+ data-walkme
74
+ onClick={onClick}
75
+ className="fixed inset-0 transition-opacity duration-300 ease-in-out"
76
+ style={{
77
+ zIndex: 9998,
78
+ backgroundColor: 'var(--walkme-overlay-bg, rgba(0, 0, 0, 0.65))',
79
+ backdropFilter: 'blur(4px)',
80
+ WebkitBackdropFilter: 'blur(4px)',
81
+ clipPath,
82
+ }}
83
+ aria-hidden="true"
84
+ />,
85
+ document.body,
86
+ )
87
+ })
88
+
89
+ /** Generate a clip-path that cuts out a rectangle from a DOMRect */
90
+ function getSpotlightClipPathFromRect(
91
+ rect: DOMRect,
92
+ padding: number,
93
+ ): string {
94
+ const top = Math.max(0, rect.top - padding)
95
+ const left = Math.max(0, rect.left - padding)
96
+ const bottom = Math.min(window.innerHeight, rect.bottom + padding)
97
+ const right = Math.min(window.innerWidth, rect.right + padding)
98
+
99
+ // polygon that covers everything EXCEPT the target area
100
+ return `polygon(
101
+ 0% 0%, 0% 100%,
102
+ ${left}px 100%, ${left}px ${top}px,
103
+ ${right}px ${top}px, ${right}px ${bottom}px,
104
+ ${left}px ${bottom}px, ${left}px 100%,
105
+ 100% 100%, 100% 0%
106
+ )`
107
+ }
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import { memo } from 'react'
4
+
5
+ interface WalkmeProgressProps {
6
+ current: number
7
+ total: number
8
+ /** Template string for progress label, e.g. "Step {current} of {total}" */
9
+ progressTemplate?: string
10
+ }
11
+
12
+ /**
13
+ * Progress bar indicator showing current step within a tour.
14
+ * Uses theme-aware CSS variables with smooth transitions.
15
+ */
16
+ export const WalkmeProgress = memo(function WalkmeProgress({
17
+ current,
18
+ total,
19
+ progressTemplate,
20
+ }: WalkmeProgressProps) {
21
+ const percentage = total > 0 ? Math.round(((current + 1) / total) * 100) : 0
22
+ const progressLabel = (progressTemplate ?? 'Step {current} of {total}')
23
+ .replace('{current}', String(current + 1))
24
+ .replace('{total}', String(total))
25
+
26
+ return (
27
+ <div data-cy="walkme-progress" data-walkme className="flex items-center gap-3">
28
+ <div
29
+ className="h-1 flex-1 overflow-hidden rounded-full"
30
+ style={{ backgroundColor: 'var(--walkme-border, #e5e7eb)' }}
31
+ role="progressbar"
32
+ aria-valuenow={current + 1}
33
+ aria-valuemin={1}
34
+ aria-valuemax={total}
35
+ aria-label={progressLabel}
36
+ >
37
+ <div
38
+ className="h-full rounded-full transition-all duration-500 ease-out"
39
+ style={{
40
+ width: `${percentage}%`,
41
+ backgroundColor: 'var(--walkme-primary, #3b82f6)',
42
+ }}
43
+ />
44
+ </div>
45
+ <span
46
+ className="text-xs tabular-nums whitespace-nowrap"
47
+ style={{ color: 'var(--walkme-text-muted, #6b7280)' }}
48
+ >
49
+ {current + 1} / {total}
50
+ </span>
51
+ </div>
52
+ )
53
+ })