@leavepulse/ui 0.55.0 → 0.57.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 (73) hide show
  1. package/dist/chunks/{LpActionBar-CnfFVIwC.js → LpActionBar-C3hkVsFz.js} +1 -1
  2. package/dist/chunks/{LpAddressInput-iOJhmRzy.js → LpAddressInput-NUHmplCd.js} +1 -1
  3. package/dist/chunks/{LpAppShell-DcSXik4a.js → LpAppShell-Bl1Ch68N.js} +2 -2
  4. package/dist/chunks/{LpButton-CKSwC6Mu.js → LpButton-BdfZFNqi.js} +31 -7
  5. package/dist/chunks/{LpConfirmDialog-Ca-CTlxT.js → LpConfirmDialog-CrbUZgJE.js} +1 -1
  6. package/dist/chunks/{LpConfirmHost-BAaSaT2b.js → LpConfirmHost-BgMIV8r9.js} +1 -1
  7. package/dist/chunks/{LpDateRangePicker-Drd7EbIz.js → LpDateRangePicker-CxV5jW7p.js} +4 -13
  8. package/dist/chunks/{LpDrawer-CBFVD624.js → LpDrawer-D9lLOC0C.js} +4 -6
  9. package/dist/chunks/LpHintLayer-CI1oZ9Pd.js +154 -0
  10. package/dist/chunks/{LpModalNav-aWwc_5CD.js → LpModalNav-DXyAyhQl.js} +2 -2
  11. package/dist/chunks/{LpNotificationBell-wZ3meeim.js → LpNotificationBell-DEL-RoHS.js} +1 -1
  12. package/dist/chunks/{LpPie-B7ti5qCH.js → LpPie-CXka8Jvx.js} +1 -1
  13. package/dist/chunks/{LpSidebar-C2oolgwy.js → LpSidebar-hZ8D1cVr.js} +1 -1
  14. package/dist/chunks/{LpTableOfContents-BUcAfGPQ.js → LpTableOfContents-DYuqkByy.js} +2 -8
  15. package/dist/chunks/{LpTooltip-CXVKcOXT.js → LpTooltip-suFCM34F.js} +2 -2
  16. package/dist/chunks/LpTour-C1TC2GyY.js +315 -0
  17. package/dist/chunks/{LpUptimeBar-ugcv25xj.js → LpUptimeBar-B4ltCfno.js} +1 -1
  18. package/dist/chunks/TourCard-BQS3ztqj.js +100 -0
  19. package/dist/component-names.d.ts +1 -1
  20. package/dist/components/LpActionBar.vue.js +1 -1
  21. package/dist/components/LpAddressInput.vue.js +1 -1
  22. package/dist/components/LpAppShell.vue.js +1 -1
  23. package/dist/components/LpButton.vue.d.ts +10 -0
  24. package/dist/components/LpButton.vue.js +1 -1
  25. package/dist/components/LpConfirmDialog.vue.js +1 -1
  26. package/dist/components/LpConfirmHost.vue.js +1 -1
  27. package/dist/components/LpDateRangePicker.vue.js +1 -1
  28. package/dist/components/LpDrawer.vue.js +1 -1
  29. package/dist/components/LpHintLayer.vue.d.ts +33 -0
  30. package/dist/components/LpHintLayer.vue.js +4 -0
  31. package/dist/components/LpImageEditor.vue.js +1 -1
  32. package/dist/components/LpModalNav.vue.js +1 -1
  33. package/dist/components/LpNotificationBell.vue.js +1 -1
  34. package/dist/components/LpPie.vue.js +1 -1
  35. package/dist/components/LpSidebar.vue.js +1 -1
  36. package/dist/components/LpTable.vue.js +1 -1
  37. package/dist/components/LpTableOfContents.vue.js +1 -1
  38. package/dist/components/LpTooltip.vue.js +1 -1
  39. package/dist/components/LpTour.vue.d.ts +98 -0
  40. package/dist/components/LpTour.vue.js +4 -0
  41. package/dist/components/LpUptimeBar.vue.js +1 -1
  42. package/dist/components/TourCard.vue.d.ts +28 -0
  43. package/dist/components/TourCard.vue.js +4 -0
  44. package/dist/components/tour.d.ts +21 -0
  45. package/dist/components/tour.js +38 -0
  46. package/dist/composables/scroll.d.ts +17 -0
  47. package/dist/composables/scroll.js +29 -0
  48. package/dist/composables/useGamepadNav.js +2 -7
  49. package/dist/composables/useHints.d.ts +18 -0
  50. package/dist/composables/useHints.js +120 -0
  51. package/dist/composables/useMediaQuery.d.ts +2 -0
  52. package/dist/composables/useMediaQuery.js +18 -0
  53. package/dist/composables/usePaintSelect.js +1 -7
  54. package/dist/index.d.ts +10 -0
  55. package/dist/index.js +38 -22
  56. package/package.json +1 -1
  57. package/src/component-names.ts +2 -0
  58. package/src/components/LpButton.vue +28 -1
  59. package/src/components/LpDateRangePicker.vue +3 -12
  60. package/src/components/LpDrawer.vue +4 -7
  61. package/src/components/LpHintLayer.vue +148 -0
  62. package/src/components/LpTableOfContents.vue +2 -14
  63. package/src/components/LpTooltip.vue +2 -4
  64. package/src/components/LpTour.vue +338 -0
  65. package/src/components/TourCard.vue +41 -0
  66. package/src/components/tour.ts +57 -0
  67. package/src/composables/scroll.ts +54 -0
  68. package/src/composables/useGamepadNav.ts +2 -7
  69. package/src/composables/useHints.ts +151 -0
  70. package/src/composables/useMediaQuery.ts +20 -0
  71. package/src/composables/usePaintSelect.ts +1 -8
  72. package/src/index.ts +10 -0
  73. package/src/tokens/tokens.css +1 -0
@@ -0,0 +1,338 @@
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
+ if (typeof raw === "function") return raw()
129
+ if (typeof raw !== "string") return raw.isConnected ? raw : null
130
+ return hints.element(raw) ?? document.querySelector(raw)
131
+ }
132
+
133
+ function waitFor(raw: TourStep["target"], mine: number): Promise<Element | null> {
134
+ const started = performance.now()
135
+ return new Promise((done) => {
136
+ const tick = () => {
137
+ if (mine !== token) return done(null)
138
+ const el = resolve(raw)
139
+ if (el && el.getClientRects().length) return done(el)
140
+ if (performance.now() - started > WAIT_MS) return done(null)
141
+ requestAnimationFrame(tick)
142
+ }
143
+ tick()
144
+ })
145
+ }
146
+
147
+ function measure() {
148
+ viewport.value = { width: window.innerWidth, height: window.innerHeight }
149
+ const el = target.value
150
+ if (!el || !el.isConnected) {
151
+ box.value = null
152
+ return
153
+ }
154
+ const r = el.getBoundingClientRect()
155
+ const prev = box.value
156
+ if (!prev || prev.x !== r.x || prev.y !== r.y || prev.width !== r.width || prev.height !== r.height) {
157
+ box.value = { x: r.x, y: r.y, width: r.width, height: r.height }
158
+ }
159
+ }
160
+
161
+ function track() {
162
+ measure()
163
+ frame = requestAnimationFrame(track)
164
+ }
165
+
166
+ async function show(): Promise<void> {
167
+ const mine = ++token
168
+ ready.value = false
169
+ leave()
170
+ const current = step.value
171
+ if (!current) return
172
+ await current.enter?.({ onLeave: (fn) => leaving.push(fn), onEnd: (fn) => cleanups.push(fn) })
173
+ if (mine !== token) return
174
+ if (!current.target) {
175
+ target.value = null
176
+ measure()
177
+ ready.value = true
178
+ return
179
+ }
180
+ const el = current.enter ? await waitFor(current.target, mine) : resolve(current.target)
181
+ if (mine !== token) return
182
+ if (!el || !el.getClientRects().length) return skip()
183
+ target.value = el
184
+ scrollToElement(el, { block: wide.value ? "nearest" : "center", behavior: smooth() ? "smooth" : "auto", margin: 48 })
185
+ await nextTick()
186
+ measure()
187
+ ready.value = true
188
+ }
189
+
190
+ function skip(): Promise<void> | void {
191
+ const nextIndex = index.value + direction
192
+ if (nextIndex < 0 || nextIndex >= props.steps.length) return direction > 0 ? finish() : show()
193
+ index.value = nextIndex
194
+ }
195
+
196
+ function next(): void {
197
+ direction = 1
198
+ if (last.value) return finish()
199
+ index.value += 1
200
+ }
201
+
202
+ function back() {
203
+ if (index.value === 0) return
204
+ direction = -1
205
+ index.value -= 1
206
+ }
207
+
208
+ function forward() {
209
+ if (!step.value?.try) next()
210
+ }
211
+
212
+ async function perform() {
213
+ await step.value?.perform?.()
214
+ setTimeout(next, TRY_SETTLE_MS)
215
+ }
216
+
217
+ function leave() {
218
+ for (const cleanup of leaving.splice(0).reverse()) cleanup()
219
+ }
220
+
221
+ function end() {
222
+ token++
223
+ leave()
224
+ cancelAnimationFrame(frame)
225
+ frame = 0
226
+ target.value = null
227
+ box.value = null
228
+ for (const cleanup of cleanups.splice(0).reverse()) cleanup()
229
+ }
230
+
231
+ function finish(): void {
232
+ open.value = false
233
+ emit("finish")
234
+ }
235
+
236
+ function dismiss() {
237
+ const at = index.value
238
+ open.value = false
239
+ emit("dismiss", at)
240
+ }
241
+
242
+ function onTargetClick(event: Event) {
243
+ if (!step.value?.try || !target.value) return
244
+ if (event.target instanceof Node && target.value.contains(event.target)) setTimeout(next, TRY_SETTLE_MS)
245
+ }
246
+
247
+ watch(
248
+ open,
249
+ (value) => {
250
+ if (value) {
251
+ direction = 1
252
+ document.addEventListener("click", onTargetClick, true)
253
+ frame ||= requestAnimationFrame(track)
254
+ void show()
255
+ } else {
256
+ document.removeEventListener("click", onTargetClick, true)
257
+ end()
258
+ }
259
+ },
260
+ { immediate: true },
261
+ )
262
+
263
+ watch(index, () => {
264
+ if (open.value) void show()
265
+ })
266
+
267
+ onBeforeUnmount(() => {
268
+ document.removeEventListener("click", onTargetClick, true)
269
+ if (open.value) end()
270
+ })
271
+
272
+ useHotkeys(
273
+ () => [
274
+ { key: "Escape", handler: dismiss, overLayers: true, allowInInput: true },
275
+ { key: "ArrowRight", handler: forward, overLayers: true },
276
+ { key: "Enter", handler: forward, overLayers: true },
277
+ { key: "ArrowLeft", handler: back, overLayers: true },
278
+ ],
279
+ { enabled: open, priority: 100 },
280
+ )
281
+
282
+ const CARD = `${PANEL_SURFACE} rounded-card w-80 max-w-[calc(100vw-24px)] p-4 text-sm text-ink outline-none`
283
+ </script>
284
+
285
+ <template>
286
+ <Teleport to="body">
287
+ <div v-if="open" class="lp-tour pointer-events-none fixed inset-0 z-(--z-tour)" @pointerdown.stop>
288
+ <div
289
+ v-for="(panel, i) in panels"
290
+ :key="i"
291
+ 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"
292
+ :style="{ transform: `translate(${panel.x}px, ${panel.y}px)`, width: `${panel.width}px`, height: `${panel.height}px` }"
293
+ />
294
+ <div
295
+ v-if="hole"
296
+ 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"
297
+ :class="step?.try ? 'pointer-events-none animate-pulse' : 'pointer-events-auto'"
298
+ :style="{ transform: `translate(${hole.x}px, ${hole.y}px)`, width: `${hole.width}px`, height: `${hole.height}px` }"
299
+ />
300
+ </div>
301
+
302
+ <PopoverRoot v-if="open && wide" :open="ready">
303
+ <PopoverAnchor :reference="reference" />
304
+ <PopoverPortal>
305
+ <PopoverContent
306
+ :side="step?.side ?? 'bottom'"
307
+ :side-offset="12"
308
+ :collision-padding="12"
309
+ update-position-strategy="always"
310
+ :trap-focus="false"
311
+ :class="[CARD, 'z-[calc(var(--z-tour)+1)] data-[state=open]:animate-[popover-in_var(--duration-medium)_var(--ease-emphasized)] motion-reduce:animate-none']"
312
+ role="dialog"
313
+ :aria-label="step?.title"
314
+ @pointerdown.stop
315
+ @interact-outside.prevent
316
+ @escape-key-down.prevent
317
+ @focus-outside.prevent
318
+ >
319
+ <slot name="card" :step="step" :index="index" :next="next" :back="back" :dismiss="dismiss">
320
+ <TourCard :step="step" :index="index" :last="last" :progress="progress" :labels="labels" @next="next" @back="back" @perform="perform" @dismiss="dismiss" />
321
+ </slot>
322
+ </PopoverContent>
323
+ </PopoverPortal>
324
+ </PopoverRoot>
325
+
326
+ <div
327
+ v-if="open && !wide && ready"
328
+ :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']"
329
+ role="dialog"
330
+ :aria-label="step?.title"
331
+ @pointerdown.stop
332
+ >
333
+ <slot name="card" :step="step" :index="index" :next="next" :back="back" :dismiss="dismiss">
334
+ <TourCard :step="step" :index="index" :last="last" :progress="progress" :labels="labels" @next="next" @back="back" @perform="perform" @dismiss="dismiss" />
335
+ </slot>
336
+ </div>
337
+ </Teleport>
338
+ </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) {
@@ -0,0 +1,151 @@
1
+ import { shallowReactive, type Directive } from "vue"
2
+
3
+ /*
4
+ * Which parts of the interface the user can see right now, and which ones they
5
+ * actually use — the ground truth a tour, a "new" badge or a nudge needs.
6
+ *
7
+ * The element carries its own id (`v-hint="'branches.remind'"`), so a tour is
8
+ * built from what is on screen, in whatever order the app wants: a button the
9
+ * user has no right to is not rendered, therefore not hinted, and nobody keeps
10
+ * a second list of steps that drifts away from the page.
11
+ *
12
+ * Three signals, all per id:
13
+ * mounted — the element is in the DOM (reactive, for "is it here at all");
14
+ * seen — it stayed at least half on screen for SEEN_MS, once per mount,
15
+ * so scrolling past a row of buttons does not count as seeing them;
16
+ * used — the user clicked it. Non-click use (a shortcut, a saved view) is
17
+ * reported with `hints.use(id)`.
18
+ *
19
+ * The kit decides nothing beyond that. Whether to badge, explain or nudge is
20
+ * the app's call, made from these events.
21
+ */
22
+
23
+ export type HintEvent = "seen" | "used" | "hover"
24
+ type Listener = (id: string, el: HTMLElement) => void
25
+
26
+ const SEEN_MS = 800
27
+ const SEEN_RATIO = 0.5
28
+
29
+ const mounted = shallowReactive(new Map<string, Set<HTMLElement>>())
30
+ const listeners: Record<HintEvent, Set<Listener>> = { seen: new Set(), used: new Set(), hover: new Set() }
31
+ const idOf = new WeakMap<HTMLElement, string>()
32
+ const timers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>()
33
+ const seenOnce = new WeakSet<HTMLElement>()
34
+
35
+ function emit(event: HintEvent, id: string, el: HTMLElement) {
36
+ for (const listener of listeners[event]) listener(id, el)
37
+ }
38
+
39
+ let observer: IntersectionObserver | null = null
40
+
41
+ function watchSight(el: HTMLElement) {
42
+ if (typeof IntersectionObserver === "undefined") {
43
+ seenOnce.add(el)
44
+ emit("seen", idOf.get(el)!, el)
45
+ return
46
+ }
47
+ observer ??= new IntersectionObserver(
48
+ (entries) => {
49
+ for (const entry of entries) {
50
+ const target = entry.target as HTMLElement
51
+ const id = idOf.get(target)
52
+ if (!id || seenOnce.has(target)) continue
53
+ clearTimeout(timers.get(target))
54
+ if (entry.intersectionRatio >= SEEN_RATIO) {
55
+ timers.set(
56
+ target,
57
+ setTimeout(() => {
58
+ seenOnce.add(target)
59
+ observer?.unobserve(target)
60
+ emit("seen", id, target)
61
+ }, SEEN_MS),
62
+ )
63
+ }
64
+ }
65
+ },
66
+ { threshold: [0, SEEN_RATIO] },
67
+ )
68
+ observer.observe(el)
69
+ }
70
+
71
+ function onClick(event: Event) {
72
+ const el = event.currentTarget as HTMLElement
73
+ const id = idOf.get(el)
74
+ if (id) emit("used", id, el)
75
+ }
76
+
77
+ function onHover(event: Event) {
78
+ const el = event.currentTarget as HTMLElement
79
+ const id = idOf.get(el)
80
+ if (id) emit("hover", id, el)
81
+ }
82
+
83
+ function attach(el: HTMLElement, id: string) {
84
+ idOf.set(el, id)
85
+ el.dataset.hint = id
86
+ const set = mounted.get(id) ?? new Set<HTMLElement>()
87
+ set.add(el)
88
+ mounted.set(id, new Set(set))
89
+ el.addEventListener("click", onClick, { capture: true })
90
+ el.addEventListener("pointerenter", onHover)
91
+ el.addEventListener("focusin", onHover)
92
+ watchSight(el)
93
+ }
94
+
95
+ function detach(el: HTMLElement) {
96
+ const id = idOf.get(el)
97
+ if (!id) return
98
+ clearTimeout(timers.get(el))
99
+ observer?.unobserve(el)
100
+ el.removeEventListener("click", onClick, { capture: true })
101
+ el.removeEventListener("pointerenter", onHover)
102
+ el.removeEventListener("focusin", onHover)
103
+ const set = new Set(mounted.get(id))
104
+ set.delete(el)
105
+ if (set.size) mounted.set(id, set)
106
+ else mounted.delete(id)
107
+ idOf.delete(el)
108
+ }
109
+
110
+ /** `v-hint="'page.feature'"` on the element that represents a feature. */
111
+ export const vHint: Directive<HTMLElement, string | null | undefined> = {
112
+ mounted(el, binding) {
113
+ if (binding.value) attach(el, binding.value)
114
+ },
115
+ updated(el, binding) {
116
+ if (binding.value === idOf.get(el)) return
117
+ detach(el)
118
+ if (binding.value) attach(el, binding.value)
119
+ },
120
+ beforeUnmount(el) {
121
+ detach(el)
122
+ },
123
+ }
124
+
125
+ export const hints = {
126
+ /** Ids of everything hinted that is in the DOM now. Reactive. */
127
+ ids(): string[] {
128
+ return [...mounted.keys()]
129
+ },
130
+ /** Whether an element with this id is in the DOM. Reactive. */
131
+ present(id: string): boolean {
132
+ return mounted.has(id)
133
+ },
134
+ /** The first connected element with this id that has a box, or null. */
135
+ element(id: string): HTMLElement | null {
136
+ for (const el of mounted.get(id) ?? []) {
137
+ if (el.isConnected && el.getClientRects().length) return el
138
+ }
139
+ return null
140
+ },
141
+ /** Subscribe to a signal; returns the unsubscribe. */
142
+ on(event: HintEvent, listener: Listener): () => void {
143
+ listeners[event].add(listener)
144
+ return () => listeners[event].delete(listener)
145
+ },
146
+ /** Report use that is not a click on the element. */
147
+ use(id: string): void {
148
+ const el = hints.element(id)
149
+ for (const listener of listeners.used) listener(id, el ?? document.body)
150
+ },
151
+ }
@@ -0,0 +1,20 @@
1
+ import { onBeforeUnmount, onMounted, ref, type Ref } from "vue"
2
+
3
+ /*
4
+ * A media query as a ref. False until mounted, so the server and the hydrating
5
+ * client render the same thing and the client switches right after.
6
+ */
7
+ export function useMediaQuery(query: string): Ref<boolean> {
8
+ const matches = ref(false)
9
+ let list: MediaQueryList | undefined
10
+ const update = () => {
11
+ matches.value = !!list?.matches
12
+ }
13
+ onMounted(() => {
14
+ list = window.matchMedia?.(query)
15
+ update()
16
+ list?.addEventListener("change", update)
17
+ })
18
+ onBeforeUnmount(() => list?.removeEventListener("change", update))
19
+ return matches
20
+ }