@leavepulse/ui 0.56.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.
- package/dist/chunks/{LpAppShell-BEGjSqFl.js → LpAppShell-Bl1Ch68N.js} +1 -1
- package/dist/chunks/{LpDateRangePicker-Drd7EbIz.js → LpDateRangePicker-CxV5jW7p.js} +4 -13
- package/dist/chunks/{LpDrawer-CBFVD624.js → LpDrawer-D9lLOC0C.js} +4 -6
- package/dist/chunks/LpHintLayer-CI1oZ9Pd.js +154 -0
- package/dist/chunks/{LpModalNav-DXBilXr0.js → LpModalNav-DXyAyhQl.js} +1 -1
- package/dist/chunks/{LpPie-B7ti5qCH.js → LpPie-CXka8Jvx.js} +1 -1
- package/dist/chunks/{LpSidebar-C2oolgwy.js → LpSidebar-hZ8D1cVr.js} +1 -1
- package/dist/chunks/{LpTableOfContents-BUcAfGPQ.js → LpTableOfContents-DYuqkByy.js} +2 -8
- package/dist/chunks/{LpTooltip-CXVKcOXT.js → LpTooltip-suFCM34F.js} +2 -2
- package/dist/chunks/LpTour-C1TC2GyY.js +315 -0
- package/dist/chunks/{LpUptimeBar-ugcv25xj.js → LpUptimeBar-B4ltCfno.js} +1 -1
- package/dist/chunks/TourCard-BQS3ztqj.js +100 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpDateRangePicker.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpHintLayer.vue.d.ts +33 -0
- package/dist/components/LpHintLayer.vue.js +4 -0
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpTableOfContents.vue.js +1 -1
- package/dist/components/LpTooltip.vue.js +1 -1
- package/dist/components/LpTour.vue.d.ts +98 -0
- package/dist/components/LpTour.vue.js +4 -0
- package/dist/components/LpUptimeBar.vue.js +1 -1
- package/dist/components/TourCard.vue.d.ts +28 -0
- package/dist/components/TourCard.vue.js +4 -0
- package/dist/components/tour.d.ts +21 -0
- package/dist/components/tour.js +38 -0
- package/dist/composables/scroll.d.ts +17 -0
- package/dist/composables/scroll.js +29 -0
- package/dist/composables/useGamepadNav.js +2 -7
- package/dist/composables/useHints.d.ts +18 -0
- package/dist/composables/useHints.js +120 -0
- package/dist/composables/useMediaQuery.d.ts +2 -0
- package/dist/composables/useMediaQuery.js +18 -0
- package/dist/composables/usePaintSelect.js +1 -7
- package/dist/index.d.ts +10 -0
- package/dist/index.js +32 -16
- package/package.json +1 -1
- package/src/component-names.ts +2 -0
- package/src/components/LpDateRangePicker.vue +3 -12
- package/src/components/LpDrawer.vue +4 -7
- package/src/components/LpHintLayer.vue +148 -0
- package/src/components/LpTableOfContents.vue +2 -14
- package/src/components/LpTooltip.vue +2 -4
- package/src/components/LpTour.vue +338 -0
- package/src/components/TourCard.vue +41 -0
- package/src/components/tour.ts +57 -0
- package/src/composables/scroll.ts +54 -0
- package/src/composables/useGamepadNav.ts +2 -7
- package/src/composables/useHints.ts +151 -0
- package/src/composables/useMediaQuery.ts +20 -0
- package/src/composables/usePaintSelect.ts +1 -8
- package/src/index.ts +10 -0
- 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
|
-
|
|
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
|
+
}
|