@hina-ui/vue 1.7.13 → 1.7.14

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.
Files changed (68) hide show
  1. package/dist/index.js +1295 -283
  2. package/dist/index.js.map +1 -1
  3. package/dist/shared/lib/thumbhash.ts +12 -0
  4. package/dist/shared/lib/tour-engine.ts +721 -0
  5. package/dist/shared/lib/tour.ts +202 -24
  6. package/dist/shared/locale/en-US.ts +1 -0
  7. package/dist/shared/locale/ja-JP.ts +1 -0
  8. package/dist/shared/locale/types.ts +1 -0
  9. package/dist/shared/locale/zh-CN.ts +1 -0
  10. package/dist/shared/primitives/dialog/index.ts +2 -2
  11. package/dist/shared/primitives/dismissable-layer/index.ts +48 -1
  12. package/dist/shared/primitives/focus-scope/index.ts +5 -3
  13. package/dist/shared/styles/semantic.css +1 -0
  14. package/dist/shared/styles/tour.css +107 -2
  15. package/dist/shared/variants/image.ts +28 -0
  16. package/dist/shared/variants/tour.ts +22 -4
  17. package/dist/types/shared/src/lib/thumbhash.d.ts +2 -0
  18. package/dist/types/shared/src/lib/thumbhash.d.ts.map +1 -0
  19. package/dist/types/shared/src/lib/tour-engine.d.ts +47 -0
  20. package/dist/types/shared/src/lib/tour-engine.d.ts.map +1 -0
  21. package/dist/types/shared/src/lib/tour.d.ts +65 -9
  22. package/dist/types/shared/src/lib/tour.d.ts.map +1 -1
  23. package/dist/types/shared/src/locale/en-US.d.ts.map +1 -1
  24. package/dist/types/shared/src/locale/ja-JP.d.ts.map +1 -1
  25. package/dist/types/shared/src/locale/types.d.ts +1 -0
  26. package/dist/types/shared/src/locale/types.d.ts.map +1 -1
  27. package/dist/types/shared/src/locale/zh-CN.d.ts.map +1 -1
  28. package/dist/types/shared/src/primitives/dialog/index.d.ts +1 -1
  29. package/dist/types/shared/src/primitives/dialog/index.d.ts.map +1 -1
  30. package/dist/types/shared/src/primitives/dismissable-layer/index.d.ts +1 -0
  31. package/dist/types/shared/src/primitives/dismissable-layer/index.d.ts.map +1 -1
  32. package/dist/types/shared/src/primitives/focus-scope/index.d.ts.map +1 -1
  33. package/dist/types/shared/src/variants/image.d.ts +35 -0
  34. package/dist/types/shared/src/variants/image.d.ts.map +1 -1
  35. package/dist/types/shared/src/variants/tour.d.ts +9 -4
  36. package/dist/types/shared/src/variants/tour.d.ts.map +1 -1
  37. package/dist/types/vue/src/components/button/Button.vue.d.ts.map +1 -1
  38. package/dist/types/vue/src/components/dialog/Dialog.vue.d.ts +3 -0
  39. package/dist/types/vue/src/components/dialog/Dialog.vue.d.ts.map +1 -1
  40. package/dist/types/vue/src/components/drawer/Drawer.vue.d.ts +3 -0
  41. package/dist/types/vue/src/components/drawer/Drawer.vue.d.ts.map +1 -1
  42. package/dist/types/vue/src/components/image/Image.vue.d.ts +1 -0
  43. package/dist/types/vue/src/components/image/Image.vue.d.ts.map +1 -1
  44. package/dist/types/vue/src/components/image/composables/useImage.d.ts +2 -0
  45. package/dist/types/vue/src/components/image/composables/useImage.d.ts.map +1 -1
  46. package/dist/types/vue/src/components/overlay-branch/OverlayBranch.vue.d.ts +17 -0
  47. package/dist/types/vue/src/components/overlay-branch/OverlayBranch.vue.d.ts.map +1 -0
  48. package/dist/types/vue/src/components/sheet/Sheet.vue.d.ts +3 -0
  49. package/dist/types/vue/src/components/sheet/Sheet.vue.d.ts.map +1 -1
  50. package/dist/types/vue/src/components/tour/Tour.vue.d.ts +10 -1
  51. package/dist/types/vue/src/components/tour/Tour.vue.d.ts.map +1 -1
  52. package/dist/types/vue/src/components/tour/types.d.ts +9 -3
  53. package/dist/types/vue/src/components/tour/types.d.ts.map +1 -1
  54. package/dist/types/vue/src/index.d.ts +2 -1
  55. package/dist/types/vue/src/index.d.ts.map +1 -1
  56. package/dist/types/vue/src/primitives/dismissable-layer/DismissableLayerBranch.d.ts.map +1 -1
  57. package/package.json +2 -1
  58. package/src/components/button/Button.vue +1 -1
  59. package/src/components/dialog/Dialog.vue +14 -1
  60. package/src/components/drawer/Drawer.vue +14 -1
  61. package/src/components/image/Image.vue +58 -24
  62. package/src/components/image/composables/useImage.ts +46 -21
  63. package/src/components/overlay-branch/OverlayBranch.vue +14 -0
  64. package/src/components/sheet/Sheet.vue +14 -1
  65. package/src/components/tour/Tour.vue +302 -141
  66. package/src/components/tour/types.ts +33 -2
  67. package/src/index.ts +11 -0
  68. package/src/primitives/dismissable-layer/DismissableLayerBranch.ts +9 -18
@@ -0,0 +1,721 @@
1
+ import { EASE, type Bezier } from '../motion'
2
+ import {
3
+ resolveTourTarget,
4
+ revealTourTarget,
5
+ spotlightRect,
6
+ TOUR_PADDING,
7
+ TOUR_RADIUS,
8
+ waitForTourTarget,
9
+ watchTourTarget,
10
+ type TourPlayContext,
11
+ type TourPoint,
12
+ type TourPointer,
13
+ type TourPointerKind,
14
+ type TourRect,
15
+ type TourResolvedTarget,
16
+ type TourSpotlightOptions,
17
+ type TourStepBase,
18
+ type TourTarget,
19
+ } from './tour'
20
+
21
+ export type TourStatus = 'idle' | 'waiting' | 'ready' | 'missing'
22
+
23
+ export interface TourEngineState {
24
+ status: TourStatus
25
+ settled: boolean
26
+ hole: TourRect | null
27
+ targetRect: TourRect | null
28
+ radius: number
29
+ elements: HTMLElement[]
30
+ playing: boolean
31
+ viewport: { width: number; height: number }
32
+ }
33
+
34
+ export interface TourEngineOptions {
35
+ pointer: () => HTMLElement | null | undefined
36
+ keyBadge: () => HTMLElement | null | undefined
37
+ pointerKind: () => TourPointerKind
38
+ cardGap?: () => number
39
+ }
40
+
41
+ const INITIAL: TourEngineState = {
42
+ status: 'idle',
43
+ settled: false,
44
+ hole: null,
45
+ targetRect: null,
46
+ radius: TOUR_RADIUS,
47
+ elements: [],
48
+ playing: false,
49
+ viewport: { width: 0, height: 0 },
50
+ }
51
+
52
+ export const TOUR_SPRING = { stiffness: 260, damping: 30 }
53
+
54
+ type Keys = 'x' | 'y' | 'width' | 'height'
55
+ const KEYS: Keys[] = ['x', 'y', 'width', 'height']
56
+
57
+ export function stepSpring(
58
+ position: TourRect,
59
+ velocity: TourRect,
60
+ goal: TourRect,
61
+ seconds: number,
62
+ { stiffness, damping } = TOUR_SPRING,
63
+ ) {
64
+ const step = 1 / 240
65
+ let left = seconds
66
+ while (left > 0) {
67
+ const dt = Math.min(step, left)
68
+ for (const key of KEYS) {
69
+ const force = -stiffness * (position[key] - goal[key]) - damping * velocity[key]
70
+ velocity[key] += force * dt
71
+ position[key] += velocity[key] * dt
72
+ }
73
+ left -= dt
74
+ }
75
+ return KEYS.every(
76
+ key => Math.abs(position[key] - goal[key]) < 0.25 && Math.abs(velocity[key]) < 2,
77
+ )
78
+ }
79
+
80
+ export function bezierEasing([x1, y1, x2, y2]: Bezier) {
81
+ const curve = (a: number, b: number, t: number) =>
82
+ 3 * a * t * (1 - t) ** 2 + 3 * b * t * t * (1 - t) + t ** 3
83
+ const slope = (a: number, b: number, t: number) =>
84
+ 3 * a * (1 - t) ** 2 + 6 * (b - a) * t * (1 - t) + 3 * (1 - b) * t * t
85
+ return (progress: number) => {
86
+ if (progress <= 0) return 0
87
+ if (progress >= 1) return 1
88
+ let t = progress
89
+ for (let i = 0; i < 8; i++) {
90
+ const error = curve(x1, x2, t) - progress
91
+ const derivative = slope(x1, x2, t)
92
+ if (Math.abs(error) < 1e-5 || derivative === 0) break
93
+ t -= error / derivative
94
+ }
95
+ return curve(y1, y2, Math.min(Math.max(t, 0), 1))
96
+ }
97
+ }
98
+
99
+ const easeMove = bezierEasing(EASE.move)
100
+
101
+ function reducedMotion() {
102
+ return (
103
+ typeof window !== 'undefined' &&
104
+ !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
105
+ )
106
+ }
107
+
108
+ function abortError() {
109
+ return new DOMException('The tour step was left', 'AbortError')
110
+ }
111
+
112
+ export function isAbortError(error: unknown) {
113
+ return error instanceof DOMException && error.name === 'AbortError'
114
+ }
115
+
116
+ function guard(signal: AbortSignal) {
117
+ if (signal.aborted) throw abortError()
118
+ }
119
+
120
+ export function tourSleep(ms: number, signal: AbortSignal) {
121
+ return new Promise<void>((resolve, reject) => {
122
+ if (signal.aborted) return reject(abortError())
123
+ const timer = window.setTimeout(done, ms)
124
+ const abort = () => {
125
+ window.clearTimeout(timer)
126
+ reject(abortError())
127
+ }
128
+ function done() {
129
+ signal.removeEventListener('abort', abort)
130
+ resolve()
131
+ }
132
+ signal.addEventListener('abort', abort, { once: true })
133
+ })
134
+ }
135
+
136
+ export function tween(
137
+ duration: number,
138
+ onFrame: (progress: number) => void,
139
+ signal: AbortSignal,
140
+ easing: (progress: number) => number = easeMove,
141
+ ) {
142
+ return new Promise<void>((resolve, reject) => {
143
+ if (signal.aborted) return reject(abortError())
144
+ if (duration <= 0 || reducedMotion()) {
145
+ onFrame(1)
146
+ return resolve()
147
+ }
148
+ const start = performance.now()
149
+ let frame = 0
150
+ const abort = () => {
151
+ cancelAnimationFrame(frame)
152
+ reject(abortError())
153
+ }
154
+ const tick = (now: number) => {
155
+ const progress = Math.min((now - start) / duration, 1)
156
+ onFrame(easing(progress))
157
+ if (progress < 1) frame = requestAnimationFrame(tick)
158
+ else {
159
+ signal.removeEventListener('abort', abort)
160
+ resolve()
161
+ }
162
+ }
163
+ signal.addEventListener('abort', abort, { once: true })
164
+ frame = requestAnimationFrame(tick)
165
+ })
166
+ }
167
+
168
+ const nextFrame = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
169
+ const delay = (ms: number) => new Promise<void>(resolve => window.setTimeout(resolve, ms))
170
+
171
+ function finiteAnimations(affects: (node: Node) => boolean = () => true) {
172
+ if (typeof document.getAnimations !== 'function') return []
173
+ return document.getAnimations().filter(animation => {
174
+ if (animation.playState !== 'running' || animation.timeline !== document.timeline) return false
175
+ const end = animation.effect?.getComputedTiming().endTime
176
+ if (typeof end !== 'number' || !Number.isFinite(end)) return false
177
+ const node = (animation.effect as KeyframeEffect | null)?.target
178
+ return !!node && affects(node)
179
+ })
180
+ }
181
+
182
+ function settle(animations: Animation[], until: number) {
183
+ if (!animations.length) return Promise.resolve()
184
+ return Promise.race([
185
+ Promise.allSettled(animations.map(animation => animation.finished)),
186
+ delay(Math.max(until - performance.now(), 0)),
187
+ ])
188
+ }
189
+
190
+ async function waitForStillTarget(target: TourResolvedTarget, limit = 1000) {
191
+ const until = performance.now() + limit
192
+ await nextFrame()
193
+ await settle(
194
+ finiteAnimations(node =>
195
+ target.elements.some(element => node === element || node.contains(element)),
196
+ ),
197
+ until,
198
+ )
199
+ let previous = target.rect()
200
+ let still = 0
201
+ while (still < 2 && performance.now() < until) {
202
+ await nextFrame()
203
+ const rect = target.rect()
204
+ const moved = KEYS.some(key => Math.abs(rect[key] - previous[key]) > 0.5)
205
+ previous = rect
206
+ still = moved ? 0 : still + 1
207
+ }
208
+ }
209
+
210
+ async function waitForFiniteAnimations(limit = 1000) {
211
+ const until = performance.now() + limit
212
+ await nextFrame()
213
+ await nextFrame()
214
+ await settle(finiteAnimations(), until)
215
+ }
216
+
217
+ function isPoint(value: unknown): value is TourPoint {
218
+ return (
219
+ !!value &&
220
+ typeof value === 'object' &&
221
+ typeof (value as TourPoint).x === 'number' &&
222
+ typeof (value as TourPoint).y === 'number' &&
223
+ !('width' in value)
224
+ )
225
+ }
226
+
227
+ export function tourPointerPoint(target: TourTarget | TourPoint): TourPoint | null {
228
+ if (isPoint(target)) return { x: target.x, y: target.y }
229
+ const resolved = resolveTourTarget(target as TourTarget)
230
+ if (!resolved) return null
231
+ const rect = resolved.rect()
232
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
233
+ }
234
+
235
+ function createPointer(
236
+ element: () => HTMLElement | null | undefined,
237
+ keyBadge: () => HTMLElement | null | undefined,
238
+ signal: AbortSignal,
239
+ memory: { point: TourPoint | null },
240
+ ready: () => Promise<void>,
241
+ ): TourPointer {
242
+ const place = (point: TourPoint) => {
243
+ memory.point = point
244
+ const node = element()
245
+ if (node) node.style.transform = `translate3d(${point.x}px, ${point.y}px, 0)`
246
+ }
247
+ const setState = (name: string, value: string | null) => {
248
+ const node = element()
249
+ if (!node) return
250
+ if (value === null) node.removeAttribute(name)
251
+ else node.setAttribute(name, value)
252
+ }
253
+ const ripple = () => {
254
+ const node = element()?.querySelector('[data-hn-tour-pointer-ripples]')
255
+ if (!node) return
256
+ const span = document.createElement('span')
257
+ span.setAttribute('data-hn-tour-ripple', '')
258
+ span.addEventListener('animationend', () => span.remove(), { once: true })
259
+ window.setTimeout(() => span.remove(), 1000)
260
+ node.appendChild(span)
261
+ }
262
+ const resolvePoint = (target: TourTarget | TourPoint) => {
263
+ const point = tourPointerPoint(target)
264
+ if (!point) throw new Error('[Hina UI] Tour: the pointer target was not found')
265
+ return point
266
+ }
267
+ const travel = async (
268
+ target: TourTarget | TourPoint,
269
+ duration: number,
270
+ onMove?: (point: TourPoint) => void,
271
+ ) => {
272
+ guard(signal)
273
+ let to = resolvePoint(target)
274
+ const hidden = element()?.getAttribute('data-state') !== 'shown'
275
+ const from = hidden || !memory.point ? { x: to.x + 40, y: to.y + 40 } : memory.point
276
+ place(from)
277
+ setState('data-state', 'shown')
278
+ await tween(
279
+ duration,
280
+ progress => {
281
+ to = tourPointerPoint(target) ?? to
282
+ const point = {
283
+ x: from.x + (to.x - from.x) * progress,
284
+ y: from.y + (to.y - from.y) * progress,
285
+ }
286
+ place(point)
287
+ onMove?.(point)
288
+ },
289
+ signal,
290
+ )
291
+ }
292
+ return {
293
+ async move(target, duration = 600) {
294
+ await ready()
295
+ await travel(target, duration)
296
+ },
297
+ async tap() {
298
+ await ready()
299
+ setState('data-pressed', '')
300
+ ripple()
301
+ await tourSleep(120, signal)
302
+ setState('data-pressed', null)
303
+ await tourSleep(180, signal)
304
+ },
305
+ async press(duration = 800) {
306
+ await ready()
307
+ element()?.style.setProperty('--hn-tour-press', `${duration}ms`)
308
+ setState('data-pressed', 'long')
309
+ try {
310
+ await tourSleep(duration, signal)
311
+ } finally {
312
+ setState('data-pressed', null)
313
+ }
314
+ },
315
+ async drag(target, duration = 800, onMove) {
316
+ await ready()
317
+ setState('data-pressed', '')
318
+ try {
319
+ await tourSleep(80, signal)
320
+ if (memory.point) onMove?.(memory.point)
321
+ await travel(target, duration, onMove)
322
+ await tourSleep(80, signal)
323
+ } finally {
324
+ setState('data-pressed', null)
325
+ }
326
+ },
327
+ async key(key, duration = 700) {
328
+ await ready()
329
+ const badge = keyBadge()
330
+ if (!badge) return
331
+ const label = badge.querySelector('[data-hn-tour-key-label]') ?? badge
332
+ label.textContent = key
333
+ const near = element()?.getAttribute('data-state') === 'shown' && memory.point
334
+ const x = near ? memory.point!.x + 20 : window.innerWidth / 2
335
+ const y = near ? memory.point!.y + 28 : window.innerHeight - 72
336
+ badge.style.transform = `translate3d(${x}px, ${y}px, 0)`
337
+ badge.setAttribute('data-placement', near ? 'pointer' : 'screen')
338
+ badge.setAttribute('data-state', 'shown')
339
+ try {
340
+ await tourSleep(duration, signal)
341
+ } finally {
342
+ badge.setAttribute('data-state', 'hidden')
343
+ }
344
+ },
345
+ async hide() {
346
+ setState('data-state', 'hidden')
347
+ },
348
+ }
349
+ }
350
+
351
+ export function createTourEngine(options: TourEngineOptions) {
352
+ let state = INITIAL
353
+ const listeners = new Set<() => void>()
354
+ const set = (patch: Partial<TourEngineState>) => {
355
+ state = { ...state, ...patch }
356
+ for (const listener of [...listeners]) listener()
357
+ }
358
+
359
+ let step: TourStepBase = {}
360
+ let stepNumber = 0
361
+ let padding = TOUR_PADDING
362
+ let resolved: TourResolvedTarget | null = null
363
+ let stopTracking = () => {}
364
+ let token = 0
365
+ let epoch = 0
366
+ let revealAt = 0
367
+ let entering = false
368
+ let entry = Promise.resolve()
369
+ let markEntered = () => {}
370
+ let waiting: AbortController | null = null
371
+
372
+ let position: TourRect | null = null
373
+ let velocity: TourRect = { x: 0, y: 0, width: 0, height: 0 }
374
+ let frame = 0
375
+ let last = 0
376
+ let settleWaiters: (() => void)[] = []
377
+
378
+ let run: { controller: AbortController; cleanups: (() => void)[] } | null = null
379
+ const pointerMemory: { point: TourPoint | null } = { point: null }
380
+
381
+ const viewport = () => ({ width: window.innerWidth, height: window.innerHeight })
382
+ const attached = (found: TourResolvedTarget) =>
383
+ found.elements.every(element => element.isConnected)
384
+ const goal = () => {
385
+ if (!resolved) return null
386
+ if (!attached(resolved)) return position ? { ...position } : null
387
+ return spotlightRect(toBox(resolved.rect()), padding)
388
+ }
389
+ const toBox = (rect: TourRect) => ({
390
+ left: rect.x,
391
+ top: rect.y,
392
+ width: rect.width,
393
+ height: rect.height,
394
+ })
395
+
396
+ function flushSettled() {
397
+ const waiters = settleWaiters
398
+ settleWaiters = []
399
+ for (const resolve of waiters) resolve()
400
+ }
401
+
402
+ function stopSpring() {
403
+ if (frame) cancelAnimationFrame(frame)
404
+ frame = 0
405
+ flushSettled()
406
+ }
407
+
408
+ function snap() {
409
+ if (resolved && !attached(resolved)) {
410
+ set({ viewport: viewport() })
411
+ return
412
+ }
413
+ const target = goal()
414
+ position = target ? { ...target } : null
415
+ velocity = { x: 0, y: 0, width: 0, height: 0 }
416
+ set({
417
+ hole: position ? { ...position } : null,
418
+ targetRect: resolved ? resolved.rect() : null,
419
+ viewport: viewport(),
420
+ })
421
+ }
422
+
423
+ function tick(now: number) {
424
+ const target = goal()
425
+ if (!target || !position) {
426
+ frame = 0
427
+ snap()
428
+ flushSettled()
429
+ return
430
+ }
431
+ const seconds = Math.min((now - last) / 1000, 1 / 20)
432
+ last = now
433
+ const done = stepSpring(position, velocity, target, seconds)
434
+ if (done) {
435
+ frame = 0
436
+ snap()
437
+ flushSettled()
438
+ return
439
+ }
440
+ set({
441
+ hole: { ...position },
442
+ ...(attached(resolved!) ? { targetRect: resolved!.rect() } : {}),
443
+ viewport: viewport(),
444
+ })
445
+ frame = requestAnimationFrame(tick)
446
+ }
447
+
448
+ function springTo(animate: boolean) {
449
+ if (!animate || !position || reducedMotion()) {
450
+ stopSpring()
451
+ snap()
452
+ return Promise.resolve()
453
+ }
454
+ const settled = new Promise<void>(resolve => settleWaiters.push(resolve))
455
+ if (!frame) {
456
+ last = performance.now()
457
+ frame = requestAnimationFrame(tick)
458
+ }
459
+ return settled
460
+ }
461
+
462
+ function track() {
463
+ if (frame) return
464
+ snap()
465
+ }
466
+
467
+ function follow(target: TourTarget | undefined, found: TourResolvedTarget) {
468
+ let stopWatch = watchTourTarget(found, track)
469
+ const observer = new MutationObserver(() => {
470
+ if (!resolved || attached(resolved)) return
471
+ const again = resolveTourTarget(target)
472
+ if (!again || !attached(again)) return
473
+ resolved = again
474
+ stopWatch()
475
+ set({ elements: again.elements })
476
+ void springTo(!!position)
477
+ stopWatch = watchTourTarget(again, track)
478
+ })
479
+ observer.observe(document.body, { childList: true, subtree: true })
480
+ return () => {
481
+ observer.disconnect()
482
+ stopWatch()
483
+ }
484
+ }
485
+
486
+ function onResize() {
487
+ if (frame) return
488
+ if (resolved) snap()
489
+ else set({ viewport: viewport() })
490
+ }
491
+
492
+ async function reveal(current: number) {
493
+ if (revealAt > performance.now()) {
494
+ await delay(revealAt - performance.now())
495
+ if (current !== token) return false
496
+ }
497
+ entering = false
498
+ set({ settled: true })
499
+ markEntered()
500
+ return true
501
+ }
502
+
503
+ function untilEntered(signal: AbortSignal) {
504
+ return new Promise<void>((resolve, reject) => {
505
+ if (signal.aborted) return reject(abortError())
506
+ const abort = () => reject(abortError())
507
+ signal.addEventListener('abort', abort, { once: true })
508
+ void entry.then(() => {
509
+ signal.removeEventListener('abort', abort)
510
+ if (signal.aborted) reject(abortError())
511
+ else resolve()
512
+ })
513
+ })
514
+ }
515
+
516
+ async function aim(
517
+ target: TourTarget | undefined,
518
+ {
519
+ wait = 0,
520
+ scroll = true,
521
+ padding: nextPadding,
522
+ radius,
523
+ }: TourSpotlightOptions & { scroll?: boolean },
524
+ ) {
525
+ const current = ++token
526
+ waiting?.abort()
527
+ const controller = new AbortController()
528
+ waiting = controller
529
+ let found = resolveTourTarget(target)
530
+ if (!found && target && wait > 0) {
531
+ found = await waitForTourTarget(target, wait, controller.signal)
532
+ if (current !== token) return 'stale' as const
533
+ }
534
+ if (!found) {
535
+ stopTracking()
536
+ stopTracking = () => {}
537
+ resolved = null
538
+ stopSpring()
539
+ position = null
540
+ set({
541
+ status: 'missing',
542
+ settled: !entering,
543
+ hole: null,
544
+ targetRect: null,
545
+ elements: [],
546
+ viewport: viewport(),
547
+ })
548
+ if (entering && !(await reveal(current))) return 'stale' as const
549
+ return target ? ('missing' as const) : ('empty' as const)
550
+ }
551
+ stopTracking()
552
+ stopTracking = () => {}
553
+ const animate = !!position
554
+ if (scroll) revealTourTarget(found)
555
+ if (animate && !reducedMotion()) {
556
+ stopSpring()
557
+ for (let attempt = 0; attempt < 3; attempt++) {
558
+ await waitForStillTarget(found)
559
+ if (current !== token) return 'stale' as const
560
+ if (attached(found)) break
561
+ const again = resolveTourTarget(target)
562
+ if (!again) break
563
+ found = again
564
+ }
565
+ }
566
+ resolved = found
567
+ padding = nextPadding ?? step.padding ?? TOUR_PADDING
568
+ set({
569
+ status: 'ready',
570
+ elements: found.elements,
571
+ radius: radius ?? step.radius ?? TOUR_RADIUS,
572
+ ...(entering ? { settled: false } : {}),
573
+ })
574
+ const settled = springTo(animate)
575
+ stopTracking = follow(target, found)
576
+ await settled
577
+ if (current !== token) return 'stale' as const
578
+ if (entering && !(await reveal(current))) return 'stale' as const
579
+ return 'ready' as const
580
+ }
581
+
582
+ function abortRun() {
583
+ const current = run
584
+ if (!current) return
585
+ run = null
586
+ current.controller.abort()
587
+ for (const cleanup of current.cleanups.reverse()) {
588
+ try {
589
+ cleanup()
590
+ } catch (error) {
591
+ console.error(error)
592
+ }
593
+ }
594
+ options.pointer()?.setAttribute('data-state', 'hidden')
595
+ options.pointer()?.removeAttribute('data-pressed')
596
+ options.keyBadge()?.setAttribute('data-state', 'hidden')
597
+ set({ playing: false })
598
+ }
599
+
600
+ function play() {
601
+ abortRun()
602
+ epoch += 1
603
+ const script = step.play
604
+ if (!script) return
605
+ const controller = new AbortController()
606
+ const cleanups: (() => void)[] = []
607
+ const current = { controller, cleanups }
608
+ run = current
609
+ const { signal } = controller
610
+ const context: TourPlayContext = {
611
+ step: stepNumber,
612
+ signal,
613
+ defer(cleanup) {
614
+ if (signal.aborted) cleanup()
615
+ else cleanups.push(cleanup)
616
+ },
617
+ pointer: createPointer(options.pointer, options.keyBadge, signal, pointerMemory, () =>
618
+ untilEntered(signal),
619
+ ),
620
+ async spotlight(target, spotlightOptions = {}) {
621
+ guard(signal)
622
+ await aim(target === undefined ? step.target : target, {
623
+ ...spotlightOptions,
624
+ scroll: false,
625
+ })
626
+ guard(signal)
627
+ },
628
+ async waitFor(target, timeout = 5000) {
629
+ guard(signal)
630
+ const found = await waitForTourTarget(target, timeout, signal)
631
+ guard(signal)
632
+ return !!found
633
+ },
634
+ sleep: ms => tourSleep(ms, signal),
635
+ }
636
+ set({ playing: true })
637
+ Promise.resolve()
638
+ .then(() => script(context))
639
+ .catch(error => {
640
+ if (!isAbortError(error)) console.error(error)
641
+ })
642
+ .finally(() => {
643
+ if (run === current) set({ playing: false })
644
+ })
645
+ }
646
+
647
+ return {
648
+ subscribe(listener: () => void) {
649
+ listeners.add(listener)
650
+ return () => {
651
+ listeners.delete(listener)
652
+ }
653
+ },
654
+ getState: () => state,
655
+ pointerKind: options.pointerKind,
656
+ enter(next: TourStepBase, number: number) {
657
+ abortRun()
658
+ epoch += 1
659
+ const fresh = state.status === 'idle'
660
+ if (fresh) {
661
+ stopSpring()
662
+ position = null
663
+ pointerMemory.point = null
664
+ window.addEventListener('resize', onResize)
665
+ }
666
+ revealAt = fresh ? 0 : performance.now() + (options.cardGap?.() ?? 0)
667
+ markEntered()
668
+ entering = true
669
+ entry = new Promise<void>(resolve => (markEntered = resolve))
670
+ step = next
671
+ stepNumber = number
672
+ set({
673
+ status: 'waiting',
674
+ settled: false,
675
+ viewport: viewport(),
676
+ ...(fresh ? { hole: null, targetRect: null, elements: [] } : {}),
677
+ })
678
+ return aim(next.target, {
679
+ wait: next.wait ?? 0,
680
+ scroll: next.scroll !== false,
681
+ })
682
+ },
683
+ play,
684
+ replay() {
685
+ if (!step.play) return
686
+ abortRun()
687
+ const current = ++epoch
688
+ stopTracking()
689
+ stopTracking = () => {}
690
+ stopSpring()
691
+ void waitForFiniteAnimations().then(() => {
692
+ if (current !== epoch) return
693
+ void aim(step.target, { wait: step.wait ?? 0, scroll: false })
694
+ play()
695
+ })
696
+ },
697
+ leave() {
698
+ abortRun()
699
+ epoch += 1
700
+ token += 1
701
+ waiting?.abort()
702
+ waiting = null
703
+ },
704
+ close() {
705
+ abortRun()
706
+ epoch += 1
707
+ token += 1
708
+ waiting?.abort()
709
+ waiting = null
710
+ stopTracking()
711
+ stopTracking = () => {}
712
+ stopSpring()
713
+ window.removeEventListener('resize', onResize)
714
+ entering = false
715
+ markEntered()
716
+ set({ status: 'idle', settled: false, playing: false })
717
+ },
718
+ }
719
+ }
720
+
721
+ export type TourEngine = ReturnType<typeof createTourEngine>