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
87102export 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+
96137type HandlersMap = Partial < Record < ID , StepHandlers > >
97138
98139export interface TourDefinition {
99140 handlers ?: HandlersMap
100- exit ?: StepHandler
141+ exit ?: SimpleHandler
101142}
102143
103144export 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