Skip to content

Commit ecd68fb

Browse files
authored
feat(product tours): support 'modal' steps (#2723)
* feat(product tours): support 'modal' steps * feat(product tours): support auto-launch field, reduce bundle size
1 parent 71d3753 commit ecd68fb

File tree

8 files changed

+275
-138
lines changed

8 files changed

+275
-138
lines changed

.changeset/famous-loops-deny.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': minor
3+
---
4+
5+
product tours: support auto-show config, add modal steps

packages/browser/src/extensions/product-tours/components/ProductTourTooltip.tsx

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface ProductTourTooltipProps {
2121
step: ProductTourStep
2222
stepIndex: number
2323
totalSteps: number
24-
targetElement: HTMLElement
24+
targetElement: HTMLElement | null
2525
onNext: () => void
2626
onPrevious: () => void
2727
onDismiss: (reason: ProductTourDismissReason) => void
@@ -112,11 +112,16 @@ export function ProductTourTooltip({
112112
const isTransitioningRef = useRef(false)
113113
const isFirstRender = useRef(true)
114114

115+
const isModalStep = !targetElement
116+
115117
const updatePosition = useCallback(() => {
118+
if (isModalStep) {
119+
return
120+
}
116121
const rect = targetElement.getBoundingClientRect()
117122
setPosition(calculateTooltipPosition(rect))
118123
setSpotlightStyle(getSpotlightStyle(rect))
119-
}, [targetElement])
124+
}, [targetElement, isModalStep])
120125

121126
useEffect(() => {
122127
const isStepChange = previousStepRef.current !== stepIndex
@@ -128,6 +133,13 @@ export function ProductTourTooltip({
128133
previousStepRef.current = stepIndex
129134
isTransitioningRef.current = true
130135

136+
if (isModalStep) {
137+
// Modal steps are just centered on screen - no positioning needed
138+
setTransitionState('visible')
139+
isTransitioningRef.current = false
140+
return
141+
}
142+
131143
scrollToElement(targetElement, () => {
132144
if (previousStepRef.current !== currentStepIndex) {
133145
return
@@ -157,6 +169,18 @@ export function ProductTourTooltip({
157169
setDisplayedStepIndex(stepIndex)
158170
setTransitionState('entering')
159171

172+
if (isModalStep) {
173+
// Modal steps don't need scrolling or position calculation
174+
setTimeout(() => {
175+
if (previousStepRef.current !== currentStepIndex) {
176+
return
177+
}
178+
setTransitionState('visible')
179+
isTransitioningRef.current = false
180+
}, 50)
181+
return
182+
}
183+
160184
scrollToElement(targetElement, () => {
161185
if (previousStepRef.current !== currentStepIndex) {
162186
return
@@ -173,10 +197,10 @@ export function ProductTourTooltip({
173197
})
174198
}, 150)
175199
}
176-
}, [targetElement, stepIndex, step, updatePosition])
200+
}, [targetElement, stepIndex, step, updatePosition, isModalStep])
177201

178202
useEffect(() => {
179-
if (transitionState !== 'visible') {
203+
if (transitionState !== 'visible' || isModalStep) {
180204
return
181205
}
182206

@@ -193,7 +217,7 @@ export function ProductTourTooltip({
193217
window?.removeEventListener('scroll', handleUpdate, true)
194218
window?.removeEventListener('resize', handleUpdate)
195219
}
196-
}, [updatePosition, transitionState])
220+
}, [updatePosition, transitionState, isModalStep])
197221

198222
useEffect(() => {
199223
const handleKeyDown = (e: KeyboardEvent) => {
@@ -228,7 +252,7 @@ export function ProductTourTooltip({
228252
'--ph-tour-border-color': appearance.borderColor,
229253
} as h.JSX.CSSProperties
230254

231-
const isReady = transitionState !== 'initializing' && position && spotlightStyle
255+
const isReady = isModalStep || (transitionState !== 'initializing' && position && spotlightStyle)
232256
const isVisible = transitionState === 'visible'
233257

234258
if (!isReady) {
@@ -240,14 +264,66 @@ export function ProductTourTooltip({
240264
)
241265
}
242266

267+
// Modal step: centered on screen with overlay dimming, no spotlight/arrow
268+
if (isModalStep) {
269+
return (
270+
<div class="ph-tour-container" style={containerStyle}>
271+
<div class="ph-tour-click-overlay" onClick={handleOverlayClick} />
272+
<div class="ph-tour-modal-overlay" />
273+
<div class="ph-tour-tooltip ph-tour-tooltip--modal" onClick={handleTooltipClick}>
274+
<button
275+
class="ph-tour-dismiss"
276+
onClick={() => onDismiss('user_clicked_skip')}
277+
aria-label="Close tour"
278+
>
279+
{cancelSVG}
280+
</button>
281+
282+
<div
283+
class="ph-tour-content"
284+
dangerouslySetInnerHTML={{ __html: renderTipTapContent(displayedStep.content) }}
285+
/>
286+
287+
<div class="ph-tour-footer">
288+
<span class="ph-tour-progress">
289+
{displayedStepIndex + 1} of {totalSteps}
290+
</span>
291+
292+
<div class="ph-tour-buttons">
293+
{!isFirstStep && (
294+
<button class="ph-tour-button ph-tour-button--secondary" onClick={onPrevious}>
295+
Back
296+
</button>
297+
)}
298+
<button class="ph-tour-button ph-tour-button--primary" onClick={onNext}>
299+
{isLastStep ? 'Done' : 'Next'}
300+
</button>
301+
</div>
302+
</div>
303+
304+
{!appearance.whiteLabel && (
305+
<a
306+
href="https://posthog.com/product-tours"
307+
target="_blank"
308+
rel="noopener noreferrer"
309+
class="ph-tour-branding"
310+
>
311+
Tour by {IconPosthogLogo}
312+
</a>
313+
)}
314+
</div>
315+
</div>
316+
)
317+
}
318+
243319
return (
244320
<div class="ph-tour-container" style={containerStyle}>
245321
<div class="ph-tour-click-overlay" onClick={handleOverlayClick} />
246322

247323
<div
248324
class="ph-tour-spotlight"
249325
style={
250-
isVisible
326+
isVisible && spotlightStyle
251327
? spotlightStyle
252328
: {
253329
top: '50%',
@@ -261,15 +337,15 @@ export function ProductTourTooltip({
261337
<div
262338
class={`ph-tour-tooltip ${isVisible ? 'ph-tour-tooltip--visible' : 'ph-tour-tooltip--hidden'}`}
263339
style={{
264-
top: `${position.top}px`,
265-
left: `${position.left}px`,
340+
top: `${position!.top}px`,
341+
left: `${position!.left}px`,
266342
opacity: isVisible ? 1 : 0,
267343
transform: isVisible ? 'translateY(0)' : 'translateY(10px)',
268344
transition: 'opacity 0.15s ease-out, transform 0.15s ease-out',
269345
}}
270346
onClick={handleTooltipClick}
271347
>
272-
<div class={`ph-tour-arrow ph-tour-arrow--${getOppositePosition(position.position)}`} />
348+
<div class={`ph-tour-arrow ph-tour-arrow--${getOppositePosition(position!.position)}`} />
273349

274350
<button class="ph-tour-dismiss" onClick={() => onDismiss('user_clicked_skip')} aria-label="Close tour">
275351
{cancelSVG}

packages/browser/src/extensions/product-tours/product-tour.css

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@
4949
pointer-events: none;
5050
}
5151

52+
.ph-tour-modal-overlay {
53+
position: fixed;
54+
top: 0;
55+
left: 0;
56+
right: 0;
57+
bottom: 0;
58+
z-index: var(--ph-tour-z-index);
59+
background: var(--ph-tour-overlay-color);
60+
pointer-events: none;
61+
}
62+
5263
.ph-tour-tooltip {
5364
position: fixed;
5465
z-index: calc(var(--ph-tour-z-index) + 1);
@@ -63,6 +74,13 @@
6374
pointer-events: auto;
6475
}
6576

77+
.ph-tour-tooltip--modal {
78+
top: 50%;
79+
left: 50%;
80+
transform: translate(-50%, -50%);
81+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2), 0 2px 8px rgba(0, 0, 0, 0.1);
82+
}
83+
6684
.ph-tour-arrow {
6785
position: absolute;
6886
width: 0;
@@ -252,6 +270,19 @@
252270
}
253271
}
254272

273+
@keyframes ph-tour-modal-fade-in {
274+
from {
275+
opacity: 0;
276+
}
277+
to {
278+
opacity: 1;
279+
}
280+
}
281+
255282
.ph-tour-tooltip {
256283
animation: ph-tour-fade-in 0.2s ease-out;
257284
}
285+
286+
.ph-tour-tooltip--modal {
287+
animation: ph-tour-modal-fade-in 0.2s ease-out;
288+
}

0 commit comments

Comments
 (0)