@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,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>