@morscherlab/mint-sdk 1.4.0-beta.14 → 1.4.0-beta.16
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/{AppSidebar-Bi8g9mRt.js → AppSidebar-CB0-RQ4m.js} +876 -114
- package/dist/AppSidebar-CB0-RQ4m.js.map +1 -0
- package/dist/{SettingsModal-jra_2u0o.js → SettingsModal-CoZK3EDN.js} +2 -2
- package/dist/{SettingsModal-jra_2u0o.js.map → SettingsModal-CoZK3EDN.js.map} +1 -1
- package/dist/__tests__/components/AppTopBarOnboarding.test.d.ts +1 -0
- package/dist/__tests__/composables/useOnboarding.test.d.ts +1 -0
- package/dist/components/AppTopBar.vue.d.ts +4 -4
- package/dist/components/internal/AppTopBarOnboardingInternal.vue.d.ts +7 -0
- package/dist/components/internal/OnboardingLayerInternal.vue.d.ts +7 -0
- package/dist/composables/useOnboarding.d.ts +119 -0
- package/dist/index.js +5 -5
- package/dist/{install-Bx_6KwJA.js → install-BKgb5qZw.js} +5 -5
- package/dist/{install-Bx_6KwJA.js.map → install-BKgb5qZw.js.map} +1 -1
- package/dist/install.js +1 -1
- package/dist/{runtime-BJvGms6h.js → runtime-Cz1GJa_O.js} +2 -2
- package/dist/{runtime-BJvGms6h.js.map → runtime-Cz1GJa_O.js.map} +1 -1
- package/dist/runtime.js +2 -2
- package/dist/{shell-Czyrzk7l.js → shell-kq9p4VsX.js} +7 -2
- package/dist/{shell-Czyrzk7l.js.map → shell-kq9p4VsX.js.map} +1 -1
- package/dist/shell.d.ts +1 -0
- package/dist/shell.js +3 -3
- package/dist/styles.css +369 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppTopBarOnboarding.test.ts +160 -0
- package/src/__tests__/composables/useOnboarding.test.ts +317 -0
- package/src/components/AppTopBar.onboarding.story.vue +69 -0
- package/src/components/AppTopBar.vue +11 -2
- package/src/components/internal/AppTopBarOnboardingInternal.vue +172 -0
- package/src/components/internal/OnboardingLayerInternal.vue +173 -0
- package/src/composables/useOnboarding.ts +465 -0
- package/src/shell.ts +17 -0
- package/src/styles/components/onboarding.css +368 -0
- package/src/styles/index.css +1 -0
- package/dist/AppSidebar-Bi8g9mRt.js.map +0 -1
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
inject,
|
|
4
|
+
onScopeDispose,
|
|
5
|
+
provide,
|
|
6
|
+
ref,
|
|
7
|
+
shallowRef,
|
|
8
|
+
toValue,
|
|
9
|
+
watch,
|
|
10
|
+
type ComputedRef,
|
|
11
|
+
type InjectionKey,
|
|
12
|
+
type MaybeRefOrGetter,
|
|
13
|
+
type ShallowRef,
|
|
14
|
+
} from 'vue'
|
|
15
|
+
import type { Placement } from '@floating-ui/dom'
|
|
16
|
+
import { usePlatform } from './usePlatform'
|
|
17
|
+
|
|
18
|
+
/** One card of a tour. It points at the element with `data-onboarding="<anchor>"`. */
|
|
19
|
+
export interface OnboardingStep {
|
|
20
|
+
/** Stable id. A returning user sees only steps whose id is new. */
|
|
21
|
+
id: string
|
|
22
|
+
/** `data-onboarding` value of the element the step points at. */
|
|
23
|
+
anchor: string
|
|
24
|
+
title: string
|
|
25
|
+
body: string
|
|
26
|
+
/** Side of the anchor for the card. Default `bottom`. */
|
|
27
|
+
placement?: Placement
|
|
28
|
+
/** `'click'`: the highlighted element takes clicks, and a click moves the tour to the next step. */
|
|
29
|
+
advanceOn?: 'click'
|
|
30
|
+
/** Leave the step out while this returns false, e.g. without the permission its control needs. */
|
|
31
|
+
available?: () => boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OnboardingTour {
|
|
35
|
+
id: string
|
|
36
|
+
/** Name in the Help menu. */
|
|
37
|
+
title: string
|
|
38
|
+
/** Start only while the app's route is this value. Omit it for elements on every page, such as the top bar. */
|
|
39
|
+
route?: string
|
|
40
|
+
steps: OnboardingStep[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OnboardingTask {
|
|
44
|
+
id: string
|
|
45
|
+
title: string
|
|
46
|
+
/** Leave the task out while this returns false, e.g. without the permission it needs. */
|
|
47
|
+
available?: () => boolean
|
|
48
|
+
/** The task is done when this returns true. The coordinator checks it at start, on a route change and when the Help menu opens. */
|
|
49
|
+
done?: () => boolean
|
|
50
|
+
/** The link at the end of the task row. */
|
|
51
|
+
action?: { label: string; run: () => void }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A new feature. A dot marks its element until the user opens it. */
|
|
55
|
+
export interface OnboardingNews {
|
|
56
|
+
id: string
|
|
57
|
+
anchor: string
|
|
58
|
+
title: string
|
|
59
|
+
body: string
|
|
60
|
+
placement?: Placement
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface OnboardingDefinition {
|
|
64
|
+
tours?: OnboardingTour[]
|
|
65
|
+
/** The "Getting started" checklist. */
|
|
66
|
+
checklist?: OnboardingTask[]
|
|
67
|
+
whatsNew?: OnboardingNews[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ProvideOnboardingOptions {
|
|
71
|
+
/** Storage namespace, e.g. `platform` or the plugin name. */
|
|
72
|
+
appId: string
|
|
73
|
+
/** The signed-in user. `null` turns onboarding off (no user yet). Default: the platform user; `local` standalone or with auth off. */
|
|
74
|
+
userId?: MaybeRefOrGetter<string | number | null | undefined>
|
|
75
|
+
/** The current route, matched against `OnboardingTour.route`. */
|
|
76
|
+
route?: MaybeRefOrGetter<string | null | undefined>
|
|
77
|
+
/** Start unseen tours on their own. Default `true`. */
|
|
78
|
+
autoStart?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The step on screen and the element it points at. */
|
|
82
|
+
export interface OnboardingActiveStep {
|
|
83
|
+
step: OnboardingStep
|
|
84
|
+
element: HTMLElement
|
|
85
|
+
/** Position among the steps of this run, and their count. */
|
|
86
|
+
index: number
|
|
87
|
+
count: number
|
|
88
|
+
/** A what's-new card, not a tour step. */
|
|
89
|
+
news: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface OnboardingState {
|
|
93
|
+
definition: OnboardingDefinition
|
|
94
|
+
/** False until a user is known. */
|
|
95
|
+
enabled: ComputedRef<boolean>
|
|
96
|
+
active: ShallowRef<OnboardingActiveStep | null>
|
|
97
|
+
/** Tours that can run on the current route. */
|
|
98
|
+
tours: ComputedRef<OnboardingTour[]>
|
|
99
|
+
/** The available tasks. */
|
|
100
|
+
tasks: ComputedRef<Array<Omit<OnboardingTask, 'done'> & { done: boolean }>>
|
|
101
|
+
tasksDone: ComputedRef<number>
|
|
102
|
+
/** The checklist chip shows while a task is open and the user did not hide it. */
|
|
103
|
+
checklistVisible: ComputedRef<boolean>
|
|
104
|
+
checklistHidden: ComputedRef<boolean>
|
|
105
|
+
news: ComputedRef<Array<OnboardingNews & { unseen: boolean }>>
|
|
106
|
+
unseenNews: ComputedRef<number>
|
|
107
|
+
startTour: (id: string) => void
|
|
108
|
+
showNews: (id: string) => void
|
|
109
|
+
next: () => void
|
|
110
|
+
back: () => void
|
|
111
|
+
/** End the run (× or Esc). Every step of the tour counts as seen. */
|
|
112
|
+
dismiss: () => void
|
|
113
|
+
complete: (taskId: string) => void
|
|
114
|
+
setChecklistHidden: (hidden: boolean) => void
|
|
115
|
+
/** Run the task predicates again. */
|
|
116
|
+
refresh: () => void
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const ONBOARDING_KEY: InjectionKey<OnboardingState> = Symbol('mint-onboarding')
|
|
120
|
+
|
|
121
|
+
/** Wait this long for the missing anchor of a run's first step, or of the step after a click step; then skip it. */
|
|
122
|
+
export const ANCHOR_WAIT_MS = 2000
|
|
123
|
+
|
|
124
|
+
const HIDE_CHECKLIST = 'hide:checklist'
|
|
125
|
+
const stepKey = (tour: OnboardingTour, step: OnboardingStep) => `step:${tour.id}/${step.id}`
|
|
126
|
+
const anchorSelector = (anchor: string) => `[data-onboarding="${CSS.escape(anchor)}"]`
|
|
127
|
+
|
|
128
|
+
/** Types a definition, so it can live in its own file. */
|
|
129
|
+
export function defineOnboarding(definition: OnboardingDefinition): OnboardingDefinition {
|
|
130
|
+
return definition
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readSeen(key: string): string[] | null {
|
|
134
|
+
try {
|
|
135
|
+
const raw = globalThis.localStorage?.getItem(key)
|
|
136
|
+
const parsed: unknown = raw ? JSON.parse(raw) : null
|
|
137
|
+
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : null
|
|
138
|
+
} catch {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeSeen(key: string, seen: Set<string>) {
|
|
144
|
+
try {
|
|
145
|
+
globalThis.localStorage?.setItem(key, JSON.stringify([...seen]))
|
|
146
|
+
} catch {
|
|
147
|
+
// Private windows and full storage: the state lasts until reload.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Resolves with the element once it is in the document, or null after `timeout` or when `signal` aborts. */
|
|
152
|
+
function waitForElement(selector: string, timeout: number, signal: AbortSignal): Promise<HTMLElement | null> {
|
|
153
|
+
const found = document.querySelector<HTMLElement>(selector)
|
|
154
|
+
if (found) return Promise.resolve(found)
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const observer = new MutationObserver(() => {
|
|
157
|
+
const element = document.querySelector<HTMLElement>(selector)
|
|
158
|
+
if (element) finish(element)
|
|
159
|
+
})
|
|
160
|
+
const timer = setTimeout(() => finish(null), timeout)
|
|
161
|
+
const onAbort = () => finish(null)
|
|
162
|
+
function finish(element: HTMLElement | null) {
|
|
163
|
+
observer.disconnect()
|
|
164
|
+
clearTimeout(timer)
|
|
165
|
+
signal.removeEventListener('abort', onAbort)
|
|
166
|
+
resolve(element)
|
|
167
|
+
}
|
|
168
|
+
signal.addEventListener('abort', onAbort)
|
|
169
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-onboarding'] })
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Any open modal except the tour card itself.
|
|
174
|
+
const MODAL_SELECTOR = '[aria-modal="true"]:not(.mint-onboarding__card)'
|
|
175
|
+
|
|
176
|
+
/** Resolves true once no modal is open, or false when `signal` aborts first. */
|
|
177
|
+
function waitForNoModal(signal: AbortSignal): Promise<boolean> {
|
|
178
|
+
if (!document.querySelector(MODAL_SELECTOR)) return Promise.resolve(true)
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
const observer = new MutationObserver(() => {
|
|
181
|
+
if (!document.querySelector(MODAL_SELECTOR)) finish(true)
|
|
182
|
+
})
|
|
183
|
+
const onAbort = () => finish(false)
|
|
184
|
+
function finish(closed: boolean) {
|
|
185
|
+
observer.disconnect()
|
|
186
|
+
signal.removeEventListener('abort', onAbort)
|
|
187
|
+
resolve(closed)
|
|
188
|
+
}
|
|
189
|
+
signal.addEventListener('abort', onAbort)
|
|
190
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-modal'] })
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Creates the app's onboarding coordinator and provides it to `AppTopBar`, which then
|
|
196
|
+
* shows the Help button, the checklist chip and the tour layer. Call it once, in the
|
|
197
|
+
* root component. It shows one item at a time and waits for open modals.
|
|
198
|
+
*
|
|
199
|
+
* ```ts
|
|
200
|
+
* provideOnboarding(onboarding, { appId: 'leaf' })
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
export function provideOnboarding(definition: OnboardingDefinition, options: ProvideOnboardingOptions): OnboardingState {
|
|
204
|
+
const { user, isAdmin } = usePlatform()
|
|
205
|
+
const userId = computed(() => {
|
|
206
|
+
// With no user, isAdmin is true only standalone or with auth off.
|
|
207
|
+
const fallback = isAdmin.value ? 'local' : null
|
|
208
|
+
const value = options.userId === undefined ? (user.value?.id ?? fallback) : toValue(options.userId)
|
|
209
|
+
return value == null ? null : String(value)
|
|
210
|
+
})
|
|
211
|
+
const route = computed(() => toValue(options.route) ?? undefined)
|
|
212
|
+
const storageKey = computed(() => userId.value === null ? null : `mint.onboarding.${options.appId}.${userId.value}`)
|
|
213
|
+
const enabled = computed(() => storageKey.value !== null)
|
|
214
|
+
|
|
215
|
+
const seen = ref(new Set<string>())
|
|
216
|
+
// Bumped by refresh(): the predicates read app state that Vue may not track.
|
|
217
|
+
const tick = ref(0)
|
|
218
|
+
const active = shallowRef<OnboardingActiveStep | null>(null)
|
|
219
|
+
let run: { tour: OnboardingTour | null; steps: OnboardingStep[]; index: number; shown: Set<number> } | null = null
|
|
220
|
+
let runId = 0
|
|
221
|
+
let stepWait: AbortController | undefined
|
|
222
|
+
let detachClick: (() => void) | undefined
|
|
223
|
+
|
|
224
|
+
/** Applies `update` to the stored state merged with this tab's, so tabs do not drop each other's keys. */
|
|
225
|
+
function persist(update: (keys: Set<string>) => void) {
|
|
226
|
+
const key = storageKey.value
|
|
227
|
+
if (!key) return
|
|
228
|
+
const merged = new Set([...(readSeen(key) ?? []), ...seen.value])
|
|
229
|
+
update(merged)
|
|
230
|
+
seen.value = merged
|
|
231
|
+
writeSeen(key, merged)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function markSeen(...keys: string[]) {
|
|
235
|
+
if (keys.every((key) => seen.value.has(key))) return
|
|
236
|
+
persist((set) => keys.forEach((key) => set.add(key)))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Another tab changed the state.
|
|
240
|
+
function onStorage(event: StorageEvent) {
|
|
241
|
+
if (event.key && event.key === storageKey.value) seen.value = new Set(readSeen(event.key) ?? [])
|
|
242
|
+
}
|
|
243
|
+
window.addEventListener('storage', onStorage)
|
|
244
|
+
|
|
245
|
+
watch(storageKey, (key) => {
|
|
246
|
+
stop()
|
|
247
|
+
if (!key) {
|
|
248
|
+
seen.value = new Set()
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
const stored = readSeen(key)
|
|
252
|
+
seen.value = new Set(stored ?? [])
|
|
253
|
+
// First run: the tours introduce today's features, so today's news counts as seen.
|
|
254
|
+
if (stored === null) {
|
|
255
|
+
for (const item of definition.whatsNew ?? []) seen.value.add(`news:${item.id}`)
|
|
256
|
+
writeSeen(key, seen.value)
|
|
257
|
+
}
|
|
258
|
+
}, { immediate: true })
|
|
259
|
+
|
|
260
|
+
const tours = computed(() => (definition.tours ?? []).filter((tour) => !tour.route || tour.route === route.value))
|
|
261
|
+
|
|
262
|
+
const tasks = computed(() => {
|
|
263
|
+
void tick.value
|
|
264
|
+
return (definition.checklist ?? []).filter((task) => task.available?.() ?? true).map((task) => ({
|
|
265
|
+
...task,
|
|
266
|
+
done: seen.value.has(`task:${task.id}`) || !!task.done?.(),
|
|
267
|
+
}))
|
|
268
|
+
})
|
|
269
|
+
// Done stays done, also when a predicate later returns false.
|
|
270
|
+
watch(tasks, (list) => markSeen(...list.filter((task) => task.done).map((task) => `task:${task.id}`)), { immediate: true })
|
|
271
|
+
const tasksDone = computed(() => tasks.value.filter((task) => task.done).length)
|
|
272
|
+
const checklistHidden = computed(() => seen.value.has(HIDE_CHECKLIST))
|
|
273
|
+
const checklistVisible = computed(() =>
|
|
274
|
+
enabled.value && tasks.value.length > 0 && tasksDone.value < tasks.value.length && !checklistHidden.value)
|
|
275
|
+
|
|
276
|
+
const news = computed(() => (definition.whatsNew ?? []).map((item) => ({ ...item, unseen: !seen.value.has(`news:${item.id}`) })))
|
|
277
|
+
const unseenNews = computed(() => enabled.value ? news.value.filter((item) => item.unseen).length : 0)
|
|
278
|
+
|
|
279
|
+
function stop() {
|
|
280
|
+
runId += 1
|
|
281
|
+
stepWait?.abort()
|
|
282
|
+
run = null
|
|
283
|
+
detachClick?.()
|
|
284
|
+
detachClick = undefined
|
|
285
|
+
active.value = null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Shows the first step at or after `from` whose anchor is there. Only the first step of
|
|
290
|
+
* the run and the step after a click step wait for theirs (the page or a menu can still
|
|
291
|
+
* be opening); after Next a missing anchor is skipped at once. Skipped steps stay unseen.
|
|
292
|
+
*/
|
|
293
|
+
/**
|
|
294
|
+
* The later steps the page can still show: the anchor is on the page now, or
|
|
295
|
+
* a click step that can show opens it. The count grows if an anchor renders later.
|
|
296
|
+
*/
|
|
297
|
+
function laterSteps(steps: OnboardingStep[], from: number) {
|
|
298
|
+
let count = 0
|
|
299
|
+
let previous = true
|
|
300
|
+
for (let at = from + 1; at < steps.length; at += 1) {
|
|
301
|
+
previous = (previous && steps[at - 1].advanceOn === 'click') || !!document.querySelector(anchorSelector(steps[at].anchor))
|
|
302
|
+
if (previous) count += 1
|
|
303
|
+
}
|
|
304
|
+
return count
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function showFrom(from: number) {
|
|
308
|
+
if (!run) return
|
|
309
|
+
const id = ++runId
|
|
310
|
+
stepWait?.abort()
|
|
311
|
+
const wait = new AbortController()
|
|
312
|
+
stepWait = wait
|
|
313
|
+
detachClick?.()
|
|
314
|
+
detachClick = undefined
|
|
315
|
+
const waitFirst = from === 0 || run.steps[from - 1].advanceOn === 'click'
|
|
316
|
+
for (let index = from; index < run.steps.length; index += 1) {
|
|
317
|
+
const step = run.steps[index]
|
|
318
|
+
const timeout = index === from && waitFirst ? ANCHOR_WAIT_MS : 0
|
|
319
|
+
const element = await waitForElement(anchorSelector(step.anchor), timeout, wait.signal)
|
|
320
|
+
if (id !== runId) return
|
|
321
|
+
if (!element) {
|
|
322
|
+
// Debug level: a missing anchor is normal, e.g. an admin-only button for a member.
|
|
323
|
+
console.debug(`[mint-onboarding] No element with data-onboarding="${step.anchor}"; step "${step.id}" skipped.`)
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
run.index = index
|
|
327
|
+
markSeen(run.tour ? stepKey(run.tour, step) : `news:${step.id}`)
|
|
328
|
+
if (step.advanceOn === 'click') {
|
|
329
|
+
const onClick = () => next()
|
|
330
|
+
element.addEventListener('click', onClick)
|
|
331
|
+
detachClick = () => element.removeEventListener('click', onClick)
|
|
332
|
+
}
|
|
333
|
+
run.shown.add(index)
|
|
334
|
+
// Earlier steps count as the later ones do: shown, and still on the page.
|
|
335
|
+
const steps = run.steps
|
|
336
|
+
const position = [...run.shown].filter((at) => at < index && !!document.querySelector(anchorSelector(steps[at].anchor))).length
|
|
337
|
+
active.value = { step, element, index: position, count: position + 1 + laterSteps(run.steps, index), news: !run.tour }
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
// A finished tour hands over to the next one; × does not (dismiss stops the run first).
|
|
341
|
+
const finished = !!run.tour
|
|
342
|
+
stop()
|
|
343
|
+
if (finished && options.autoStart !== false) void autoStart()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function begin(tour: OnboardingTour | null, steps: OnboardingStep[]) {
|
|
347
|
+
if (!enabled.value || steps.length === 0) return
|
|
348
|
+
stop()
|
|
349
|
+
run = { tour, steps, index: 0, shown: new Set() }
|
|
350
|
+
void showFrom(0)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function startTour(id: string) {
|
|
354
|
+
const tour = tours.value.find((item) => item.id === id)
|
|
355
|
+
if (!tour) return
|
|
356
|
+
autoStartAbort?.abort()
|
|
357
|
+
begin(tour, availableSteps(tour))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function showNews(id: string) {
|
|
361
|
+
const item = definition.whatsNew?.find((entry) => entry.id === id)
|
|
362
|
+
if (!item) return
|
|
363
|
+
autoStartAbort?.abort()
|
|
364
|
+
begin(null, [item])
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function next() {
|
|
368
|
+
if (run) void showFrom(run.index + 1)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function back() {
|
|
372
|
+
if (!run) return
|
|
373
|
+
// Back goes only to steps whose element is still on the page; it does not wait.
|
|
374
|
+
for (let index = run.index - 1; index >= 0; index -= 1) {
|
|
375
|
+
if (document.querySelector(anchorSelector(run.steps[index].anchor))) {
|
|
376
|
+
void showFrom(index)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function dismiss() {
|
|
383
|
+
autoStartAbort?.abort()
|
|
384
|
+
const tour = run?.tour
|
|
385
|
+
if (tour) markSeen(...tour.steps.map((step) => stepKey(tour, step)))
|
|
386
|
+
stop()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function complete(taskId: string) {
|
|
390
|
+
markSeen(`task:${taskId}`)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function setChecklistHidden(hidden: boolean) {
|
|
394
|
+
if (!storageKey.value) return
|
|
395
|
+
if (hidden) markSeen(HIDE_CHECKLIST)
|
|
396
|
+
else persist((set) => set.delete(HIDE_CHECKLIST))
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function refresh() {
|
|
400
|
+
tick.value += 1
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const availableSteps = (tour: OnboardingTour) => tour.steps.filter((step) => step.available?.() ?? true)
|
|
404
|
+
|
|
405
|
+
// Tours started on this page; a tour with skipped steps does not start again until the page changes.
|
|
406
|
+
const tried = new Set<string>()
|
|
407
|
+
let autoStartAbort: AbortController | undefined
|
|
408
|
+
async function autoStart() {
|
|
409
|
+
autoStartAbort?.abort()
|
|
410
|
+
const controller = new AbortController()
|
|
411
|
+
autoStartAbort = controller
|
|
412
|
+
if (!(await waitForNoModal(controller.signal)) || run || !enabled.value) return
|
|
413
|
+
for (const tour of tours.value) {
|
|
414
|
+
if (tried.has(tour.id)) continue
|
|
415
|
+
const unseen = availableSteps(tour).filter((step) => !seen.value.has(stepKey(tour, step)))
|
|
416
|
+
if (unseen.length) {
|
|
417
|
+
tried.add(tour.id)
|
|
418
|
+
begin(tour, unseen)
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// A new page ends the run: its elements are gone. Its unseen steps start again.
|
|
425
|
+
watch([route, storageKey], () => {
|
|
426
|
+
stop()
|
|
427
|
+
tried.clear()
|
|
428
|
+
refresh()
|
|
429
|
+
if (options.autoStart !== false) void autoStart()
|
|
430
|
+
}, { immediate: true })
|
|
431
|
+
|
|
432
|
+
onScopeDispose(() => {
|
|
433
|
+
autoStartAbort?.abort()
|
|
434
|
+
window.removeEventListener('storage', onStorage)
|
|
435
|
+
stop()
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
const state: OnboardingState = {
|
|
439
|
+
definition,
|
|
440
|
+
enabled,
|
|
441
|
+
active,
|
|
442
|
+
tours,
|
|
443
|
+
tasks,
|
|
444
|
+
tasksDone,
|
|
445
|
+
checklistVisible,
|
|
446
|
+
checklistHidden,
|
|
447
|
+
news,
|
|
448
|
+
unseenNews,
|
|
449
|
+
startTour,
|
|
450
|
+
showNews,
|
|
451
|
+
next,
|
|
452
|
+
back,
|
|
453
|
+
dismiss,
|
|
454
|
+
complete,
|
|
455
|
+
setChecklistHidden,
|
|
456
|
+
refresh,
|
|
457
|
+
}
|
|
458
|
+
provide(ONBOARDING_KEY, state)
|
|
459
|
+
return state
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The app's onboarding coordinator, e.g. to call `complete(taskId)`. Null when the app provides none. */
|
|
463
|
+
export function useOnboarding(): OnboardingState | null {
|
|
464
|
+
return inject(ONBOARDING_KEY, null)
|
|
465
|
+
}
|
package/src/shell.ts
CHANGED
|
@@ -101,3 +101,20 @@ export {
|
|
|
101
101
|
type UsePluginWorkspaceOptions,
|
|
102
102
|
type UsePluginWorkspaceReturn,
|
|
103
103
|
} from './composables/usePluginWorkspace'
|
|
104
|
+
|
|
105
|
+
// Onboarding: tours, the Getting started checklist and what's-new hints
|
|
106
|
+
export {
|
|
107
|
+
ANCHOR_WAIT_MS,
|
|
108
|
+
defineOnboarding,
|
|
109
|
+
provideOnboarding,
|
|
110
|
+
useOnboarding,
|
|
111
|
+
ONBOARDING_KEY,
|
|
112
|
+
type OnboardingActiveStep,
|
|
113
|
+
type OnboardingDefinition,
|
|
114
|
+
type OnboardingNews,
|
|
115
|
+
type OnboardingState,
|
|
116
|
+
type OnboardingStep,
|
|
117
|
+
type OnboardingTask,
|
|
118
|
+
type OnboardingTour,
|
|
119
|
+
type ProvideOnboardingOptions,
|
|
120
|
+
} from './composables/useOnboarding'
|