@morscherlab/mint-sdk 1.4.0-beta.15 → 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.
Files changed (34) hide show
  1. package/dist/{AppSidebar-Bi8g9mRt.js → AppSidebar-CB0-RQ4m.js} +876 -114
  2. package/dist/AppSidebar-CB0-RQ4m.js.map +1 -0
  3. package/dist/{SettingsModal-jra_2u0o.js → SettingsModal-CoZK3EDN.js} +2 -2
  4. package/dist/{SettingsModal-jra_2u0o.js.map → SettingsModal-CoZK3EDN.js.map} +1 -1
  5. package/dist/__tests__/components/AppTopBarOnboarding.test.d.ts +1 -0
  6. package/dist/__tests__/composables/useOnboarding.test.d.ts +1 -0
  7. package/dist/components/AppTopBar.vue.d.ts +4 -4
  8. package/dist/components/internal/AppTopBarOnboardingInternal.vue.d.ts +7 -0
  9. package/dist/components/internal/OnboardingLayerInternal.vue.d.ts +7 -0
  10. package/dist/composables/useOnboarding.d.ts +119 -0
  11. package/dist/index.js +5 -5
  12. package/dist/{install-Bx_6KwJA.js → install-BKgb5qZw.js} +5 -5
  13. package/dist/{install-Bx_6KwJA.js.map → install-BKgb5qZw.js.map} +1 -1
  14. package/dist/install.js +1 -1
  15. package/dist/{runtime-BJvGms6h.js → runtime-Cz1GJa_O.js} +2 -2
  16. package/dist/{runtime-BJvGms6h.js.map → runtime-Cz1GJa_O.js.map} +1 -1
  17. package/dist/runtime.js +2 -2
  18. package/dist/{shell-Czyrzk7l.js → shell-kq9p4VsX.js} +7 -2
  19. package/dist/{shell-Czyrzk7l.js.map → shell-kq9p4VsX.js.map} +1 -1
  20. package/dist/shell.d.ts +1 -0
  21. package/dist/shell.js +3 -3
  22. package/dist/styles.css +369 -0
  23. package/package.json +1 -1
  24. package/src/__tests__/components/AppTopBarOnboarding.test.ts +160 -0
  25. package/src/__tests__/composables/useOnboarding.test.ts +317 -0
  26. package/src/components/AppTopBar.onboarding.story.vue +69 -0
  27. package/src/components/AppTopBar.vue +11 -2
  28. package/src/components/internal/AppTopBarOnboardingInternal.vue +172 -0
  29. package/src/components/internal/OnboardingLayerInternal.vue +173 -0
  30. package/src/composables/useOnboarding.ts +465 -0
  31. package/src/shell.ts +17 -0
  32. package/src/styles/components/onboarding.css +368 -0
  33. package/src/styles/index.css +1 -0
  34. 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'