Skip to content

Commit b2fa6d2

Browse files
committed
docs(discovery): unified enter handler and iOS anchor positioning
- Fix CSS Anchor Positioning issue where step 3 popover wasn't visible on iOS Safari. Moved search-results activator to wrap category header instead of first result row (avoids nested anchor-name conflicts). - Refactor useDiscovery to use unified `enter` handler that runs on any step entry (forward, back, resume, jump). Removes need for separate `back` handlers. - Add async handler support with `done()` callback and Promise return for future interactive tours. New `isReady` state blocks navigation until handler completes. - Simplify using-search tour from 18 handlers to 9 (enter only).
1 parent 3114ae3 commit b2fa6d2

File tree

2 files changed

+196
-84
lines changed
  • apps/docs/src

2 files changed

+196
-84
lines changed

apps/docs/src/composables/useDiscovery/index.ts

Lines changed: 157 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,30 @@
22
* @module useDiscovery
33
*
44
* @remarks
5-
* Composable for managing guided tours through documentation.
5+
* Composable for managing guided and interactive tours through documentation.
66
* Handles tour navigation, step progression, dynamic handler loading,
77
* and integration with form validation.
88
*
99
* Key features:
10-
* - Event-based lifecycle (enter/leave/back) for UI synchronization
11-
* - Dynamic handler loading from tour definition modules
10+
* - Unified `enter` handler runs on any step entry (forward, back, resume)
11+
* - Async handler support via Promise return or `done()` callback
12+
* - Interactive tour support with `done()` for user-action-driven progression
1213
* - Form validation integration per step
1314
* - Activator and root registry for positioning
15+
*
16+
* Handler execution:
17+
* - `enter(ctx)` - Runs when entering a step (any direction)
18+
* - `leave()` - Runs when leaving a step (any direction)
19+
* - `completed()` - Runs when step is completed (going forward only)
20+
*
21+
* Handler context:
22+
* - `done()` - Call to signal async setup complete (for interactive tours)
23+
* - `direction` - How user arrived: 'forward' | 'back' | 'resume' | 'jump'
24+
*
25+
* Async patterns:
26+
* - Return Promise: `enter: async () => { await setup() }`
27+
* - Use done(): `enter: ({ done }) => { watchForAction(done) }`
28+
* - Sync (default): `enter: () => { doSetup() }`
1429
*/
1530

1631
// Framework
@@ -86,18 +101,44 @@ export type DiscoveryTourTicketInput = SingleTicketInput & DiscoveryTour & {
86101

87102
export type DiscoveryTourTicket = SingleTicket<DiscoveryTourTicketInput>
88103

89-
type StepHandler = () => void
90-
type StepHandlers = {
104+
/** Direction of navigation when entering a step */
105+
export type StepDirection = 'forward' | 'back' | 'resume' | 'jump'
106+
107+
/** Context passed to step handlers */
108+
export interface StepHandlerContext {
109+
/** Call when async setup is complete (for interactive tours) */
110+
done: () => void
111+
/** How the user arrived at this step */
112+
direction: StepDirection
113+
}
114+
115+
/**
116+
* Step handler function signature.
117+
* Supports multiple patterns:
118+
* - Sync: `() => { doSetup() }`
119+
* - Async Promise: `async () => { await setup() }`
120+
* - Async callback: `({ done }) => { watchForAction(done) }`
121+
* - With direction: `({ direction }) => { if (direction === 'back') ... }`
122+
*/
123+
export type StepHandler = (ctx: StepHandlerContext) => void | Promise<void>
124+
125+
/** Simple handler for leave/completed (no async needed) */
126+
export type SimpleHandler = () => void
127+
128+
export type StepHandlers = {
129+
/** Runs when entering a step (any direction: forward, back, resume, jump) */
91130
enter?: StepHandler
92-
leave?: StepHandler
93-
back?: StepHandler
94-
completed?: StepHandler
131+
/** Runs when leaving a step (any direction) */
132+
leave?: SimpleHandler
133+
/** Runs when step is completed successfully (forward only, before leave) */
134+
completed?: SimpleHandler
95135
}
136+
96137
type HandlersMap = Partial<Record<ID, StepHandlers>>
97138

98139
export interface TourDefinition {
99140
handlers?: HandlersMap
100-
exit?: StepHandler
141+
exit?: SimpleHandler
101142
}
102143

103144
export interface TourDefinitionModule {
@@ -115,6 +156,8 @@ export interface DiscoveryContext {
115156
isComplete: Readonly<ShallowRef<boolean>>
116157
isFirst: Readonly<ShallowRef<boolean>>
117158
isLast: Readonly<ShallowRef<boolean>>
159+
/** Whether the current step's handler has completed (for interactive tours) */
160+
isReady: Readonly<ShallowRef<boolean>>
118161
canGoBack: Readonly<Ref<boolean>>
119162
canGoNext: Readonly<Ref<boolean>>
120163
selectedId: StepContext<DiscoveryStepTicket>['selectedId']
@@ -125,7 +168,7 @@ export interface DiscoveryContext {
125168
complete: () => void
126169
reset: () => void
127170
next: () => Promise<void>
128-
prev: () => void
171+
prev: () => Promise<void>
129172
step: (index: number) => Promise<void>
130173
}
131174

@@ -150,47 +193,87 @@ export function createDiscovery (): DiscoveryContext {
150193

151194
const isActive = shallowRef(false)
152195
const isComplete = shallowRef(false)
196+
const isReady = shallowRef(true)
153197

154198
const isFirst = toRef(() => steps.selectedIndex.value === 0)
155199
const isLast = toRef(() => steps.selectedIndex.value === steps.size - 1)
156-
const canGoBack = toRef(() => steps.selectedIndex.value > 0)
157-
const canGoNext = toRef(() => steps.selectedIndex.value < steps.size - 1)
200+
const canGoBack = toRef(() => isReady.value && steps.selectedIndex.value > 0)
201+
const canGoNext = toRef(() => isReady.value && steps.selectedIndex.value < steps.size - 1)
158202

159203
// Handler state
160204
let handlers: HandlersMap = {}
161-
let exitHandler: StepHandler | undefined
205+
let exitHandler: SimpleHandler | undefined
162206
let isStarting = false
163207

164-
function onEnter (ticket: DiscoveryStepTicket) {
165-
handlers[ticket.id]?.enter?.()
166-
}
208+
/**
209+
* Invoke a step handler with async support.
210+
* Handles three patterns:
211+
* 1. Sync handler (no done call, no Promise) - resolves immediately
212+
* 2. Promise return - awaits the Promise
213+
* 3. done() callback - resolves when done() is called
214+
*/
215+
async function invokeEnterHandler (
216+
handler: StepHandler | undefined,
217+
direction: StepDirection,
218+
): Promise<void> {
219+
if (!handler) {
220+
isReady.value = true
221+
return
222+
}
167223

168-
function onLeave (ticket: DiscoveryStepTicket) {
169-
handlers[ticket.id]?.leave?.()
170-
}
224+
isReady.value = false
225+
226+
return new Promise<void>(resolve => {
227+
let resolved = false
171228

172-
function onBack (ticket: DiscoveryStepTicket) {
173-
handlers[ticket.id]?.back?.()
229+
function done () {
230+
if (resolved) return
231+
resolved = true
232+
isReady.value = true
233+
resolve()
234+
}
235+
236+
const ctx: StepHandlerContext = { done, direction }
237+
238+
try {
239+
const result = handler(ctx)
240+
241+
// If handler returns a Promise, await it then auto-done
242+
if (result instanceof Promise) {
243+
result
244+
.then(() => done())
245+
.catch(error => {
246+
console.error('[v0:discovery] Handler error:', error)
247+
done()
248+
})
249+
} else if (handler.length === 0) {
250+
// Handler takes no arguments - sync handler, auto-done
251+
done()
252+
}
253+
// Otherwise, handler uses done() callback - wait for it
254+
} catch (error) {
255+
console.error('[v0:discovery] Handler error:', error)
256+
done()
257+
}
258+
})
174259
}
175260

176-
function onCompleted (ticket: DiscoveryStepTicket) {
177-
handlers[ticket.id]?.completed?.()
261+
async function onEnter (ticket: DiscoveryStepTicket, direction: StepDirection) {
262+
// Emit event for external listeners (e.g., tests, debugging)
263+
steps.emit('enter', ticket)
264+
// Invoke handler with async support
265+
const handler = handlers[ticket.id]?.enter
266+
await invokeEnterHandler(handler, direction)
178267
}
179268

180-
function attachHandlers () {
181-
steps.on('enter', onEnter)
182-
steps.on('leave', onLeave)
183-
steps.on('back', onBack)
184-
steps.on('completed', onCompleted)
269+
function onLeave (ticket: DiscoveryStepTicket) {
270+
steps.emit('leave', ticket)
271+
handlers[ticket.id]?.leave?.()
185272
}
186273

187-
function detachHandlers () {
188-
steps.off('enter', onEnter)
189-
steps.off('leave', onLeave)
190-
steps.off('back', onBack)
191-
steps.off('completed', onCompleted)
192-
handlers = {}
193-
exitHandler = undefined
274+
function onCompleted (ticket: DiscoveryStepTicket) {
275+
steps.emit('completed', ticket)
276+
handlers[ticket.id]?.completed?.()
194277
}
195278

196279
async function start (id: ID, options?: { stepId?: ID, context?: Record<string, unknown> }) {
@@ -230,55 +313,66 @@ export function createDiscovery (): DiscoveryContext {
230313
}
231314
}
232315

233-
attachHandlers()
234-
235316
isActive.value = true
236317
isComplete.value = false
237318

238319
tour.select()
239320

321+
// Determine direction based on whether resuming at a specific step
322+
const direction: StepDirection = options?.stepId ? 'resume' : 'forward'
323+
240324
// Start at specific step if provided, otherwise first
241325
if (options?.stepId && steps.has(options.stepId)) {
242326
steps.select(options.stepId)
243327
} else {
244328
steps.first()
245329
}
246330

247-
// Emit enter for initial step
331+
// Run enter handler for initial step
248332
const initial = steps.selectedItem.value
249-
if (initial) steps.emit('enter', initial)
333+
if (initial) {
334+
await onEnter(initial, direction)
335+
}
250336
} finally {
251337
isStarting = false
252338
}
253339
}
254340

255341
function stop () {
256342
const current = steps.selectedItem.value
257-
if (current) steps.emit('leave', current)
343+
if (current) onLeave(current)
258344
exitHandler?.()
259-
detachHandlers()
345+
handlers = {}
346+
exitHandler = undefined
260347
isActive.value = false
348+
isReady.value = true
261349
}
262350

263351
function reset () {
264-
detachHandlers()
352+
handlers = {}
353+
exitHandler = undefined
265354
form.reset()
266355
steps.reset()
267356
tours.reset()
268357
isActive.value = false
269358
isComplete.value = false
359+
isReady.value = true
270360
}
271361

272362
function complete () {
273363
const current = steps.selectedItem.value
274-
if (current) steps.emit('leave', current)
364+
if (current) onLeave(current)
275365
exitHandler?.()
276-
detachHandlers()
366+
handlers = {}
367+
exitHandler = undefined
277368
isActive.value = false
278369
isComplete.value = true
370+
isReady.value = true
279371
}
280372

281373
async function next () {
374+
if (!isReady.value) return
375+
282376
const current = steps.selectedItem.value
283377
if (!current) return
284378

@@ -292,32 +386,38 @@ export function createDiscovery (): DiscoveryContext {
292386
}
293387
}
294388

295-
steps.emit('completed', current)
296-
steps.emit('leave', current)
389+
onCompleted(current)
390+
onLeave(current)
297391
steps.next()
298392

299-
// Emit enter with newly selected item after navigation
393+
// Run enter handler for new step
300394
const newItem = steps.selectedItem.value
301395
if (newItem && newItem.id !== current.id) {
302-
steps.emit('enter', newItem)
396+
await onEnter(newItem, 'forward')
303397
}
304398
}
305399

306-
function prev () {
400+
async function prev () {
401+
if (!isReady.value) return
402+
307403
const current = steps.selectedItem.value
308404
if (!current) return
309405

406+
// Emit back event for current step (for backwards compatibility)
310407
steps.emit('back', current)
408+
onLeave(current)
311409
steps.prev()
312410

313-
// Emit enter with newly selected item after navigation
411+
// Run enter handler for new step (same handler, different direction)
314412
const newItem = steps.selectedItem.value
315413
if (newItem && newItem.id !== current.id) {
316-
steps.emit('enter', newItem)
414+
await onEnter(newItem, 'back')
317415
}
318416
}
319417

320418
async function step (index: number) {
419+
if (!isReady.value) return
420+
321421
const current = steps.selectedItem.value
322422
const id = steps.lookup(index - 1)
323423
if (isUndefined(id)) return
@@ -333,18 +433,18 @@ export function createDiscovery (): DiscoveryContext {
333433
}
334434
}
335435

336-
// Emit completed and leave for current step if navigating away
436+
// Leave current step if navigating away
337437
if (current && current.id !== id) {
338-
steps.emit('completed', current)
339-
steps.emit('leave', current)
438+
onCompleted(current)
439+
onLeave(current)
340440
}
341441

342442
steps.select(id)
343443

344-
// Emit enter for newly selected step
444+
// Run enter handler for new step
345445
const newItem = steps.selectedItem.value
346446
if (newItem && (!current || newItem.id !== current.id)) {
347-
steps.emit('enter', newItem)
447+
await onEnter(newItem, 'jump')
348448
}
349449
}
350450

@@ -359,6 +459,7 @@ export function createDiscovery (): DiscoveryContext {
359459
isComplete: readonly(isComplete),
360460
isFirst: readonly(isFirst),
361461
isLast: readonly(isLast),
462+
isReady: readonly(isReady),
362463
canGoBack: readonly(canGoBack),
363464
canGoNext: readonly(canGoNext),
364465
selectedId: steps.selectedId,

0 commit comments

Comments
 (0)