@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,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
|
+
})
|