@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
@@ -1,31 +1,44 @@
1
1
  <script setup lang="ts">
2
- import { computed, nextTick, onMounted, ref, shallowRef, useId, watch, watchEffect } from 'vue'
2
+ import {
3
+ computed,
4
+ nextTick,
5
+ onBeforeUnmount,
6
+ onMounted,
7
+ ref,
8
+ shallowRef,
9
+ useId,
10
+ watch,
11
+ watchEffect,
12
+ } from 'vue'
13
+ import { RotateCcw } from '@lucide/vue'
3
14
  import {
4
15
  beaconPoint,
5
16
  clampTourStep,
6
17
  hideOutsideTour,
7
- resolveTourTarget,
8
- revealTourTarget,
18
+ listenTourKeys,
9
19
  runTourChange,
10
20
  spotlightMask,
11
- spotlightRect,
12
21
  spotlightShape,
13
22
  TOUR_ARROW_HEIGHT,
14
23
  TOUR_ARROW_PADDING,
15
24
  TOUR_ARROW_PATH,
16
25
  TOUR_ARROW_WIDTH,
17
- TOUR_PADDING,
18
- TOUR_RADIUS,
19
26
  TOUR_SIDE_OFFSET,
20
27
  tourBeaconOnArrival,
21
28
  tourKeyAction,
29
+ tourOwnsInput,
30
+ tourCardGap,
31
+ tourPageBlocked,
22
32
  tourReference,
23
33
  tourTargetBlocked,
24
34
  trapTourFocus,
25
- watchTourTarget,
35
+ type TourCloseReason,
36
+ type TourPointerKind,
26
37
  type TourRect,
27
38
  type TourSpotlight,
28
39
  } from '../../../../shared/src/lib/tour'
40
+ import { createTourEngine } from '../../../../shared/src/lib/tour-engine'
41
+ import { addBranch } from '../../../../shared/src/primitives/dismissable-layer'
29
42
  import { DismissableLayer } from '../../primitives/dismissable-layer'
30
43
  import { PopperArrow, PopperContent, PopperRoot } from '../../primitives/popper'
31
44
  import { Presence } from '../../primitives/presence'
@@ -37,6 +50,7 @@
37
50
  import Button from '../button/Button.vue'
38
51
  import Card from '../card/Card.vue'
39
52
  import CloseButton from '../close-button/CloseButton.vue'
53
+ import IconButton from '../icon-button/IconButton.vue'
40
54
  import Text from '../text/Text.vue'
41
55
  import {
42
56
  tourBeacon,
@@ -47,14 +61,30 @@
47
61
  tourCentered,
48
62
  tourFooter,
49
63
  tourHeader,
64
+ tourKey,
65
+ tourKeyCap,
50
66
  tourMask,
67
+ tourPointer,
68
+ tourPointerArrow,
69
+ tourPointerTouch,
51
70
  } from './tour.variants'
52
71
  import type { TourExpose, TourProps, TourSlotProps, TourStep } from './types'
53
72
 
54
73
  defineOptions({ name: 'HnTour' })
55
74
 
56
- const props = withDefaults(defineProps<TourProps>(), { mask: true, blockTarget: false })
57
- const emit = defineEmits<{ finish: []; skip: [] }>()
75
+ const props = withDefaults(defineProps<TourProps>(), {
76
+ mask: true,
77
+ blockTarget: false,
78
+ blockPage: false,
79
+ pointer: 'auto',
80
+ })
81
+ const emit = defineEmits<{
82
+ finish: []
83
+ skip: []
84
+ close: [reason: TourCloseReason]
85
+ stepEnter: [step: number]
86
+ stepLeave: [step: number]
87
+ }>()
58
88
  defineSlots<{
59
89
  content?(props: TourSlotProps): unknown
60
90
  footer?(props: TourSlotProps): unknown
@@ -74,150 +104,216 @@
74
104
  const isLast = computed(() => current.value >= total.value)
75
105
  const busy = ref(false)
76
106
  const beaconPending = ref(false)
77
- const resolvedFor = ref<number | null>(null)
78
- const target = shallowRef<HTMLElement | null>(null)
79
- const hole = ref<TourRect | null>(null)
80
- const targetRect = ref<TourRect | null>(null)
81
- const viewport = ref({ width: 0, height: 0 })
107
+ const entered = ref<number | null>(null)
108
+ const coarse = ref(false)
109
+
110
+ const pointerKind = computed<TourPointerKind>(() =>
111
+ props.pointer === 'auto' ? (coarse.value ? 'touch' : 'mouse') : props.pointer,
112
+ )
113
+
114
+ const card = shallowRef<{ $el: HTMLElement } | null>(null)
115
+ const layer = shallowRef<{ $el: HTMLElement } | null>(null)
116
+ const beaconButton = shallowRef<HTMLButtonElement | null>(null)
117
+ const maskElement = shallowRef<HTMLElement | null>(null)
118
+ const blockerElement = shallowRef<HTMLElement | null>(null)
119
+ const pointerElement = shallowRef<HTMLElement | null>(null)
120
+ const keyElement = shallowRef<HTMLElement | null>(null)
121
+
122
+ const engine = createTourEngine({
123
+ pointer: () => pointerElement.value,
124
+ keyBadge: () => keyElement.value,
125
+ pointerKind: () => pointerKind.value,
126
+ cardGap: () => tourCardGap(card.value?.$el),
127
+ })
128
+ const tour = shallowRef(engine.getState())
129
+ const unsubscribe = engine.subscribe(() => (tour.value = engine.getState()))
82
130
 
83
- const padding = computed(() => currentStep.value.padding ?? TOUR_PADDING)
84
131
  const active = computed(() => open.value && total.value > 0)
85
- const resolved = computed(() => active.value && resolvedFor.value !== null)
86
- const beaconVisible = computed(() => resolved.value && !!target.value && beaconPending.value)
87
- const cardVisible = computed(() => resolved.value && !beaconVisible.value)
88
- const maskVisible = computed(() => cardVisible.value && props.mask)
89
- const modal = computed(() => props.mask)
132
+ const engaged = computed(() => active.value && entered.value !== null)
133
+ const live = computed(() => engaged.value && entered.value === current.value)
134
+ const status = computed(() => (engaged.value ? tour.value.status : 'idle'))
135
+ const beaconVisible = computed(
136
+ () => live.value && status.value === 'ready' && !!tour.value.hole && beaconPending.value,
137
+ )
138
+ const cardVisible = computed(
139
+ () =>
140
+ live.value &&
141
+ (status.value === 'ready' || status.value === 'missing') &&
142
+ tour.value.settled &&
143
+ !beaconVisible.value,
144
+ )
145
+ let anchoredWhenShown = false
146
+ const anchored = computed(() => {
147
+ if (!cardVisible.value) return anchoredWhenShown
148
+ anchoredWhenShown = status.value === 'ready' && !!tour.value.hole
149
+ return anchoredWhenShown
150
+ })
151
+ let stepWhenShown = current.value
152
+ const shown = computed(() => {
153
+ if (cardVisible.value) stepWhenShown = current.value
154
+ return clampTourStep(stepWhenShown, total.value)
155
+ })
156
+ const shownStep = computed<TourStep>(() => props.steps[shown.value - 1] ?? {})
157
+ const maskVisible = computed(
158
+ () =>
159
+ !!props.mask &&
160
+ status.value !== 'idle' &&
161
+ !beaconVisible.value &&
162
+ !(beaconPending.value && status.value === 'waiting'),
163
+ )
164
+ const modal = computed(() => !!props.mask)
165
+ const scripted = computed(() => !!currentStep.value.play)
90
166
  const blocked = computed(() => tourTargetBlocked(currentStep.value, props.blockTarget))
91
- const blocking = computed(() => cardVisible.value && !!hole.value && blocked.value)
167
+ const pageBlocked = computed(
168
+ () => engaged.value && tourPageBlocked(currentStep.value, props.blockPage),
169
+ )
170
+ const blocking = computed(() => cardVisible.value && !!tour.value.hole && blocked.value)
171
+ const targetElements = computed(() => tour.value.elements)
92
172
 
93
173
  const spotlight = computed(() =>
94
- hole.value
95
- ? spotlightShape(viewport.value, hole.value, currentStep.value.radius ?? TOUR_RADIUS)
174
+ tour.value.hole
175
+ ? spotlightShape(tour.value.viewport, tour.value.hole, tour.value.radius)
96
176
  : null,
97
177
  )
98
178
  const lastSpotlight = shallowRef<TourSpotlight | null>(null)
99
179
  const spotlightState = computed(() => spotlightMask(spotlight.value, lastSpotlight.value))
100
- const holeStyle = computed(() =>
101
- hole.value
102
- ? {
103
- left: `${hole.value.x}px`,
104
- top: `${hole.value.y}px`,
105
- width: `${hole.value.width}px`,
106
- height: `${hole.value.height}px`,
107
- }
108
- : undefined,
109
- )
180
+ const blockerStyle = computed(() => {
181
+ if (pageBlocked.value) return { left: '0px', top: '0px', width: '100%', height: '100%' }
182
+ const hole = tour.value.hole
183
+ if (!blocking.value || !hole) return undefined
184
+ return {
185
+ left: `${hole.x}px`,
186
+ top: `${hole.y}px`,
187
+ width: `${hole.width}px`,
188
+ height: `${hole.height}px`,
189
+ }
190
+ })
110
191
  const beaconStyle = computed(() => {
111
- if (!targetRect.value) return undefined
112
- const point = beaconPoint(targetRect.value, direction.value)
192
+ if (!tour.value.targetRect) return undefined
193
+ const point = beaconPoint(tour.value.targetRect, direction.value)
113
194
  return { left: `${point.x}px`, top: `${point.y}px` }
114
195
  })
115
- const reference = computed(() =>
116
- target.value ? tourReference(target.value, padding.value) : undefined,
117
- )
196
+ let holeWhenShown: TourRect | null = null
197
+ const reference = tourReference(() => {
198
+ if (cardVisible.value) holeWhenShown = engine.getState().hole
199
+ return holeWhenShown
200
+ })
118
201
 
119
- const card = shallowRef<{ $el: HTMLElement } | null>(null)
120
- const layer = shallowRef<{ $el: HTMLElement } | null>(null)
121
- const beaconButton = shallowRef<HTMLButtonElement | null>(null)
122
202
  let returnFocus: HTMLElement | null = null
123
-
124
- const mounted = ref(false)
203
+ let closeReason: TourCloseReason | null = null
125
204
  let navigated = false
205
+ let refocusBeacon = false
206
+ const mounted = ref(false)
207
+
208
+ function enterStep() {
209
+ if (!mounted.value) return
210
+ if (!active.value) {
211
+ if (entered.value !== null) exitTour()
212
+ return
213
+ }
214
+ if (entered.value === current.value) return
215
+ const previous = entered.value
216
+ refocusBeacon = !!card.value?.$el.contains(document.activeElement)
217
+ if (previous !== null) {
218
+ engine.leave()
219
+ emit('stepLeave', previous)
220
+ } else {
221
+ returnFocus = document.activeElement as HTMLElement | null
222
+ closeReason = null
223
+ }
224
+ beaconPending.value = tourBeaconOnArrival(
225
+ currentStep.value,
226
+ !!props.beacon,
227
+ previous !== null && navigated,
228
+ )
229
+ navigated = false
230
+ const number = current.value
231
+ entered.value = number
232
+ emit('stepEnter', number)
233
+ void engine.enter(currentStep.value, number).then(result => {
234
+ if (result === 'missing' && entered.value === number)
235
+ devWarn('Tour', `the target of step ${number} was not found; the card is centered`)
236
+ })
237
+ if (!beaconPending.value) engine.play()
238
+ }
126
239
 
127
- function resolveTarget() {
128
- if (!mounted.value || !active.value) return
129
- const definition = currentStep.value.target
130
- const element = resolveTourTarget(definition)
131
- if (definition && !element)
132
- devWarn('Tour', `the target of step ${current.value} was not found; the card is centered`)
133
- target.value = element
134
- root.value = element ?? document.documentElement
135
- resolvedFor.value = current.value
136
- if (element && currentStep.value.scroll !== false) revealTourTarget(element)
240
+ function exitTour() {
241
+ const previous = entered.value
242
+ entered.value = null
243
+ engine.close()
244
+ lastSpotlight.value = null
245
+ if (previous !== null) emit('stepLeave', previous)
246
+ emit('close', closeReason ?? 'close')
247
+ closeReason = null
248
+ void nextTick(restoreFocus)
137
249
  }
138
250
 
139
- watch([active, current, () => props.steps], resolveTarget, { flush: 'post' })
251
+ watch([active, current], enterStep, { flush: 'post' })
140
252
 
141
253
  onMounted(() => {
142
254
  mounted.value = true
143
- beaconPending.value = tourBeaconOnArrival(currentStep.value, !!props.beacon, false)
144
- resolveTarget()
145
- if (!active.value) return
146
- returnFocus = document.activeElement as HTMLElement | null
147
- void nextTick(() => {
148
- if (cardVisible.value) card.value?.$el.focus({ preventScroll: true })
149
- })
255
+ coarse.value = !!window.matchMedia?.('(pointer: coarse)').matches
256
+ enterStep()
150
257
  })
151
258
 
152
- watch([active, current], ([isActive], [wasActive]) => {
153
- const focusInCard = !!card.value?.$el.contains(document.activeElement)
154
- beaconPending.value = tourBeaconOnArrival(
155
- currentStep.value,
156
- !!props.beacon,
157
- !!isActive && !!wasActive && navigated,
158
- )
159
- navigated = false
160
- if (!isActive) {
161
- resolvedFor.value = null
162
- lastSpotlight.value = null
163
- }
164
- if (isActive && !wasActive) returnFocus = document.activeElement as HTMLElement | null
165
- if (!isActive && wasActive) void nextTick(restoreFocus)
166
- if (isActive && wasActive && focusInCard)
167
- void nextTick(() => {
168
- if (beaconVisible.value) beaconButton.value?.focus()
169
- })
259
+ onBeforeUnmount(() => {
260
+ unsubscribe()
261
+ engine.close()
170
262
  })
171
263
 
172
264
  watch([spotlight, maskVisible], ([value, visible]) => {
173
265
  if (value && visible) lastSpotlight.value = value
174
266
  })
175
267
 
268
+ watch(targetElements, elements => {
269
+ root.value = elements[0] ?? document.documentElement
270
+ })
271
+
176
272
  watch(cardVisible, visible => {
177
273
  if (visible) void nextTick(() => card.value?.$el.focus({ preventScroll: true }))
178
274
  })
179
275
 
180
- watchEffect(onCleanup => {
181
- const element = target.value
182
- if (!mounted.value || !active.value) return
183
- const update = () => {
184
- viewport.value = { width: window.innerWidth, height: window.innerHeight }
185
- if (!element) {
186
- hole.value = null
187
- targetRect.value = null
188
- return
189
- }
190
- const rect = element.getBoundingClientRect()
191
- targetRect.value = spotlightRect(rect, 0)
192
- hole.value = spotlightRect(rect, padding.value)
193
- }
194
- if (!element) {
195
- update()
196
- window.addEventListener('resize', update)
197
- onCleanup(() => window.removeEventListener('resize', update))
198
- return
199
- }
200
- onCleanup(watchTourTarget(element, update))
276
+ watch(beaconVisible, visible => {
277
+ if (!visible || !refocusBeacon) return
278
+ refocusBeacon = false
279
+ void nextTick(() => beaconButton.value?.focus())
201
280
  })
202
281
 
282
+ function useBranch(element: () => HTMLElement | null | undefined) {
283
+ watch(
284
+ element,
285
+ (node, _, onCleanup) => {
286
+ if (node) onCleanup(addBranch(node))
287
+ },
288
+ { immediate: true, flush: 'post' },
289
+ )
290
+ }
291
+
292
+ useBranch(() => layer.value?.$el)
293
+ useBranch(() => maskElement.value)
294
+ useBranch(() => blockerElement.value)
295
+ useBranch(() => beaconButton.value)
296
+
297
+ const owns = () => tourOwnsInput(layer.value?.$el, scripted.value || pageBlocked.value)
298
+
203
299
  watchEffect(
204
300
  onCleanup => {
205
301
  if (!cardVisible.value || !modal.value) return
206
302
  const layerElement = layer.value?.$el as HTMLElement | undefined
207
303
  if (!layerElement?.isConnected) return
208
- const reachable = !blocked.value
209
- const element = target.value
210
- onCleanup(hideOutsideTour([layerElement, element]))
211
- onCleanup(
212
- trapTourFocus(
213
- () => [card.value?.$el, reachable ? element : null],
214
- () => layer.value?.$el,
215
- ),
216
- )
304
+ const reachable = !blocked.value && !pageBlocked.value
305
+ const elements = targetElements.value
306
+ onCleanup(hideOutsideTour([layerElement, ...elements]))
307
+ onCleanup(trapTourFocus(() => [card.value?.$el, ...(reachable ? elements : [])], owns))
217
308
  },
218
309
  { flush: 'post' },
219
310
  )
220
311
 
312
+ watchEffect(onCleanup => {
313
+ if (!engaged.value || beaconVisible.value) return
314
+ onCleanup(listenTourKeys(onKeydown))
315
+ })
316
+
221
317
  function restoreFocus() {
222
318
  const focus = returnFocus
223
319
  returnFocus = null
@@ -257,21 +353,29 @@
257
353
 
258
354
  function finish() {
259
355
  emit('finish')
356
+ closeReason = 'finish'
260
357
  open.value = false
261
358
  }
262
359
 
263
- function skip() {
360
+ function skip(reason: 'skip' | 'escape' = 'skip') {
264
361
  if (busy.value) return
265
362
  emit('skip')
363
+ closeReason = reason
266
364
  open.value = false
267
365
  }
268
366
 
269
367
  function close() {
368
+ closeReason = 'close'
270
369
  open.value = false
271
370
  }
272
371
 
273
- function onEscape(event: KeyboardEvent) {
274
- if (props.locked || busy.value) event.preventDefault()
372
+ function replay() {
373
+ if (live.value && !beaconPending.value) engine.replay()
374
+ }
375
+
376
+ function openBeacon() {
377
+ beaconPending.value = false
378
+ engine.play()
275
379
  }
276
380
 
277
381
  function preventDismiss(event: Event) {
@@ -279,6 +383,13 @@
279
383
  }
280
384
 
281
385
  function onKeydown(event: KeyboardEvent) {
386
+ if (event.defaultPrevented || !owns()) return
387
+ if (event.key === 'Escape') {
388
+ event.preventDefault()
389
+ event.stopPropagation()
390
+ if (!props.locked) skip('escape')
391
+ return
392
+ }
282
393
  const action = tourKeyAction(event, direction.value)
283
394
  if (!action) return
284
395
  event.preventDefault()
@@ -288,25 +399,30 @@
288
399
  }
289
400
 
290
401
  const slotProps = computed<TourSlotProps>(() => ({
291
- step: currentStep.value,
292
- current: current.value,
402
+ step: shownStep.value,
403
+ current: shown.value,
293
404
  total: total.value,
294
405
  busy: busy.value,
406
+ playing: tour.value.playing,
295
407
  next: () => void next(),
296
408
  prev: () => void prev(),
297
- skip,
409
+ skip: () => skip(),
410
+ replay,
298
411
  }))
299
412
 
300
413
  const popperProps = computed(() =>
301
- target.value
414
+ anchored.value
302
415
  ? {
303
416
  asChild: true,
304
- reference: reference.value,
305
- side: currentStep.value.side ?? 'bottom',
306
- align: currentStep.value.align ?? 'center',
417
+ reference,
418
+ side: shownStep.value.side ?? 'bottom',
419
+ align: shownStep.value.align ?? 'center',
307
420
  sideOffset: TOUR_SIDE_OFFSET,
308
421
  collisionPadding: 16,
309
422
  arrowPadding: TOUR_ARROW_PADDING,
423
+ prioritizePosition: true,
424
+ sticky: 'always' as const,
425
+ updatePositionStrategy: 'always' as const,
310
426
  }
311
427
  : {
312
428
  class: cn(tourCentered(), 'hn-anim-pop'),
@@ -314,23 +430,57 @@
314
430
  },
315
431
  )
316
432
 
317
- defineExpose<TourExpose>({ next, prev, goTo, close })
433
+ defineExpose<TourExpose>({ next, prev, goTo, close, replay })
318
434
  </script>
319
435
 
320
436
  <template>
321
437
  <TeleportPrimitive v-if="total > 0">
322
438
  <div
439
+ ref="blockerElement"
323
440
  data-hn-tour-blocker
324
441
  aria-hidden="true"
325
- :hidden="!blocking"
442
+ :hidden="!blockerStyle"
326
443
  :class="tourBlocker()"
327
- :style="blocking ? holeStyle : undefined"
444
+ :style="blockerStyle"
328
445
  />
446
+ <div
447
+ ref="pointerElement"
448
+ data-hn-tour-pointer
449
+ :data-kind="pointerKind"
450
+ data-state="hidden"
451
+ aria-hidden="true"
452
+ :class="tourPointer()"
453
+ >
454
+ <span data-hn-tour-pointer-ring />
455
+ <span data-hn-tour-pointer-ripples />
456
+ <svg
457
+ v-if="pointerKind === 'mouse'"
458
+ data-hn-tour-pointer-glyph
459
+ viewBox="0 0 24 24"
460
+ stroke-width="1.5"
461
+ stroke-linejoin="round"
462
+ :class="tourPointerArrow()"
463
+ >
464
+ <path d="M4 2.5v16.2l4.1-3.9 2.8 6.6 2.9-1.2-2.8-6.5h5.8z" />
465
+ </svg>
466
+ <span v-else data-hn-tour-pointer-glyph :class="tourPointerTouch()" />
467
+ </div>
468
+ <div
469
+ ref="keyElement"
470
+ data-hn-tour-key
471
+ data-state="hidden"
472
+ aria-hidden="true"
473
+ :class="tourKey()"
474
+ >
475
+ <kbd data-hn-tour-key-label :class="tourKeyCap()" />
476
+ </div>
329
477
  <Presence :present="maskVisible">
330
478
  <div
479
+ ref="maskElement"
331
480
  data-hn-tour-mask
332
481
  aria-hidden="true"
333
482
  :data-state="maskVisible ? 'open' : 'closed'"
483
+ :data-mask="props.mask === 'dim' ? 'dim' : undefined"
334
484
  :data-hn-tour-hole="spotlightState.hole"
335
485
  :class="tourMask()"
336
486
  :style="spotlightState.style"
@@ -344,7 +494,7 @@
344
494
  :aria-label="t.tour.beacon(current, currentStep.title ?? '')"
345
495
  :class="tourBeacon()"
346
496
  :style="beaconStyle"
347
- @click="beaconPending = false"
497
+ @click="openBeacon"
348
498
  >
349
499
  <span :class="tourBeaconPulse()" />
350
500
  <span :class="tourBeaconDot()" />
@@ -354,33 +504,31 @@
354
504
  <DismissableLayer
355
505
  ref="layer"
356
506
  as-child
357
- @escape-key-down="onEscape"
507
+ @escape-key-down="preventDismiss"
358
508
  @pointer-down-outside="preventDismiss"
359
509
  @focus-outside="preventDismiss"
360
- @dismiss="skip"
361
510
  >
362
- <component :is="target ? PopperContent : 'div'" v-bind="popperProps">
511
+ <component :is="anchored ? PopperContent : 'div'" v-bind="popperProps">
363
512
  <Card
364
513
  ref="card"
365
514
  data-hn-tour
366
- :data-step="current"
515
+ :data-step="shown"
367
516
  :data-state="cardVisible ? 'open' : 'closed'"
368
517
  :padded="false"
369
518
  role="dialog"
370
519
  tabindex="-1"
371
520
  :dir="direction"
372
521
  :aria-modal="modal ? 'true' : undefined"
373
- :aria-labelledby="currentStep.title ? titleId : undefined"
522
+ :aria-labelledby="shownStep.title ? titleId : undefined"
374
523
  :aria-describedby="
375
- currentStep.description || $slots.content ? descriptionId : undefined
524
+ shownStep.description || $slots.content ? descriptionId : undefined
376
525
  "
377
526
  :aria-busy="busy || undefined"
378
- :class="cn(tourCard(), target && 'hn-anim-pop', props.class)"
379
- @keydown="onKeydown"
527
+ :class="cn(tourCard(), anchored && 'hn-anim-pop', props.class)"
380
528
  >
381
- <div v-if="currentStep.title || !props.locked" :class="tourHeader()">
382
- <Text v-if="currentStep.title" :id="titleId" weight="medium" class="min-w-0 flex-1">
383
- {{ currentStep.title }}
529
+ <div v-if="shownStep.title || !props.locked" :class="tourHeader()">
530
+ <Text v-if="shownStep.title" :id="titleId" weight="medium" class="min-w-0 flex-1">
531
+ {{ shownStep.title }}
384
532
  </Text>
385
533
  <CloseButton
386
534
  v-if="!props.locked"
@@ -388,16 +536,16 @@
388
536
  class="ms-auto -me-1.5 -mt-1.5"
389
537
  :label="props.skipText ?? t.tour.skip"
390
538
  :disabled="busy"
391
- @click="skip"
539
+ @click="skip()"
392
540
  />
393
541
  </div>
394
- <div v-if="currentStep.description || $slots.content" :id="descriptionId">
542
+ <div v-if="shownStep.description || $slots.content" :id="descriptionId">
395
543
  <slot name="content" v-bind="slotProps">
396
- <Text tone="muted" size="sm">{{ currentStep.description }}</Text>
544
+ <Text tone="muted" size="sm">{{ shownStep.description }}</Text>
397
545
  </slot>
398
546
  </div>
399
547
  <PopperArrow
400
- v-if="target"
548
+ v-if="anchored"
401
549
  data-hn-tour-arrow
402
550
  :width="TOUR_ARROW_WIDTH"
403
551
  :height="TOUR_ARROW_HEIGHT"
@@ -414,11 +562,24 @@
414
562
  <slot name="footer" v-bind="slotProps">
415
563
  <div :class="tourFooter()">
416
564
  <Text as="span" tone="muted" size="sm" class="tabular-nums">
417
- {{ t.tour.progress(current, total) }}
565
+ {{ t.tour.progress(shown, total) }}
418
566
  </Text>
567
+ <IconButton
568
+ v-if="!!shownStep.play"
569
+ data-hn-tour-replay
570
+ :label="props.replayText ?? t.tour.replay"
571
+ :tooltip="false"
572
+ size="sm"
573
+ variant="ghost"
574
+ tone="neutral"
575
+ pill
576
+ @click="replay"
577
+ >
578
+ <RotateCcw />
579
+ </IconButton>
419
580
  <div class="ms-auto flex gap-2">
420
581
  <Button
421
- v-if="current > 1"
582
+ v-if="shown > 1"
422
583
  data-hn-tour-prev
423
584
  size="sm"
424
585
  variant="soft"
@@ -430,7 +591,7 @@
430
591
  </Button>
431
592
  <Button data-hn-tour-next size="sm" :loading="busy" @click="next">
432
593
  {{
433
- isLast
594
+ shown >= total
434
595
  ? (props.finishText ?? t.tour.finish)
435
596
  : (props.nextText ?? t.tour.next)
436
597
  }}