@leavepulse/ui 0.56.0 → 0.58.0

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 (182) hide show
  1. package/dist/chunks/{ContextMenuItems-BtKU7074.js → ContextMenuItems-BY5sTVQF.js} +1 -1
  2. package/dist/chunks/{LayoutCanvas-BkaGXTjx.js → LayoutCanvas-BcGLJVBq.js} +1 -1
  3. package/dist/chunks/{LayoutNode-B3_9E1Xa.js → LayoutNode-BkBn6dvM.js} +1 -1
  4. package/dist/chunks/{LpActionBar-C3hkVsFz.js → LpActionBar-DCN-_pZP.js} +2 -2
  5. package/dist/chunks/{LpAddressInput-NUHmplCd.js → LpAddressInput-Dio6Sqed.js} +7 -7
  6. package/dist/chunks/{LpAlert-B6ilrM7T.js → LpAlert-CXMn1GYE.js} +1 -1
  7. package/dist/chunks/{LpAppShell-BEGjSqFl.js → LpAppShell-Be6T96td.js} +3 -3
  8. package/dist/chunks/{LpAutocomplete-C8IgSMsi.js → LpAutocomplete-hJ_6CHlh.js} +1 -1
  9. package/dist/chunks/{LpAvatar-LTvy0OkE.js → LpAvatar-BxNs23Xu.js} +2 -2
  10. package/dist/chunks/{LpBreadcrumbs-D4TsoFju.js → LpBreadcrumbs-BLzOEgAF.js} +1 -1
  11. package/dist/chunks/{LpButton-BdfZFNqi.js → LpButton-vhlwmpBy.js} +1 -1
  12. package/dist/chunks/{LpCalendar-DrA2McTU.js → LpCalendar-BLoE8Qm7.js} +1 -1
  13. package/dist/chunks/{LpCard-DbjO5yaF.js → LpCard-DhR7u0d8.js} +1 -1
  14. package/dist/chunks/{LpCheckbox-DvVYeOD-.js → LpCheckbox-B_C8sOoT.js} +1 -1
  15. package/dist/chunks/{LpCodeBlock-ca7ANFyW.js → LpCodeBlock-CXsqJwDy.js} +1 -1
  16. package/dist/chunks/{LpColorPicker-D1sQqtXS.js → LpColorPicker-DQ88niXq.js} +2 -2
  17. package/dist/chunks/{LpConfirmDialog-CrbUZgJE.js → LpConfirmDialog-DxlSdjmk.js} +3 -3
  18. package/dist/chunks/{LpConfirmHost-BgMIV8r9.js → LpConfirmHost-BcrmTNu_.js} +1 -1
  19. package/dist/chunks/{LpContextMenu-EF_90u3g.js → LpContextMenu-B1NGfpp1.js} +2 -2
  20. package/dist/chunks/{LpDatePicker-BrdFtSTy.js → LpDatePicker-B-VMHMgy.js} +3 -3
  21. package/dist/chunks/{LpDateRangePicker-Drd7EbIz.js → LpDateRangePicker-B68KPKPl.js} +6 -15
  22. package/dist/chunks/{LpDisclosure-Cc23-JOT.js → LpDisclosure-Ct-VBHxt.js} +1 -1
  23. package/dist/chunks/{LpDrawer-CBFVD624.js → LpDrawer-DSknigxH.js} +5 -7
  24. package/dist/chunks/{LpDropdownMenu-DrpJJBp0.js → LpDropdownMenu-C7oHVlKY.js} +2 -2
  25. package/dist/chunks/{LpEmptyState-BJbZXC8D.js → LpEmptyState-CBwwkkdA.js} +1 -1
  26. package/dist/chunks/{LpFieldNotice-wtieLUTj.js → LpFieldNotice-9hH4ITZa.js} +1 -1
  27. package/dist/chunks/{LpFileTree-3E4w4Rjr.js → LpFileTree-CMA0_rsB.js} +2 -2
  28. package/dist/chunks/{LpFileTreeNode-B4uB8Ks_.js → LpFileTreeNode-B4T258hF.js} +3 -3
  29. package/dist/chunks/{LpFormField-CtVYj6tO.js → LpFormField-BpQ6WJPe.js} +1 -1
  30. package/dist/chunks/LpHintLayer-B5-6FyEf.js +154 -0
  31. package/dist/chunks/LpHints-BqoZXHZk.js +109 -0
  32. package/dist/chunks/{LpIcon-DYtaid0K.js → LpIcon-B5guGKrW.js} +1 -1
  33. package/dist/chunks/{LpInput-D-YGQbEv.js → LpInput-WvyP02ID.js} +2 -2
  34. package/dist/chunks/{LpLightbox-EVXzAq62.js → LpLightbox-Dq-e36uT.js} +1 -1
  35. package/dist/chunks/{LpLogViewer-BfgtXVKm.js → LpLogViewer-DwiYE32z.js} +2 -2
  36. package/dist/chunks/{LpMapPicker-Cc84LaM3.js → LpMapPicker-BH5csaBt.js} +1 -1
  37. package/dist/chunks/{LpMediaGallery-BnlkN_1f.js → LpMediaGallery-B1kDypJ-.js} +2 -2
  38. package/dist/chunks/{LpModal-JNa3B_Lr.js → LpModal-BxYssnXg.js} +1 -1
  39. package/dist/chunks/{LpModalNav-DXBilXr0.js → LpModalNav-Bn2ws64b.js} +4 -4
  40. package/dist/chunks/{LpNotificationBell-DEL-RoHS.js → LpNotificationBell-DJfs4JjQ.js} +4 -4
  41. package/dist/chunks/{LpNumberField-B77O-z28.js → LpNumberField-CJPjmoDz.js} +1 -1
  42. package/dist/chunks/{LpPagination-BcDlo3r_.js → LpPagination-DehAaJLY.js} +1 -1
  43. package/dist/chunks/{LpPasswordInput-eKzQhbQP.js → LpPasswordInput-Bg5uTZbx.js} +2 -2
  44. package/dist/chunks/{LpPhoneInput-BCbrrhgE.js → LpPhoneInput-DStwcbnQ.js} +2 -2
  45. package/dist/chunks/{LpPie-B7ti5qCH.js → LpPie-DeTkbbOK.js} +3 -3
  46. package/dist/chunks/{LpRangeCalendar-DZuaRV0W.js → LpRangeCalendar-D5sDID9W.js} +1 -1
  47. package/dist/chunks/{LpSection-tKEH0jYf.js → LpSection-B19VPYmr.js} +1 -1
  48. package/dist/chunks/{LpSegmented-C09EEam2.js → LpSegmented-D3Eam8sa.js} +1 -1
  49. package/dist/chunks/{LpSelect-DNIL6MB9.js → LpSelect-DynjWK1A.js} +1 -1
  50. package/dist/chunks/{LpSidebar-C2oolgwy.js → LpSidebar-CshW0fqC.js} +3 -3
  51. package/dist/chunks/{LpSidebarNav-C9dHu-2b.js → LpSidebarNav-C9b3i9G2.js} +2 -2
  52. package/dist/chunks/{LpStat-B-7udBRP.js → LpStat-BS2TEXYK.js} +1 -1
  53. package/dist/chunks/{LpStepper-C7XiVKZj.js → LpStepper-BSglWF45.js} +1 -1
  54. package/dist/chunks/{LpTableFilter-CrW7_Rj4.js → LpTableFilter-CwHXVxJt.js} +2 -2
  55. package/dist/chunks/{LpTableOfContents-BUcAfGPQ.js → LpTableOfContents-DYuqkByy.js} +2 -8
  56. package/dist/chunks/{LpTabs-BMSBB78h.js → LpTabs-BBGRHY38.js} +1 -1
  57. package/dist/chunks/{LpTagsInput-D1SuqbhB.js → LpTagsInput-CHbQoAAA.js} +3 -3
  58. package/dist/chunks/{LpTextarea-uuScWo6-.js → LpTextarea-Bz2IWhTH.js} +1 -1
  59. package/dist/chunks/{LpThemeSwitcher-Fz85BCRS.js → LpThemeSwitcher-B_qhSl7q.js} +2 -2
  60. package/dist/chunks/{LpTimeField-Cqe7fXnh.js → LpTimeField-DvGXgdES.js} +1 -1
  61. package/dist/chunks/{LpToaster-DTtWuKvq.js → LpToaster-Bo9Q4QBP.js} +1 -1
  62. package/dist/chunks/{LpTooltip-CXVKcOXT.js → LpTooltip-suFCM34F.js} +2 -2
  63. package/dist/chunks/LpTour-D7m9ZxNk.js +334 -0
  64. package/dist/chunks/{LpUptimeBar-ugcv25xj.js → LpUptimeBar-B4ltCfno.js} +1 -1
  65. package/dist/chunks/{MenuTitle-J3ZDYjpV.js → MenuTitle-DQP6hc2N.js} +1 -1
  66. package/dist/chunks/TourCard-DCHOjr6e.js +100 -0
  67. package/dist/chunks/icons-QqBscG5I.js +43 -0
  68. package/dist/component-names.d.ts +1 -1
  69. package/dist/components/ContextMenuItems.vue.js +1 -1
  70. package/dist/components/LayoutCanvas.vue.js +1 -1
  71. package/dist/components/LayoutNode.vue.js +1 -1
  72. package/dist/components/LpActionBar.vue.js +1 -1
  73. package/dist/components/LpAddressInput.vue.js +1 -1
  74. package/dist/components/LpAlert.vue.js +1 -1
  75. package/dist/components/LpAppShell.vue.js +1 -1
  76. package/dist/components/LpAutocomplete.vue.js +1 -1
  77. package/dist/components/LpAvatar.vue.js +1 -1
  78. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  79. package/dist/components/LpButton.vue.js +1 -1
  80. package/dist/components/LpCalendar.vue.js +1 -1
  81. package/dist/components/LpCard.vue.js +1 -1
  82. package/dist/components/LpChart.vue.js +2 -2
  83. package/dist/components/LpCheckbox.vue.js +1 -1
  84. package/dist/components/LpCodeBlock.vue.js +1 -1
  85. package/dist/components/LpColorPicker.vue.js +1 -1
  86. package/dist/components/LpCommandPalette.vue.js +2 -2
  87. package/dist/components/LpConfirmDialog.vue.js +1 -1
  88. package/dist/components/LpConfirmHost.vue.js +1 -1
  89. package/dist/components/LpContextMenu.vue.js +1 -1
  90. package/dist/components/LpDatePicker.vue.js +1 -1
  91. package/dist/components/LpDateRangePicker.vue.js +1 -1
  92. package/dist/components/LpDisclosure.vue.js +1 -1
  93. package/dist/components/LpDrawer.vue.js +1 -1
  94. package/dist/components/LpDropdownMenu.vue.js +1 -1
  95. package/dist/components/LpDropzone.vue.js +1 -1
  96. package/dist/components/LpEmptyState.vue.js +1 -1
  97. package/dist/components/LpFieldNotice.vue.js +1 -1
  98. package/dist/components/LpFileTree.vue.js +1 -1
  99. package/dist/components/LpFileTreeNode.vue.js +1 -1
  100. package/dist/components/LpFormField.vue.js +1 -1
  101. package/dist/components/LpHintLayer.vue.d.ts +33 -0
  102. package/dist/components/LpHintLayer.vue.js +4 -0
  103. package/dist/components/LpHints.vue.d.ts +20 -0
  104. package/dist/components/LpHints.vue.js +4 -0
  105. package/dist/components/LpIcon.vue.js +1 -1
  106. package/dist/components/LpImageEditor.vue.js +2 -2
  107. package/dist/components/LpInput.vue.js +1 -1
  108. package/dist/components/LpLightbox.vue.js +1 -1
  109. package/dist/components/LpLogViewer.vue.js +1 -1
  110. package/dist/components/LpMapPicker.vue.js +1 -1
  111. package/dist/components/LpMediaGallery.vue.js +1 -1
  112. package/dist/components/LpModal.vue.js +1 -1
  113. package/dist/components/LpModalNav.vue.js +1 -1
  114. package/dist/components/LpNotificationBell.vue.js +1 -1
  115. package/dist/components/LpNumberField.vue.js +1 -1
  116. package/dist/components/LpPagination.vue.js +1 -1
  117. package/dist/components/LpPaletteFacts.vue.js +1 -1
  118. package/dist/components/LpPaletteHeader.vue.js +2 -2
  119. package/dist/components/LpPasswordInput.vue.js +1 -1
  120. package/dist/components/LpPhoneInput.vue.js +1 -1
  121. package/dist/components/LpPie.vue.js +1 -1
  122. package/dist/components/LpRangeCalendar.vue.js +1 -1
  123. package/dist/components/LpSection.vue.js +1 -1
  124. package/dist/components/LpSegmented.vue.js +1 -1
  125. package/dist/components/LpSelect.vue.js +1 -1
  126. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  127. package/dist/components/LpSidebar.vue.js +1 -1
  128. package/dist/components/LpSidebarNav.vue.js +1 -1
  129. package/dist/components/LpStat.vue.js +1 -1
  130. package/dist/components/LpStepper.vue.js +1 -1
  131. package/dist/components/LpTable.vue.js +6 -6
  132. package/dist/components/LpTableFilter.vue.js +1 -1
  133. package/dist/components/LpTableOfContents.vue.js +1 -1
  134. package/dist/components/LpTabs.vue.js +1 -1
  135. package/dist/components/LpTagsInput.vue.js +1 -1
  136. package/dist/components/LpTextarea.vue.js +1 -1
  137. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  138. package/dist/components/LpTimeField.vue.js +1 -1
  139. package/dist/components/LpToaster.vue.js +1 -1
  140. package/dist/components/LpTooltip.vue.js +1 -1
  141. package/dist/components/LpTour.vue.d.ts +98 -0
  142. package/dist/components/LpTour.vue.js +4 -0
  143. package/dist/components/LpUptimeBar.vue.js +1 -1
  144. package/dist/components/MenuTitle.vue.js +1 -1
  145. package/dist/components/TourCard.vue.d.ts +28 -0
  146. package/dist/components/TourCard.vue.js +4 -0
  147. package/dist/components/tour.d.ts +21 -0
  148. package/dist/components/tour.js +38 -0
  149. package/dist/composables/scroll.d.ts +17 -0
  150. package/dist/composables/scroll.js +29 -0
  151. package/dist/composables/useGamepadNav.js +2 -7
  152. package/dist/composables/useHintSystem.d.ts +195 -0
  153. package/dist/composables/useHintSystem.js +334 -0
  154. package/dist/composables/useHints.d.ts +18 -0
  155. package/dist/composables/useHints.js +120 -0
  156. package/dist/composables/useMediaQuery.d.ts +2 -0
  157. package/dist/composables/useMediaQuery.js +18 -0
  158. package/dist/composables/usePaintSelect.js +1 -7
  159. package/dist/icons-all.js +1 -1
  160. package/dist/index.d.ts +13 -0
  161. package/dist/index.js +89 -64
  162. package/package.json +1 -1
  163. package/src/component-names.ts +3 -0
  164. package/src/components/LpDateRangePicker.vue +3 -12
  165. package/src/components/LpDrawer.vue +4 -7
  166. package/src/components/LpHintLayer.vue +148 -0
  167. package/src/components/LpHints.vue +116 -0
  168. package/src/components/LpTableOfContents.vue +2 -14
  169. package/src/components/LpTooltip.vue +2 -4
  170. package/src/components/LpTour.vue +358 -0
  171. package/src/components/TourCard.vue +41 -0
  172. package/src/components/tour.ts +57 -0
  173. package/src/composables/scroll.ts +54 -0
  174. package/src/composables/useGamepadNav.ts +2 -7
  175. package/src/composables/useHintSystem.ts +512 -0
  176. package/src/composables/useHints.ts +151 -0
  177. package/src/composables/useMediaQuery.ts +20 -0
  178. package/src/composables/usePaintSelect.ts +1 -8
  179. package/src/index.ts +27 -0
  180. package/src/kit-icons.ts +1 -1
  181. package/src/tokens/tokens.css +1 -0
  182. package/dist/chunks/icons-CkA0njzm.js +0 -43
@@ -12,6 +12,7 @@
12
12
  import { Motion } from "motion-v"
13
13
  import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from "vue"
14
14
  import { useLabels } from "../composables/useLpConfig"
15
+ import { scrollParent } from "../composables/scroll"
15
16
  import { usePillTransition } from "../composables/usePillTransition"
16
17
 
17
18
  export interface TocLink {
@@ -113,24 +114,11 @@ function unfreeze() {
113
114
  safetyTimer = null
114
115
  }
115
116
 
116
- /*
117
- * The element that actually scrolls to the heading. Inside an app shell that is
118
- * a scroll region, not the window, and watching window.scrollY there saw no
119
- * movement and unfroze on the first frame.
120
- */
121
- function scrollerOf(el: HTMLElement): HTMLElement | null {
122
- for (let node = el.parentElement; node; node = node.parentElement) {
123
- const { overflowY } = getComputedStyle(node)
124
- if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) return node
125
- }
126
- return null
127
- }
128
-
129
117
  function suppressSpyUntilScrollEnds(target: HTMLElement) {
130
118
  spySuppressed = true
131
119
  if (rafId != null) cancelAnimationFrame(rafId)
132
120
  if (safetyTimer != null) clearTimeout(safetyTimer)
133
- const scroller = scrollerOf(target)
121
+ const scroller = scrollParent(target)
134
122
  const position = () => (scroller ? scroller.scrollTop : window.scrollY)
135
123
  let last = position()
136
124
  const tick = () => {
@@ -42,6 +42,7 @@ import {
42
42
  type SpringState,
43
43
  } from "../composables/usePillTransition"
44
44
  import { PANEL_SURFACE, TOOLTIP_ANIM } from "./dropdown"
45
+ import { prefersReducedMotion } from "../composables/easing"
45
46
 
46
47
  const props = withDefaults(
47
48
  defineProps<{
@@ -116,9 +117,6 @@ const shared = injectTooltipProviderContext(null)
116
117
  */
117
118
  const PassThrough: FunctionalComponent = (_, { slots }) => slots.default?.()
118
119
 
119
- const reducedMotion = () =>
120
- typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
121
-
122
120
  const target = { x: 0, y: 0 }
123
121
  const offset = ref({ x: 0, y: 0 })
124
122
 
@@ -174,7 +172,7 @@ const onPointerMove = (e: PointerEvent) => {
174
172
  const dir = props.side === "bottom" ? -1 : 1
175
173
  target.y = e.clientY - (rect.top + rect.height / 2) + half * dir
176
174
  // Without motion the tip goes straight to the reading instead of trailing.
177
- if (reducedMotion()) {
175
+ if (prefersReducedMotion()) {
178
176
  offset.value = { x: target.x, y: target.y }
179
177
  return
180
178
  }
@@ -0,0 +1,358 @@
1
+ <script lang="ts">
2
+ export interface TourContext {
3
+ /** Undo something when this step is left, in either direction (close the menu it opened). */
4
+ onLeave(cleanup: () => void): void
5
+ /** Undo something when the tour ends, for what several steps share (a dialog they all live in). */
6
+ onEnd(cleanup: () => void): void
7
+ }
8
+
9
+ export interface TourStep {
10
+ /**
11
+ * What to point at: a `v-hint` id, a CSS selector, an element or a getter.
12
+ * Left out, the step is a centred card with nothing highlighted (an intro).
13
+ * A target that never appears is skipped, so a step for a control the user
14
+ * cannot see costs nothing.
15
+ */
16
+ target?: string | Element | (() => Element | null)
17
+ title: string
18
+ text?: string
19
+ /** A shortcut for the same action, shown as keycaps ("Ctrl+K"). */
20
+ keys?: string
21
+ side?: "top" | "right" | "bottom" | "left"
22
+ /**
23
+ * Ask the user to press the highlighted control themselves; the tour moves
24
+ * on when they do. Doing it beats reading about it.
25
+ */
26
+ try?: boolean
27
+ /** With `try`, a "do it for me" button runs this instead. */
28
+ perform?: () => void | Promise<void>
29
+ /** Runs before the step shows: open the dialog or tab its target lives in. */
30
+ enter?: (context: TourContext) => void | Promise<void>
31
+ }
32
+ </script>
33
+
34
+ <script setup lang="ts">
35
+ /*
36
+ * A guided walk over the real interface: the target is lit through a dim
37
+ * scrim, a card beside it says what it is, and the user steps through with the
38
+ * buttons or the arrow keys.
39
+ *
40
+ * The scrim is four panels around a hole rather than a mask, so the target
41
+ * stays clickable (a `try` step waits for exactly that click) and the hole can
42
+ * glide between targets on any engine, WebKitGTK included. The card is a reka
43
+ * popover anchored to the hole, so flipping and sliding to stay on screen is
44
+ * floating-ui's job; on a phone it becomes a sheet at the bottom instead.
45
+ *
46
+ * Whatever a step opens through `enter` it undoes through `onLeave` (gone when
47
+ * the step is) or `onEnd` (shared by the steps after it), and the tour runs
48
+ * those however it ends: finished, closed, Escape, or unmounted by a route
49
+ * change. A tour must leave the page as it found it.
50
+ */
51
+ import { PopoverAnchor, PopoverContent, PopoverPortal, PopoverRoot } from "reka-ui"
52
+ import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue"
53
+ import { prefersReducedMotion } from "../composables/easing"
54
+ import { hints } from "../composables/useHints"
55
+ import { useHotkeys } from "../composables/useHotkeys"
56
+ import { useLabels } from "../composables/useLpConfig"
57
+ import { useMediaQuery } from "../composables/useMediaQuery"
58
+ import { scrollToElement } from "../composables/scroll"
59
+ import { PANEL_SURFACE } from "./dropdown"
60
+ import TourCard from "./TourCard.vue"
61
+ import type { Box } from "../composables/spatialNav"
62
+ import { holeOf, panelsAround } from "./tour"
63
+
64
+ const props = withDefaults(
65
+ defineProps<{
66
+ steps: TourStep[]
67
+ /** Room around the target inside the lit hole, px. */
68
+ padding?: number
69
+ labels?: Partial<typeof DEFAULT_LABELS>
70
+ }>(),
71
+ { padding: 6 },
72
+ )
73
+
74
+ const emit = defineEmits<{
75
+ /** The last step was passed. */
76
+ (e: "finish"): void
77
+ /** Closed early, on this step index. */
78
+ (e: "dismiss", index: number): void
79
+ }>()
80
+
81
+ const open = defineModel<boolean>("open", { default: false })
82
+ const index = defineModel<number>("step", { default: 0 })
83
+
84
+ const DEFAULT_LABELS = {
85
+ next: "Next",
86
+ back: "Back",
87
+ done: "Done",
88
+ close: "Close tour",
89
+ progress: "{n} of {total}",
90
+ perform: "Do it for me",
91
+ tryIt: "Try it: press the highlighted control.",
92
+ }
93
+ const labels = useLabels("tour", DEFAULT_LABELS, () => props.labels)
94
+
95
+ const WAIT_MS = 800
96
+ const TRY_SETTLE_MS = 120
97
+
98
+ const wide = useMediaQuery("(min-width: 640px)")
99
+ const step = computed<TourStep | undefined>(() => props.steps[index.value])
100
+ const target = shallowRef<Element | null>(null)
101
+ const box = ref<Box | null>(null)
102
+ const viewport = ref({ width: 0, height: 0 })
103
+ const ready = ref(false)
104
+ const cleanups: (() => void)[] = []
105
+ const leaving: (() => void)[] = []
106
+ let frame = 0
107
+ let token = 0
108
+ let direction = 1
109
+
110
+ const hole = computed(() => (box.value ? holeOf(box.value, props.padding, viewport.value) : null))
111
+ const panels = computed(() =>
112
+ hole.value
113
+ ? panelsAround(hole.value, viewport.value)
114
+ : [{ x: 0, y: 0, width: viewport.value.width, height: viewport.value.height }],
115
+ )
116
+ const reference = {
117
+ getBoundingClientRect: () => {
118
+ const h = hole.value
119
+ return h ? new DOMRect(h.x, h.y, h.width, h.height) : new DOMRect(viewport.value.width / 2, viewport.value.height / 2, 0, 0)
120
+ },
121
+ }
122
+ const last = computed(() => index.value >= props.steps.length - 1)
123
+ const progress = computed(() => labels.value.progress.replace("{n}", String(index.value + 1)).replace("{total}", String(props.steps.length)))
124
+ const smooth = () => !prefersReducedMotion()
125
+
126
+ function resolve(raw: TourStep["target"]): Element | null {
127
+ if (!raw) return null
128
+ try {
129
+ if (typeof raw === "function") return raw()
130
+ if (typeof raw !== "string") return raw.isConnected ? raw : null
131
+ return hints.element(raw) ?? document.querySelector(raw)
132
+ } catch {
133
+ return null
134
+ }
135
+ }
136
+
137
+ function waitFor(raw: TourStep["target"], mine: number): Promise<Element | null> {
138
+ const started = performance.now()
139
+ return new Promise((done) => {
140
+ const tick = () => {
141
+ if (mine !== token) return done(null)
142
+ const el = resolve(raw)
143
+ if (el && el.getClientRects().length) return done(el)
144
+ if (performance.now() - started > WAIT_MS) return done(null)
145
+ requestAnimationFrame(tick)
146
+ }
147
+ tick()
148
+ })
149
+ }
150
+
151
+ function measure() {
152
+ viewport.value = { width: window.innerWidth, height: window.innerHeight }
153
+ const el = target.value
154
+ if (!el || !el.isConnected) {
155
+ box.value = null
156
+ return
157
+ }
158
+ const r = el.getBoundingClientRect()
159
+ const prev = box.value
160
+ if (!prev || prev.x !== r.x || prev.y !== r.y || prev.width !== r.width || prev.height !== r.height) {
161
+ box.value = { x: r.x, y: r.y, width: r.width, height: r.height }
162
+ }
163
+ }
164
+
165
+ function track() {
166
+ measure()
167
+ frame = requestAnimationFrame(track)
168
+ }
169
+
170
+ async function show(): Promise<void> {
171
+ const mine = ++token
172
+ ready.value = false
173
+ leave()
174
+ const current = step.value
175
+ if (!current) return
176
+ try {
177
+ await current.enter?.({ onLeave: (fn) => leaving.push(fn), onEnd: (fn) => cleanups.push(fn) })
178
+ } catch (error) {
179
+ console.warn("[LpTour] step skipped, its enter() failed:", error)
180
+ if (mine === token) skip()
181
+ return
182
+ }
183
+ if (mine !== token) return
184
+ if (!current.target) {
185
+ target.value = null
186
+ measure()
187
+ ready.value = true
188
+ return
189
+ }
190
+ const el = current.enter ? await waitFor(current.target, mine) : resolve(current.target)
191
+ if (mine !== token) return
192
+ if (!el || !el.getClientRects().length) return skip()
193
+ target.value = el
194
+ scrollToElement(el, { block: wide.value ? "nearest" : "center", behavior: smooth() ? "smooth" : "auto", margin: 48 })
195
+ await nextTick()
196
+ measure()
197
+ ready.value = true
198
+ }
199
+
200
+ function skip(): Promise<void> | void {
201
+ const nextIndex = index.value + direction
202
+ if (nextIndex < 0 || nextIndex >= props.steps.length) return direction > 0 ? finish() : show()
203
+ index.value = nextIndex
204
+ }
205
+
206
+ function next(): void {
207
+ direction = 1
208
+ if (last.value) return finish()
209
+ index.value += 1
210
+ }
211
+
212
+ function back() {
213
+ if (index.value === 0) return
214
+ direction = -1
215
+ index.value -= 1
216
+ }
217
+
218
+ function forward() {
219
+ if (!step.value?.try) next()
220
+ }
221
+
222
+ async function perform() {
223
+ await step.value?.perform?.()
224
+ setTimeout(next, TRY_SETTLE_MS)
225
+ }
226
+
227
+ function runAll(list: (() => void)[]) {
228
+ for (const cleanup of list.splice(0).reverse()) {
229
+ try {
230
+ cleanup()
231
+ } catch (error) {
232
+ console.warn("[LpTour] cleanup failed:", error)
233
+ }
234
+ }
235
+ }
236
+
237
+ function leave() {
238
+ runAll(leaving)
239
+ }
240
+
241
+ function end() {
242
+ token++
243
+ leave()
244
+ cancelAnimationFrame(frame)
245
+ frame = 0
246
+ target.value = null
247
+ box.value = null
248
+ runAll(cleanups)
249
+ }
250
+
251
+ function finish(): void {
252
+ open.value = false
253
+ emit("finish")
254
+ }
255
+
256
+ function dismiss() {
257
+ const at = index.value
258
+ open.value = false
259
+ emit("dismiss", at)
260
+ }
261
+
262
+ function onTargetClick(event: Event) {
263
+ if (!step.value?.try || !target.value) return
264
+ if (event.target instanceof Node && target.value.contains(event.target)) setTimeout(next, TRY_SETTLE_MS)
265
+ }
266
+
267
+ watch(
268
+ open,
269
+ (value) => {
270
+ if (value) {
271
+ direction = 1
272
+ document.addEventListener("click", onTargetClick, true)
273
+ frame ||= requestAnimationFrame(track)
274
+ void show()
275
+ } else {
276
+ document.removeEventListener("click", onTargetClick, true)
277
+ end()
278
+ }
279
+ },
280
+ { immediate: true },
281
+ )
282
+
283
+ watch(index, () => {
284
+ if (open.value) void show()
285
+ })
286
+
287
+ onBeforeUnmount(() => {
288
+ document.removeEventListener("click", onTargetClick, true)
289
+ if (open.value) end()
290
+ })
291
+
292
+ useHotkeys(
293
+ () => [
294
+ { key: "Escape", handler: dismiss, overLayers: true, allowInInput: true },
295
+ { key: "ArrowRight", handler: forward, overLayers: true },
296
+ { key: "Enter", handler: forward, overLayers: true },
297
+ { key: "ArrowLeft", handler: back, overLayers: true },
298
+ ],
299
+ { enabled: open, priority: 100 },
300
+ )
301
+
302
+ const CARD = `${PANEL_SURFACE} rounded-card w-80 max-w-[calc(100vw-24px)] p-4 text-sm text-ink outline-none`
303
+ </script>
304
+
305
+ <template>
306
+ <Teleport to="body">
307
+ <div v-if="open" class="lp-tour pointer-events-none fixed inset-0 z-(--z-tour)" @pointerdown.stop>
308
+ <div
309
+ v-for="(panel, i) in panels"
310
+ :key="i"
311
+ class="lp-scrim pointer-events-auto absolute left-0 top-0 transition-[transform,width,height] duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
312
+ :style="{ transform: `translate(${panel.x}px, ${panel.y}px)`, width: `${panel.width}px`, height: `${panel.height}px` }"
313
+ />
314
+ <div
315
+ v-if="hole"
316
+ class="absolute left-0 top-0 rounded-control ring-2 ring-brand transition-[transform,width,height] duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
317
+ :class="step?.try ? 'pointer-events-none animate-pulse' : 'pointer-events-auto'"
318
+ :style="{ transform: `translate(${hole.x}px, ${hole.y}px)`, width: `${hole.width}px`, height: `${hole.height}px` }"
319
+ />
320
+ </div>
321
+
322
+ <PopoverRoot v-if="open && wide" :open="ready">
323
+ <PopoverAnchor :reference="reference" />
324
+ <PopoverPortal>
325
+ <PopoverContent
326
+ :side="step?.side ?? 'bottom'"
327
+ :side-offset="12"
328
+ :collision-padding="12"
329
+ update-position-strategy="always"
330
+ :trap-focus="false"
331
+ :class="[CARD, 'z-[calc(var(--z-tour)+1)] data-[state=open]:animate-[popover-in_var(--duration-medium)_var(--ease-emphasized)] motion-reduce:animate-none']"
332
+ role="dialog"
333
+ :aria-label="step?.title"
334
+ @pointerdown.stop
335
+ @interact-outside.prevent
336
+ @escape-key-down.prevent
337
+ @focus-outside.prevent
338
+ >
339
+ <slot name="card" :step="step" :index="index" :next="next" :back="back" :dismiss="dismiss">
340
+ <TourCard :step="step" :index="index" :last="last" :progress="progress" :labels="labels" @next="next" @back="back" @perform="perform" @dismiss="dismiss" />
341
+ </slot>
342
+ </PopoverContent>
343
+ </PopoverPortal>
344
+ </PopoverRoot>
345
+
346
+ <div
347
+ v-if="open && !wide && ready"
348
+ :class="[CARD, 'pointer-events-auto fixed inset-x-3 bottom-3 z-[calc(var(--z-tour)+1)] w-auto animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)] motion-reduce:animate-none']"
349
+ role="dialog"
350
+ :aria-label="step?.title"
351
+ @pointerdown.stop
352
+ >
353
+ <slot name="card" :step="step" :index="index" :next="next" :back="back" :dismiss="dismiss">
354
+ <TourCard :step="step" :index="index" :last="last" :progress="progress" :labels="labels" @next="next" @back="back" @perform="perform" @dismiss="dismiss" />
355
+ </slot>
356
+ </div>
357
+ </Teleport>
358
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import type { TourStep } from "./LpTour.vue"
3
+ import LpButton from "./LpButton.vue"
4
+ import LpIcon from "./LpIcon.vue"
5
+ import LpKeys from "./LpKeys.vue"
6
+
7
+ defineProps<{
8
+ step: TourStep | undefined
9
+ index: number
10
+ last: boolean
11
+ progress: string
12
+ labels: { next: string; back: string; done: string; close: string; perform: string; tryIt: string }
13
+ }>()
14
+
15
+ defineEmits<{
16
+ (e: "next"): void
17
+ (e: "back"): void
18
+ (e: "perform"): void
19
+ (e: "dismiss"): void
20
+ }>()
21
+ </script>
22
+
23
+ <template>
24
+ <div class="flex items-start justify-between gap-3">
25
+ <p class="font-medium" aria-live="polite">{{ step?.title }}</p>
26
+ <button type="button" class="text-muted hover:text-ink" :aria-label="labels.close" @click="$emit('dismiss')">
27
+ <LpIcon name="lucide:x" class="size-4" />
28
+ </button>
29
+ </div>
30
+ <p v-if="step?.text" class="mt-1.5 text-muted">{{ step.text }}</p>
31
+ <LpKeys v-if="step?.keys" :combo="step.keys" size="sm" class="mt-2" />
32
+ <p v-if="step?.try" class="mt-2 text-xs text-brand">{{ labels.tryIt }}</p>
33
+ <div class="mt-4 flex items-center justify-between gap-2">
34
+ <span class="text-xs tabular-nums text-muted">{{ progress }}</span>
35
+ <div class="flex gap-2">
36
+ <LpButton v-if="index > 0" size="sm" variant="ghost" @click="$emit('back')">{{ labels.back }}</LpButton>
37
+ <LpButton v-if="step?.try && step.perform" size="sm" variant="outline" @click="$emit('perform')">{{ labels.perform }}</LpButton>
38
+ <LpButton size="sm" :variant="step?.try ? 'ghost' : undefined" @click="$emit('next')">{{ last ? labels.done : labels.next }}</LpButton>
39
+ </div>
40
+ </div>
41
+ </template>
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Geometry for LpTour, kept out of the component so it can be tested without a
3
+ * DOM: where the spotlight hole goes and what the dim panels around it cover.
4
+ * Placing the step card is reka's popper (floating-ui), anchored to the hole.
5
+ */
6
+
7
+ import type { Box } from "../composables/spatialNav"
8
+
9
+ type Size = { width: number; height: number }
10
+
11
+ /** The hole around a target: padded, then clipped to the viewport so a tall
12
+ * target never pushes the dim panels off-screen. */
13
+ export function holeOf(target: Box, padding: number, viewport: Size): Box {
14
+ const x = Math.max(0, target.x - padding)
15
+ const y = Math.max(0, target.y - padding)
16
+ const right = Math.min(viewport.width, target.x + target.width + padding)
17
+ const bottom = Math.min(viewport.height, target.y + target.height + padding)
18
+ return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) }
19
+ }
20
+
21
+ /** The four dim panels around a hole: top, bottom, left, right. */
22
+ export function panelsAround(hole: Box, viewport: Size): Box[] {
23
+ const bottom = hole.y + hole.height
24
+ return [
25
+ { x: 0, y: 0, width: viewport.width, height: hole.y },
26
+ { x: 0, y: bottom, width: viewport.width, height: Math.max(0, viewport.height - bottom) },
27
+ { x: 0, y: hole.y, width: hole.x, height: hole.height },
28
+ { x: hole.x + hole.width, y: hole.y, width: Math.max(0, viewport.width - hole.x - hole.width), height: hole.height },
29
+ ]
30
+ }
31
+
32
+ /**
33
+ * Open the context menu of an element as a right-click would, at its centre.
34
+ * Lets a tour step show a row's menu without the user hunting for it; the menu
35
+ * itself is whatever the app already wires to that element.
36
+ *
37
+ * Returns the way to close it again: a press outside the menu, which every
38
+ * dismissable layer honours. Not Escape — that key also closes whatever is on
39
+ * top, a tour included, and would end the walk instead of the menu.
40
+ */
41
+ export function openContextMenu(el: Element): () => void {
42
+ const box = el.getBoundingClientRect()
43
+ el.dispatchEvent(
44
+ new MouseEvent("contextmenu", {
45
+ bubbles: true,
46
+ cancelable: true,
47
+ button: 2,
48
+ clientX: box.left + box.width / 2,
49
+ clientY: box.top + box.height / 2,
50
+ }),
51
+ )
52
+ return () => {
53
+ const outside = { bubbles: true, cancelable: true, clientX: 0, clientY: 0 }
54
+ document.body.dispatchEvent(new PointerEvent("pointerdown", outside))
55
+ document.body.dispatchEvent(new PointerEvent("pointerup", outside))
56
+ }
57
+ }
@@ -0,0 +1,54 @@
1
+ /*
2
+ * The element that actually scrolls, and scrolling it to show a child. Inside an
3
+ * app shell that is a scroll region, not the window, and `scrollIntoView` moves
4
+ * every scrollable ancestor at once, including `overflow: hidden` wrappers that
5
+ * then sit shifted with no way back for the user.
6
+ */
7
+
8
+ const SCROLLS = /(auto|scroll|overlay)/
9
+
10
+ export interface ScrollParentOptions {
11
+ /** Which overflow counts: vertical (default), horizontal, or either. */
12
+ axis?: "y" | "x" | "both"
13
+ /** Start the search at the element itself rather than its parent. */
14
+ self?: boolean
15
+ }
16
+
17
+ /** The nearest ancestor that scrolls along `axis` and has something to scroll, or null. */
18
+ export function scrollParent(el: Element | null | undefined, { axis = "y", self = false }: ScrollParentOptions = {}): HTMLElement | null {
19
+ for (let node = self ? (el as HTMLElement | null | undefined) : el?.parentElement; node; node = node.parentElement) {
20
+ if (node === document.body || node === document.documentElement) return null
21
+ const style = getComputedStyle(node)
22
+ const y = axis !== "x" && SCROLLS.test(style.overflowY) && node.scrollHeight > node.clientHeight
23
+ const x = axis !== "y" && SCROLLS.test(style.overflowX) && node.scrollWidth > node.clientWidth
24
+ if (y || x) return node
25
+ }
26
+ return null
27
+ }
28
+
29
+ export interface ScrollToElementOptions {
30
+ /** `nearest` scrolls only when the element is out of view. */
31
+ block?: "nearest" | "center" | "start"
32
+ behavior?: ScrollBehavior
33
+ /** Room kept between the element and the scroller's edge. */
34
+ margin?: number
35
+ }
36
+
37
+ /** Scroll the nearest scroller, and only it, so that `el` is in view. */
38
+ export function scrollToElement(
39
+ el: Element | null | undefined,
40
+ { block = "nearest", behavior = "auto", margin = 0 }: ScrollToElementOptions = {},
41
+ ): void {
42
+ if (!el) return
43
+ const box = scrollParent(el) ?? (document.scrollingElement as HTMLElement | null)
44
+ if (!box) return
45
+ const outer = box === document.scrollingElement ? new DOMRect(0, 0, window.innerWidth, window.innerHeight) : box.getBoundingClientRect()
46
+ const inner = el.getBoundingClientRect()
47
+ const top = inner.top - outer.top + box.scrollTop
48
+ let target = box.scrollTop
49
+ if (block === "start") target = top - margin
50
+ else if (block === "center") target = top - (box.clientHeight - inner.height) / 2
51
+ else if (inner.top < outer.top + margin) target = top - margin
52
+ else if (inner.bottom > outer.bottom - margin) target = top - box.clientHeight + inner.height + margin
53
+ if (target !== box.scrollTop) box.scrollTo({ top: target, behavior })
54
+ }
@@ -1,5 +1,6 @@
1
1
  import { onBeforeUnmount, onMounted, ref, type MaybeRefOrGetter, toValue } from "vue"
2
2
  import { focusableIn, nextInDirection, type Direction } from "./spatialNav"
3
+ import { scrollParent } from "./scroll"
3
4
 
4
5
  /*
5
6
  * A game controller driving the interface: the D-pad and the left stick move
@@ -124,13 +125,7 @@ export function useGamepadNav(options: UseGamepadNavOptions = {}) {
124
125
  }
125
126
 
126
127
  function scrollBy(dx: number, dy: number) {
127
- let element = document.activeElement as HTMLElement | null
128
- while (element && element !== document.body) {
129
- const style = getComputedStyle(element)
130
- if (/(auto|scroll)/.test(style.overflowY + style.overflowX) && element.scrollHeight > element.clientHeight) break
131
- element = element.parentElement
132
- }
133
- ;(element && element !== document.body ? element : document.scrollingElement)?.scrollBy(dx, dy)
128
+ ;(scrollParent(document.activeElement, { axis: "both", self: true }) ?? document.scrollingElement)?.scrollBy(dx, dy)
134
129
  }
135
130
 
136
131
  function poll(now: number) {