@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.
- 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,172 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The top-bar onboarding controls: the "Getting started n/m" chip while the
|
|
4
|
+
* checklist is open, and the Help button with the checklist, tours and news.
|
|
5
|
+
*/
|
|
6
|
+
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
7
|
+
import { autoUpdate, computePosition, offset, shift } from '@floating-ui/dom'
|
|
8
|
+
import { Check, CircleHelp, Play } from 'lucide-vue-next'
|
|
9
|
+
import { useEventListener } from '../../composables/useEventListener'
|
|
10
|
+
import type { OnboardingState } from '../../composables/useOnboarding'
|
|
11
|
+
import OnboardingLayerInternal from './OnboardingLayerInternal.vue'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{ state: OnboardingState }>()
|
|
14
|
+
|
|
15
|
+
const root = ref<HTMLElement | null>(null)
|
|
16
|
+
const helpButton = ref<HTMLElement | null>(null)
|
|
17
|
+
const panel = ref<HTMLElement | null>(null)
|
|
18
|
+
const panelPosition = ref({ x: 0, y: 0 })
|
|
19
|
+
const open = ref(false)
|
|
20
|
+
const hasContent = computed(() =>
|
|
21
|
+
props.state.tasks.value.length > 0 || props.state.tours.value.length > 0 || props.state.news.value.length > 0)
|
|
22
|
+
const progress = computed(() => {
|
|
23
|
+
const total = props.state.tasks.value.length
|
|
24
|
+
return total ? Math.round((props.state.tasksDone.value / total) * 100) : 0
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function toggle() {
|
|
28
|
+
open.value = !open.value
|
|
29
|
+
if (open.value) props.state.refresh()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The panel goes to the body, as the top bar clips its overflow; it follows the button.
|
|
33
|
+
let stopAutoUpdate: (() => void) | undefined
|
|
34
|
+
watch(open, async (isOpen) => {
|
|
35
|
+
stopAutoUpdate?.()
|
|
36
|
+
stopAutoUpdate = undefined
|
|
37
|
+
if (!isOpen) return
|
|
38
|
+
await nextTick()
|
|
39
|
+
const button = helpButton.value
|
|
40
|
+
const floating = panel.value
|
|
41
|
+
if (!button || !floating || !open.value) return
|
|
42
|
+
stopAutoUpdate = autoUpdate(button, floating, async () => {
|
|
43
|
+
const { x, y } = await computePosition(button, floating, {
|
|
44
|
+
strategy: 'fixed',
|
|
45
|
+
placement: 'bottom-end',
|
|
46
|
+
middleware: [offset(8), shift({ padding: 8 })],
|
|
47
|
+
})
|
|
48
|
+
panelPosition.value = { x, y }
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
onBeforeUnmount(() => stopAutoUpdate?.())
|
|
52
|
+
|
|
53
|
+
function run(action: () => void) {
|
|
54
|
+
open.value = false
|
|
55
|
+
action()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
useEventListener(document, 'pointerdown', (event) => {
|
|
59
|
+
const target = event.target as Node
|
|
60
|
+
if (open.value && !root.value?.contains(target) && !panel.value?.contains(target)) open.value = false
|
|
61
|
+
})
|
|
62
|
+
useEventListener(document, 'keydown', (event) => {
|
|
63
|
+
if (open.value && event.key === 'Escape') open.value = false
|
|
64
|
+
})
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<template>
|
|
68
|
+
<div v-if="hasContent" ref="root" class="mint-onboarding-help">
|
|
69
|
+
<button
|
|
70
|
+
v-if="state.checklistVisible.value"
|
|
71
|
+
type="button"
|
|
72
|
+
class="mint-onboarding-help__chip"
|
|
73
|
+
:class="{ 'mint-onboarding-help__chip--open': open }"
|
|
74
|
+
data-onboarding="topbar-checklist"
|
|
75
|
+
:aria-expanded="open"
|
|
76
|
+
@click="toggle"
|
|
77
|
+
>
|
|
78
|
+
Getting started
|
|
79
|
+
<span class="mint-onboarding-help__count">{{ state.tasksDone.value }}/{{ state.tasks.value.length }}</span>
|
|
80
|
+
<span class="mint-onboarding-help__bar" aria-hidden="true"><span :style="{ width: `${progress}%` }" /></span>
|
|
81
|
+
</button>
|
|
82
|
+
<button
|
|
83
|
+
ref="helpButton"
|
|
84
|
+
type="button"
|
|
85
|
+
class="mint-topbar__settings-btn"
|
|
86
|
+
:class="{ 'mint-topbar__settings-btn--open': open }"
|
|
87
|
+
data-onboarding="topbar-help"
|
|
88
|
+
:aria-label="state.unseenNews.value ? `Help, ${state.unseenNews.value} new` : 'Help'"
|
|
89
|
+
:aria-expanded="open"
|
|
90
|
+
@click="toggle"
|
|
91
|
+
>
|
|
92
|
+
<CircleHelp class="mint-topbar__settings-icon" aria-hidden="true" />
|
|
93
|
+
<span v-if="state.unseenNews.value" class="mint-onboarding-help__badge" aria-hidden="true">{{ state.unseenNews.value }}</span>
|
|
94
|
+
</button>
|
|
95
|
+
|
|
96
|
+
<Teleport to="body">
|
|
97
|
+
<div
|
|
98
|
+
v-if="open"
|
|
99
|
+
ref="panel"
|
|
100
|
+
class="mint-popover mint-onboarding-help__panel"
|
|
101
|
+
role="dialog"
|
|
102
|
+
aria-label="Help"
|
|
103
|
+
:style="{ left: `${panelPosition.x}px`, top: `${panelPosition.y}px` }"
|
|
104
|
+
>
|
|
105
|
+
<template v-if="state.tasks.value.length">
|
|
106
|
+
<div class="mint-onboarding-help__header">
|
|
107
|
+
<span class="mint-onboarding-help__heading">Getting started</span>
|
|
108
|
+
<span class="mint-onboarding-help__count">{{ state.tasksDone.value }}/{{ state.tasks.value.length }}</span>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="mint-onboarding-help__progress" aria-hidden="true"><span :style="{ width: `${progress}%` }" /></div>
|
|
111
|
+
<ul class="mint-onboarding-help__todos">
|
|
112
|
+
<li
|
|
113
|
+
v-for="task in state.tasks.value"
|
|
114
|
+
:key="task.id"
|
|
115
|
+
:class="['mint-onboarding-help__todo', { 'mint-onboarding-help__todo--done': task.done }]"
|
|
116
|
+
>
|
|
117
|
+
<span class="mint-onboarding-help__check" role="img" :aria-label="task.done ? 'Done' : 'Not done'">
|
|
118
|
+
<Check v-if="task.done" :size="10" :stroke-width="3" aria-hidden="true" />
|
|
119
|
+
</span>
|
|
120
|
+
<span class="mint-onboarding-help__todo-title">{{ task.title }}</span>
|
|
121
|
+
<button
|
|
122
|
+
v-if="task.action && !task.done"
|
|
123
|
+
type="button"
|
|
124
|
+
class="mint-onboarding-help__link"
|
|
125
|
+
@click="run(task.action.run)"
|
|
126
|
+
>
|
|
127
|
+
{{ task.action.label }}
|
|
128
|
+
</button>
|
|
129
|
+
</li>
|
|
130
|
+
</ul>
|
|
131
|
+
<button
|
|
132
|
+
v-if="state.tasksDone.value < state.tasks.value.length"
|
|
133
|
+
type="button"
|
|
134
|
+
class="mint-onboarding-help__link mint-onboarding-help__hide"
|
|
135
|
+
@click="state.setChecklistHidden(!state.checklistHidden.value)"
|
|
136
|
+
>
|
|
137
|
+
{{ state.checklistHidden.value ? 'Show checklist in the top bar' : 'Hide checklist from the top bar' }}
|
|
138
|
+
</button>
|
|
139
|
+
</template>
|
|
140
|
+
|
|
141
|
+
<template v-if="state.tours.value.length">
|
|
142
|
+
<div class="mint-onboarding-help__section">Tours</div>
|
|
143
|
+
<button
|
|
144
|
+
v-for="tour in state.tours.value"
|
|
145
|
+
:key="tour.id"
|
|
146
|
+
type="button"
|
|
147
|
+
class="mint-menu-item"
|
|
148
|
+
@click="run(() => state.startTour(tour.id))"
|
|
149
|
+
>
|
|
150
|
+
<Play class="mint-onboarding-help__icon" aria-hidden="true" />
|
|
151
|
+
{{ tour.title }}
|
|
152
|
+
</button>
|
|
153
|
+
</template>
|
|
154
|
+
|
|
155
|
+
<template v-if="state.news.value.length">
|
|
156
|
+
<div class="mint-onboarding-help__section">What's new</div>
|
|
157
|
+
<button
|
|
158
|
+
v-for="item in state.news.value"
|
|
159
|
+
:key="item.id"
|
|
160
|
+
type="button"
|
|
161
|
+
class="mint-menu-item"
|
|
162
|
+
@click="run(() => state.showNews(item.id))"
|
|
163
|
+
>
|
|
164
|
+
{{ item.title }}
|
|
165
|
+
<span v-if="item.unseen" class="mint-onboarding-help__new">New</span>
|
|
166
|
+
</button>
|
|
167
|
+
</template>
|
|
168
|
+
</div>
|
|
169
|
+
</Teleport>
|
|
170
|
+
</div>
|
|
171
|
+
<OnboardingLayerInternal :state="state" />
|
|
172
|
+
</template>
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The tour layer: a scrim with a hole over the anchor, the step card, and the
|
|
4
|
+
* what's-new dots. AppTopBar renders it when the app provides onboarding.
|
|
5
|
+
*/
|
|
6
|
+
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
|
|
7
|
+
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
|
|
8
|
+
import { X } from 'lucide-vue-next'
|
|
9
|
+
import BaseButton from '../BaseButton.vue'
|
|
10
|
+
import { useEventListener } from '../../composables/useEventListener'
|
|
11
|
+
import type { OnboardingState } from '../../composables/useOnboarding'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{ state: OnboardingState }>()
|
|
14
|
+
|
|
15
|
+
// Space between the anchor and the edge of the hole.
|
|
16
|
+
const HOLE_PAD = 4
|
|
17
|
+
|
|
18
|
+
const active = computed(() => props.state.active.value)
|
|
19
|
+
const card = ref<HTMLElement | null>(null)
|
|
20
|
+
const hole = ref<DOMRect | null>(null)
|
|
21
|
+
const cardPosition = ref({ x: 0, y: 0 })
|
|
22
|
+
const titleId = useId()
|
|
23
|
+
let stopAutoUpdate: (() => void) | undefined
|
|
24
|
+
|
|
25
|
+
async function position() {
|
|
26
|
+
const current = active.value
|
|
27
|
+
if (!current || !card.value) return
|
|
28
|
+
hole.value = current.element.getBoundingClientRect()
|
|
29
|
+
const { x, y } = await computePosition(current.element, card.value, {
|
|
30
|
+
strategy: 'fixed',
|
|
31
|
+
placement: current.step.placement ?? 'bottom',
|
|
32
|
+
middleware: [offset(12), flip(), shift({ padding: 12 })],
|
|
33
|
+
})
|
|
34
|
+
// The step may have changed while the position was computed.
|
|
35
|
+
if (active.value === current) cardPosition.value = { x, y }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Focus goes back where it was when the run ends.
|
|
39
|
+
let returnFocus: HTMLElement | null = null
|
|
40
|
+
watch(active, async (current, previous) => {
|
|
41
|
+
stopAutoUpdate?.()
|
|
42
|
+
stopAutoUpdate = undefined
|
|
43
|
+
hole.value = null
|
|
44
|
+
if (current && !previous) returnFocus = document.activeElement as HTMLElement | null
|
|
45
|
+
if (!current) {
|
|
46
|
+
if (returnFocus?.isConnected) returnFocus.focus()
|
|
47
|
+
returnFocus = null
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
await nextTick()
|
|
51
|
+
if (!card.value || active.value !== current) return
|
|
52
|
+
current.element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
53
|
+
stopAutoUpdate = autoUpdate(current.element, card.value, () => void position())
|
|
54
|
+
// A click step: the keyboard user presses the control itself.
|
|
55
|
+
if (current.step.advanceOn === 'click') current.element.focus()
|
|
56
|
+
// Otherwise the card itself: no focus ring on load, and Tab reaches Back and Next.
|
|
57
|
+
else card.value.focus()
|
|
58
|
+
}, { immediate: true })
|
|
59
|
+
onBeforeUnmount(() => stopAutoUpdate?.())
|
|
60
|
+
|
|
61
|
+
// The layer is its own focus trap: Tab cycles over the card's buttons (and the
|
|
62
|
+
// anchor on a click step), never the page behind; from the card itself Tab goes
|
|
63
|
+
// to the first stop and Shift+Tab to the last.
|
|
64
|
+
useEventListener(document, 'keydown', (event) => {
|
|
65
|
+
const current = active.value
|
|
66
|
+
if (!current || !card.value) return
|
|
67
|
+
// Keys belong to the tour only while focus is on its card, its anchor, or nowhere.
|
|
68
|
+
const focused = document.activeElement
|
|
69
|
+
if (focused && focused !== document.body && !card.value.contains(focused) && focused !== current.element) return
|
|
70
|
+
const onCard = focused === card.value
|
|
71
|
+
if (event.key === 'Escape') {
|
|
72
|
+
event.stopPropagation()
|
|
73
|
+
props.state.dismiss()
|
|
74
|
+
} else if (event.key === 'Tab') {
|
|
75
|
+
const buttons = [...card.value.querySelectorAll<HTMLElement>('button')]
|
|
76
|
+
const stops = current.step.advanceOn === 'click' ? [current.element, ...buttons] : buttons
|
|
77
|
+
const at = stops.indexOf(document.activeElement as HTMLElement)
|
|
78
|
+
const target = at === -1 ? (event.shiftKey ? stops.length - 1 : 0) : (at + (event.shiftKey ? -1 : 1) + stops.length) % stops.length
|
|
79
|
+
event.preventDefault()
|
|
80
|
+
stops[target].focus()
|
|
81
|
+
} else if (event.key === 'Enter' && onCard && current.step.advanceOn !== 'click') {
|
|
82
|
+
event.preventDefault()
|
|
83
|
+
props.state.next()
|
|
84
|
+
}
|
|
85
|
+
}, { capture: true })
|
|
86
|
+
|
|
87
|
+
const holeStyle = computed(() => hole.value && {
|
|
88
|
+
left: `${hole.value.left - HOLE_PAD}px`,
|
|
89
|
+
top: `${hole.value.top - HOLE_PAD}px`,
|
|
90
|
+
width: `${hole.value.width + 2 * HOLE_PAD}px`,
|
|
91
|
+
height: `${hole.value.height + 2 * HOLE_PAD}px`,
|
|
92
|
+
})
|
|
93
|
+
// advanceOn 'click': cut the hole out of the click blocker, so the anchor takes the click.
|
|
94
|
+
const blockerStyle = computed(() => {
|
|
95
|
+
const rect = hole.value
|
|
96
|
+
if (!rect || active.value?.step.advanceOn !== 'click') return undefined
|
|
97
|
+
const [x, y, w, h] = [rect.left - HOLE_PAD, rect.top - HOLE_PAD, rect.width + 2 * HOLE_PAD, rect.height + 2 * HOLE_PAD]
|
|
98
|
+
return { clipPath: `path(evenodd, 'M0 0H${window.innerWidth}V${window.innerHeight}H0Z M${x} ${y}h${w}v${h}h${-w}Z')` }
|
|
99
|
+
})
|
|
100
|
+
const isLast = computed(() => !!active.value && active.value.index === active.value.count - 1)
|
|
101
|
+
const nextLabel = computed(() => active.value?.news ? 'Got it' : isLast.value ? 'Done' : 'Next')
|
|
102
|
+
|
|
103
|
+
// What's-new dots: one on each element with unseen news, hidden while a card shows.
|
|
104
|
+
const dots = ref<Array<{ id: string; title: string; x: number; y: number }>>([])
|
|
105
|
+
let frame = 0
|
|
106
|
+
function placeDots() {
|
|
107
|
+
cancelAnimationFrame(frame)
|
|
108
|
+
frame = requestAnimationFrame(() => {
|
|
109
|
+
dots.value = active.value ? [] : props.state.news.value.flatMap((item) => {
|
|
110
|
+
if (!item.unseen) return []
|
|
111
|
+
const element = document.querySelector(`[data-onboarding="${CSS.escape(item.anchor)}"]`)
|
|
112
|
+
if (!element) return []
|
|
113
|
+
const rect = element.getBoundingClientRect()
|
|
114
|
+
return rect.width ? [{ id: item.id, title: item.title, x: rect.right, y: rect.top }] : []
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
const observer = new MutationObserver(placeDots)
|
|
119
|
+
watch(() => props.state.unseenNews.value > 0, (watching) => {
|
|
120
|
+
observer.disconnect()
|
|
121
|
+
if (watching) observer.observe(document.body, { childList: true, subtree: true })
|
|
122
|
+
placeDots()
|
|
123
|
+
}, { immediate: true })
|
|
124
|
+
watch(active, placeDots)
|
|
125
|
+
useEventListener(window, 'resize', placeDots)
|
|
126
|
+
useEventListener(window, 'scroll', placeDots, { capture: true, passive: true })
|
|
127
|
+
onBeforeUnmount(() => {
|
|
128
|
+
observer.disconnect()
|
|
129
|
+
cancelAnimationFrame(frame)
|
|
130
|
+
})
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<template>
|
|
134
|
+
<Teleport to="body">
|
|
135
|
+
<button
|
|
136
|
+
v-for="dot in dots"
|
|
137
|
+
:key="dot.id"
|
|
138
|
+
type="button"
|
|
139
|
+
class="mint-onboarding__dot"
|
|
140
|
+
:style="{ left: `${dot.x}px`, top: `${dot.y}px` }"
|
|
141
|
+
:aria-label="`New: ${dot.title}`"
|
|
142
|
+
@click="state.showNews(dot.id)"
|
|
143
|
+
/>
|
|
144
|
+
<div v-if="active" class="mint-onboarding">
|
|
145
|
+
<div class="mint-onboarding__blocker" :style="blockerStyle" />
|
|
146
|
+
<div v-if="holeStyle" class="mint-onboarding__hole" :style="holeStyle" />
|
|
147
|
+
<div
|
|
148
|
+
ref="card"
|
|
149
|
+
class="mint-onboarding__card"
|
|
150
|
+
role="dialog"
|
|
151
|
+
aria-modal="true"
|
|
152
|
+
:aria-labelledby="titleId"
|
|
153
|
+
tabindex="-1"
|
|
154
|
+
:style="{ left: `${cardPosition.x}px`, top: `${cardPosition.y}px` }"
|
|
155
|
+
>
|
|
156
|
+
<button type="button" class="mint-onboarding__close" :aria-label="active.news ? 'Close' : 'Close tour'" @click="state.dismiss()">
|
|
157
|
+
<X :size="14" aria-hidden="true" />
|
|
158
|
+
</button>
|
|
159
|
+
<span v-if="active.count > 1" class="mint-onboarding__count">
|
|
160
|
+
<span aria-hidden="true">{{ active.index + 1 }} / {{ active.count }}</span>
|
|
161
|
+
<span class="mint-onboarding__sr">Step {{ active.index + 1 }} of {{ active.count }}</span>
|
|
162
|
+
</span>
|
|
163
|
+
<p :id="titleId" class="mint-onboarding__title">{{ active.step.title }}</p>
|
|
164
|
+
<p class="mint-onboarding__body">{{ active.step.body }}</p>
|
|
165
|
+
<div class="mint-onboarding__footer">
|
|
166
|
+
<span v-if="active.step.advanceOn === 'click'" class="mint-onboarding__hint">Click the highlighted control.</span>
|
|
167
|
+
<BaseButton v-if="active.index > 0" variant="ghost" size="sm" @click="state.back()">Back</BaseButton>
|
|
168
|
+
<BaseButton v-if="active.step.advanceOn !== 'click'" size="sm" @click="state.next()">{{ nextLabel }}</BaseButton>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</Teleport>
|
|
173
|
+
</template>
|