Skip to content

Commit 0163c71

Browse files
authored
feat(product tours): click element to advance to next step (#2783)
## Problem product tours only support progression through a "next" button, which means you cannot build a tour that requires any user interaction (e.g. opening a drawer or modal, etc) <!-- Who are we building for, what are their needs, why is this important? --> ## Changes adds support for a new `click`\-type trigger that will progress the tour when the target element is clicked. after click, the tour waits up to 2s for the next step's element to be visible. also: - removes the strict validation that "all elements must be in the dom at tour start" - adds URL "normalization" to strip trailing slashes when the operator is `exact` (PR to add this note in main repo coming soon) [product-tour-click-action.mp4 <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.com/user-attachments/thumbnails/47ac12a5-4f73-4e73-af8b-035c909da6ff.mp4" />](https://app.graphite.com/user-attachments/video/47ac12a5-4f73-4e73-af8b-035c909da6ff.mp4) <!-- What is changed and what information would be useful to a reviewer? --> ## Release info Sub-libraries affected ### Libraries affected <!-- Please mark which libraries will require a version bump. --> - [ ] All of them - [x] posthog-js (web) - [ ] posthog-js-lite (web lite) - [ ] posthog-node - [ ] posthog-react-native - [ ] @posthog/react - [ ] @posthog/ai - [ ] @posthog/nextjs-config - [ ] @posthog/nuxt - [ ] @posthog/rollup-plugin - [ ] @posthog/webpack-plugin ## Checklist - [ ] Tests for new code - [x] Accounted for the impact of any changes across different platforms - [x] Accounted for backwards compatibility of any changes (no breaking changes!) - [x] Took care not to unnecessarily increase the bundle size ### If releasing new changes - [x] Ran `pnpm changeset` to generate a changeset file - [x] Added the "release" label to the PR to indicate we're publishing new versions for the affected packages <!-- For more details check RELEASING.md -->
1 parent ee5e76d commit 0163c71

File tree

6 files changed

+118
-18
lines changed

6 files changed

+118
-18
lines changed

.changeset/late-pianos-share.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: enable click-element-to-progress steps

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

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,14 @@ export function ProductTourTooltip({
240240
e.stopPropagation()
241241
}
242242

243+
const handleSpotlightClick = (e: MouseEvent) => {
244+
e.stopPropagation()
245+
if (targetElement) {
246+
targetElement.click()
247+
}
248+
onNext()
249+
}
250+
243251
const isLastStep = displayedStepIndex >= totalSteps - 1
244252
const isFirstStep = displayedStepIndex === 0
245253

@@ -295,6 +303,7 @@ export function ProductTourTooltip({
295303
Back
296304
</button>
297305
)}
306+
{/* modal steps cannot have action triggers, so we always show the next/done button */}
298307
<button class="ph-tour-button ph-tour-button--primary" onClick={onNext}>
299308
{isLastStep ? 'Done' : 'Next'}
300309
</button>
@@ -322,16 +331,21 @@ export function ProductTourTooltip({
322331

323332
<div
324333
class="ph-tour-spotlight"
325-
style={
326-
isVisible && spotlightStyle
334+
style={{
335+
...(isVisible && spotlightStyle
327336
? spotlightStyle
328337
: {
329338
top: '50%',
330339
left: '50%',
331340
width: '0px',
332341
height: '0px',
333-
}
334-
}
342+
}),
343+
...(displayedStep.progressionTrigger === 'click' && {
344+
pointerEvents: 'auto',
345+
cursor: 'pointer',
346+
}),
347+
}}
348+
onClick={displayedStep.progressionTrigger === 'click' ? handleSpotlightClick : undefined}
335349
/>
336350

337351
<div
@@ -367,9 +381,11 @@ export function ProductTourTooltip({
367381
Back
368382
</button>
369383
)}
370-
<button class="ph-tour-button ph-tour-button--primary" onClick={onNext}>
371-
{isLastStep ? 'Done' : 'Next'}
372-
</button>
384+
{displayedStep.progressionTrigger === 'button' && (
385+
<button class="ph-tour-button ph-tour-button--primary" onClick={onNext}>
386+
{isLastStep ? 'Done' : 'Next'}
387+
</button>
388+
)}
373389
</div>
374390
</div>
375391

packages/browser/src/extensions/product-tours/product-tours-utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@ export function renderTipTapContent(content: any): string {
209209
}
210210
}
211211

212+
export function normalizeUrl(url: string): string {
213+
return url.endsWith('/') ? url.slice(0, -1) : url
214+
}
215+
212216
function escapeHtml(text: string): string {
213217
const div = document.createElement('div')
214218
div.textContent = text

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

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ import {
55
ProductTourCallback,
66
ProductTourDismissReason,
77
ProductTourRenderReason,
8+
ShowTourOptions,
89
} from '../../posthog-product-tours-types'
910
import { SurveyEventName, SurveyEventProperties } from '../../posthog-surveys-types'
10-
import { findElementBySelector, getElementMetadata, getProductTourStylesheet } from './product-tours-utils'
11+
import {
12+
findElementBySelector,
13+
getElementMetadata,
14+
getProductTourStylesheet,
15+
normalizeUrl,
16+
} from './product-tours-utils'
1117
import { ProductTourTooltip } from './components/ProductTourTooltip'
1218
import { ProductTourSurveyStep } from './components/ProductTourSurveyStep'
1319
import { createLogger } from '../../utils/logger'
@@ -34,8 +40,13 @@ function doesTourUrlMatch(tour: ProductTour): boolean {
3440
return false
3541
}
3642

37-
const targets = [conditions.url]
3843
const matchType = conditions.urlMatchType || SurveyMatchType.Icontains
44+
45+
if (matchType === SurveyMatchType.Exact) {
46+
return normalizeUrl(href) === normalizeUrl(conditions.url)
47+
}
48+
49+
const targets = [conditions.url]
3950
return propertyComparisons[matchType](targets, [href])
4051
}
4152

@@ -232,7 +243,9 @@ export class ProductTourManager {
232243
return true
233244
}
234245

235-
showTour(tour: ProductTour, reason: ProductTourRenderReason = 'auto'): void {
246+
showTour(tour: ProductTour, options?: ShowTourOptions): void {
247+
const renderReason: ProductTourRenderReason = options?.reason ?? 'auto'
248+
236249
// Validate all step selectors before showing the tour
237250
// Steps without selectors are modal steps and don't need validation
238251
const selectorFailures: Array<{
@@ -280,20 +293,20 @@ export class ProductTourManager {
280293

281294
const failedSelectors = selectorFailures.map((f) => `Step ${f.stepIndex}: "${f.selector}" (${f.error})`)
282295
logger.warn(
283-
`Tour "${tour.name}" (${tour.id}) not shown: ${selectorFailures.length} selector(s) failed to match:\n - ${failedSelectors.join('\n - ')}`
296+
`Tour "${tour.name}" (${tour.id}): ${selectorFailures.length} selector(s) failed to match:\n - ${failedSelectors.join('\n - ')}${options?.enableStrictValidation === true ? '\n\nenableStrictValidation is true, not displaying tour.' : ''}`
284297
)
285-
return
298+
if (options?.enableStrictValidation === true) return
286299
}
287300

288301
this._activeTour = tour
289302
this._currentStepIndex = 0
290-
this._renderReason = reason
303+
this._renderReason = renderReason
291304

292305
this._captureEvent('product tour shown', {
293306
$product_tour_id: tour.id,
294307
$product_tour_name: tour.name,
295308
$product_tour_iteration: tour.current_iteration || 1,
296-
$product_tour_render_reason: reason,
309+
$product_tour_render_reason: renderReason,
297310
})
298311

299312
this._renderCurrentStep()
@@ -305,7 +318,7 @@ export class ProductTourManager {
305318
const tour = tours.find((t) => t.id === tourId)
306319
if (tour) {
307320
logger.info(`found tour: `, tour)
308-
this.showTour(tour, 'api')
321+
this.showTour(tour, { reason: 'api' })
309322
} else {
310323
logger.info('could not find tour', tourId)
311324
}
@@ -382,7 +395,7 @@ export class ProductTourManager {
382395
this._cleanup()
383396
}
384397

385-
private _renderCurrentStep(): void {
398+
private _renderCurrentStep(retryCount: number = 0): void {
386399
if (!this._activeTour) {
387400
return
388401
}
@@ -410,7 +423,24 @@ export class ProductTourManager {
410423

411424
const result = findElementBySelector(step.selector)
412425

426+
const previousStep = this._currentStepIndex > 0 ? this._activeTour.steps[this._currentStepIndex - 1] : null
427+
const shouldWaitForElement = previousStep?.progressionTrigger === 'click'
428+
429+
// 2s total timeout
430+
const maxRetries = 20
431+
const retryTimeout = 100
432+
413433
if (result.error === 'not_found' || result.error === 'not_visible') {
434+
// if previous step was click-to-progress, give some time for the next element
435+
if (shouldWaitForElement && retryCount < maxRetries) {
436+
setTimeout(() => {
437+
this._renderCurrentStep(retryCount + 1)
438+
}, retryTimeout)
439+
return
440+
}
441+
442+
const waitDurationMs = retryCount * retryTimeout
443+
414444
this._captureEvent('product tour step selector failed', {
415445
$product_tour_id: this._activeTour.id,
416446
$product_tour_step_id: step.id,
@@ -419,10 +449,13 @@ export class ProductTourManager {
419449
$product_tour_error: result.error,
420450
$product_tour_matches_count: result.matchCount,
421451
$product_tour_failure_phase: 'runtime',
452+
$product_tour_waited_for_element: shouldWaitForElement,
453+
$product_tour_wait_duration_ms: waitDurationMs,
422454
})
423455

424456
logger.warn(
425-
`Tour "${this._activeTour.name}" dismissed: element for step ${this._currentStepIndex} became unavailable (${result.error})`
457+
`Tour "${this._activeTour.name}" dismissed: element for step ${this._currentStepIndex} became unavailable (${result.error})` +
458+
(shouldWaitForElement ? ` after waiting ${waitDurationMs}ms` : '')
426459
)
427460
this.dismissTour('element_unavailable')
428461
return
@@ -613,7 +646,7 @@ export class ProductTourManager {
613646
}
614647

615648
logger.info(`Tour ${tour.id} triggered by click on ${tour.trigger_selector}`)
616-
this.showTour(tour, 'trigger')
649+
this.showTour(tour, { reason: 'trigger' })
617650
}
618651

619652
addEventListener(currentElement, 'click', listener)

packages/browser/src/posthog-product-tours-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface ProductTourSurveyQuestion {
2626
export interface ProductTourStep {
2727
id: string
2828
selector?: string
29+
progressionTrigger: 'button' | 'click'
2930
content: JSONContent | null
3031
/** Inline survey question config - if present, this is a survey step */
3132
survey?: ProductTourSurveyQuestion
@@ -89,3 +90,8 @@ export const DEFAULT_PRODUCT_TOUR_APPEARANCE: Required<ProductTourAppearance> =
8990
borderColor: '#e5e7eb',
9091
whiteLabel: false,
9192
}
93+
94+
export interface ShowTourOptions {
95+
reason?: ProductTourRenderReason
96+
enableStrictValidation?: boolean
97+
}

playground/nextjs/pages/index.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default function Home() {
1212
const flags = useActiveFeatureFlags()
1313

1414
const [time, setTime] = useState('')
15+
const [modalOpen, setModalOpen] = useState(false)
1516
const consentGiven = cookieConsentGiven()
1617

1718
useEffect(() => {
@@ -112,8 +113,43 @@ export default function Home() {
112113
<button onClick={() => posthog?.reset()} id="set-user-properties">
113114
Reset
114115
</button>
116+
<button onClick={() => setModalOpen(true)}>Open Modal</button>
115117
</div>
116118

119+
{modalOpen && (
120+
<div
121+
style={{
122+
position: 'fixed',
123+
top: 0,
124+
left: 0,
125+
right: 0,
126+
bottom: 0,
127+
backgroundColor: 'rgba(0,0,0,0.5)',
128+
display: 'flex',
129+
alignItems: 'center',
130+
justifyContent: 'center',
131+
zIndex: 1000,
132+
}}
133+
onClick={() => setModalOpen(false)}
134+
>
135+
<div
136+
style={{
137+
backgroundColor: 'white',
138+
padding: '24px',
139+
borderRadius: '8px',
140+
}}
141+
onClick={(e) => e.stopPropagation()}
142+
>
143+
<h3>Modal</h3>
144+
<button data-attr="surprise-modal-button">Button inside modal</button>
145+
<br />
146+
<button onClick={() => setModalOpen(false)} style={{ marginTop: '12px' }}>
147+
Close
148+
</button>
149+
</div>
150+
</div>
151+
)}
152+
117153
{isClient && (
118154
<>
119155
<div className="px-4 py-2 bg-gray-100 rounded border-2 border-gray-800 my-2">

0 commit comments

Comments
 (0)