@cat-factory/app 0.201.1 → 0.202.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/README.md +68 -0
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
- package/app/components/tutorial/TutorialOverlay.vue +287 -38
- package/app/components/tutorial/TutorialPrompt.vue +19 -7
- package/app/modular/tutorial-tours.spec.ts +97 -10
- package/app/stores/tutorial.spec.ts +92 -0
- package/app/stores/tutorial.ts +74 -3
- package/app/utils/tutorial.spec.ts +59 -1
- package/app/utils/tutorial.ts +67 -1
- package/i18n/locales/de.json +2 -0
- package/i18n/locales/en.json +8 -0
- package/i18n/locales/es.json +2 -0
- package/i18n/locales/fr.json +2 -0
- package/i18n/locales/he.json +2 -0
- package/i18n/locales/it.json +2 -0
- package/i18n/locales/ja.json +2 -0
- package/i18n/locales/pl.json +2 -0
- package/i18n/locales/tr.json +2 -0
- package/i18n/locales/uk.json +2 -0
- package/package.json +1 -1
|
@@ -1,33 +1,53 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { usePreferredReducedMotion } from '@vueuse/core'
|
|
2
3
|
import {
|
|
3
4
|
computeCoachMarkLayout,
|
|
4
5
|
DEFAULT_TARGET_WAIT_MS,
|
|
6
|
+
needsReveal,
|
|
7
|
+
TARGET_IDLE_INTERVAL_MS,
|
|
5
8
|
TARGET_TRACK_INTERVAL_MS,
|
|
6
9
|
} from '~/utils/tutorial'
|
|
7
10
|
import type { CoachMarkLayout, TutorialRect, TutorialStep, TutorialTour } from '~/utils/tutorial'
|
|
8
11
|
import {
|
|
12
|
+
boardNodeIdFor,
|
|
9
13
|
isTargetClickAdvance,
|
|
10
14
|
resolveSkip,
|
|
15
|
+
shouldFocusCard,
|
|
11
16
|
stepTargetSelectors,
|
|
12
17
|
unexpectedlySkippedSteps,
|
|
13
18
|
waitBudgetMs,
|
|
14
19
|
} from './TutorialOverlay.logic'
|
|
15
|
-
import type { TutorialDirection } from './TutorialOverlay.logic'
|
|
20
|
+
import type { TutorialAdvanceCause, TutorialDirection } from './TutorialOverlay.logic'
|
|
16
21
|
|
|
17
22
|
// The one shared tour runtime: resolves the running tour from the `tutorialTours` slot,
|
|
18
23
|
// anchors a highlight ring + tooltip to the current step's `data-testid`, and advances
|
|
19
24
|
// on Next or on a real click on the highlighted control. Mounted (from `pages/index.vue`)
|
|
20
25
|
// only while `tutorial.touring`, so all the DOM tracking below exists only mid-tour.
|
|
21
26
|
//
|
|
22
|
-
// Anchor tracking is
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
27
|
+
// Anchor tracking is not a one-shot query: board controls move (canvas pan/zoom, panels
|
|
28
|
+
// opening) and appear asynchronously (a step can point INTO the modal the previous step's
|
|
29
|
+
// click opens). So the runtime HUNTS for a not-yet-mounted anchor on a fast poll bounded by
|
|
30
|
+
// the step's wait budget, and then TRACKS the one it found off events (scroll, resize,
|
|
31
|
+
// element resize, board camera) with a slow backstop tick behind them. A target that never
|
|
32
|
+
// appears within the wait SKIPS the step — controls are RBAC/tier/deployment dependent, and
|
|
33
|
+
// a tour is a set of opportunities, not a fixed script.
|
|
34
|
+
//
|
|
35
|
+
// An anchor that is on the page but off SCREEN is revealed before it is pointed at, by
|
|
36
|
+
// whichever mechanism its container understands: the board is a transform-panned canvas
|
|
37
|
+
// (move the camera), everything else is an ordinary scroll container (`scrollIntoView`).
|
|
38
|
+
//
|
|
39
|
+
// Everything that DECIDES rather than measures lives in `TutorialOverlay.logic.ts`, and the
|
|
40
|
+
// geometry in `utils/tutorial.ts`; both are unit-tested, the SFC is not.
|
|
28
41
|
const { t } = useI18n()
|
|
29
42
|
const tutorial = useTutorialStore()
|
|
30
43
|
const { tours } = useTutorialTours()
|
|
44
|
+
const { fitView, viewport } = useBoardFlow()
|
|
45
|
+
// Reduced motion is honoured in BOTH directions here: the CSS below drops the ring's transition
|
|
46
|
+
// and the searching spinner behind `motion-safe:`, and this drives the JS half — an instant
|
|
47
|
+
// scroll and an instant camera move, since a reveal is involuntary motion the user did not ask
|
|
48
|
+
// for and is exactly what the preference is about.
|
|
49
|
+
const reducedMotion = usePreferredReducedMotion()
|
|
50
|
+
const motionMs = computed(() => (reducedMotion.value === 'reduce' ? 0 : 250))
|
|
31
51
|
|
|
32
52
|
/**
|
|
33
53
|
* The running tour's script, resolved ONCE from the slot when the tour starts and then HELD
|
|
@@ -58,18 +78,40 @@ const step = computed<TutorialStep | null>(() => tour.value?.steps[tutorial.step
|
|
|
58
78
|
const total = computed(() => tour.value?.steps.length ?? 0)
|
|
59
79
|
const isLast = computed(() => tour.value !== null && tutorial.stepIndex >= total.value - 1)
|
|
60
80
|
|
|
61
|
-
// The tour could not be resolved when it started (a stale persisted id, or a tour this board
|
|
62
|
-
//
|
|
63
|
-
//
|
|
81
|
+
// The tour could not be resolved when it started (a stale persisted id, or a tour this board is
|
|
82
|
+
// not offered at all): end it instead of rendering a dead overlay, and do NOT leave a resume
|
|
83
|
+
// point behind — it is a position in a script that could not be loaded, so offering it again
|
|
84
|
+
// would put the user straight back here. Since the script is held, this can no longer fire
|
|
85
|
+
// because a gate flipped mid-tour.
|
|
86
|
+
//
|
|
87
|
+
// A resolvable tour whose CURSOR is out of range is a different fact with a different fix: it
|
|
88
|
+
// means a resume landed past the end of a script the gates have thinned since. Rewind to the
|
|
89
|
+
// start rather than ending, or breaking off a tour would cost the user the tour itself.
|
|
64
90
|
watch(
|
|
65
91
|
() => [tour.value, step.value] as const,
|
|
66
92
|
([tr, st]) => {
|
|
67
|
-
if (tutorial.touring
|
|
93
|
+
if (!tutorial.touring) return
|
|
94
|
+
if (!tr) tutorial.stopTour({ resumable: false })
|
|
95
|
+
else if (!st) tutorial.setStepIndex(0)
|
|
68
96
|
},
|
|
69
97
|
{ immediate: true },
|
|
70
98
|
)
|
|
71
99
|
|
|
72
100
|
const targetRect = ref<TutorialRect | null>(null)
|
|
101
|
+
/**
|
|
102
|
+
* The resolved anchor, held between ticks. Event-driven re-measures (scroll, resize, canvas
|
|
103
|
+
* pan) reuse it rather than re-running the selector, and the slow backstop tick re-resolves —
|
|
104
|
+
* so a step still re-anchors when its control is replaced underneath it, without paying for a
|
|
105
|
+
* document query several times a second for the whole length of the tour.
|
|
106
|
+
*/
|
|
107
|
+
const anchorEl = ref<HTMLElement | null>(null)
|
|
108
|
+
/**
|
|
109
|
+
* The step index whose anchor has already been brought into view. A reveal is attempted at
|
|
110
|
+
* most ONCE per step: `fitView` and `scrollIntoView` are animations that take longer than a
|
|
111
|
+
* tracking tick, so re-deciding each tick would re-issue the move against a viewport still
|
|
112
|
+
* mid-flight and fight the user the moment they panned away deliberately.
|
|
113
|
+
*/
|
|
114
|
+
const revealedForStep = ref<number | null>(null)
|
|
73
115
|
const cardEl = ref<HTMLElement | null>(null)
|
|
74
116
|
const layout = ref<CoachMarkLayout>({ top: -9999, left: -9999, placement: 'center' })
|
|
75
117
|
|
|
@@ -92,23 +134,73 @@ const unexpectedSkips = computed(() =>
|
|
|
92
134
|
)
|
|
93
135
|
const abridged = computed(() => unexpectedSkips.value.length > 0)
|
|
94
136
|
|
|
95
|
-
|
|
137
|
+
/**
|
|
138
|
+
* The browser viewport. Named apart from Vue Flow's `viewport` (the board CAMERA) above —
|
|
139
|
+
* and not `screen`, which would shadow the DOM global of that name for the whole component.
|
|
140
|
+
*/
|
|
141
|
+
const screenSize = () => ({ width: window.innerWidth, height: window.innerHeight })
|
|
96
142
|
/** The tooltip's own size, or a sensible guess before it has rendered once. */
|
|
97
143
|
const cardSize = () => ({
|
|
98
144
|
width: cardEl.value?.offsetWidth ?? 320,
|
|
99
145
|
height: cardEl.value?.offsetHeight ?? 180,
|
|
100
146
|
})
|
|
101
147
|
|
|
148
|
+
/** Is this element still in the document and still rendering a box? */
|
|
149
|
+
function isUsable(el: HTMLElement | null): el is HTMLElement {
|
|
150
|
+
// `getClientRects().length` distinguishes a mounted-but-hidden control (display:none
|
|
151
|
+
// drawer item) from a visible one; pointing at an invisible control helps nobody.
|
|
152
|
+
return el !== null && el.isConnected && el.getClientRects().length > 0
|
|
153
|
+
}
|
|
154
|
+
|
|
102
155
|
function queryTarget(s: TutorialStep): HTMLElement | null {
|
|
103
156
|
for (const selector of stepTargetSelectors(s)) {
|
|
104
157
|
const el = document.querySelector<HTMLElement>(selector)
|
|
105
|
-
|
|
106
|
-
// drawer item) from a visible one; pointing at an invisible control helps nobody.
|
|
107
|
-
if (el && el.getClientRects().length > 0) return el
|
|
158
|
+
if (isUsable(el)) return el
|
|
108
159
|
}
|
|
109
160
|
return null
|
|
110
161
|
}
|
|
111
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The anchor for this step. Reuses the held element unless `requery` is set or it has gone
|
|
165
|
+
* stale (unmounted, or hidden) — the two cases where it no longer describes anything on screen.
|
|
166
|
+
*/
|
|
167
|
+
function resolveAnchor(s: TutorialStep, requery: boolean): HTMLElement | null {
|
|
168
|
+
if (!requery && isUsable(anchorEl.value)) return anchorEl.value
|
|
169
|
+
anchorEl.value = queryTarget(s)
|
|
170
|
+
return anchorEl.value
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Bring an off-screen anchor into view, by whichever mechanism its container understands.
|
|
175
|
+
*
|
|
176
|
+
* The board is a transform-panned Vue Flow canvas, so a card that is off screen is not
|
|
177
|
+
* SCROLLED away and `scrollIntoView` on it does nothing at all; the camera has to move
|
|
178
|
+
* instead. Everything else — a panel's scroll container, a long modal body, the sidebar —
|
|
179
|
+
* is an ordinary scroll and `scrollIntoView` is exactly right.
|
|
180
|
+
*
|
|
181
|
+
* The camera move is clamped to the CURRENT zoom (`fitView` would otherwise zoom to fit a
|
|
182
|
+
* single small node, throwing away the user's own view of their board to point at a button).
|
|
183
|
+
*/
|
|
184
|
+
function revealAnchor(el: HTMLElement) {
|
|
185
|
+
const nodeId = boardNodeIdFor(el)
|
|
186
|
+
if (nodeId !== null) {
|
|
187
|
+
const zoom = viewport.value.zoom
|
|
188
|
+
fitView({
|
|
189
|
+
nodes: [nodeId],
|
|
190
|
+
padding: 0.3,
|
|
191
|
+
minZoom: zoom,
|
|
192
|
+
maxZoom: zoom,
|
|
193
|
+
duration: motionMs.value,
|
|
194
|
+
})
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
el.scrollIntoView({
|
|
198
|
+
behavior: motionMs.value === 0 ? 'auto' : 'smooth',
|
|
199
|
+
block: 'center',
|
|
200
|
+
inline: 'center',
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
112
204
|
/** Give up on a step whose anchor never appeared, continuing the user's own direction. */
|
|
113
205
|
function skipMissingStep(s: TutorialStep) {
|
|
114
206
|
skippedStepIds.value = new Set(skippedStepIds.value).add(s.id)
|
|
@@ -117,28 +209,44 @@ function skipMissingStep(s: TutorialStep) {
|
|
|
117
209
|
else tutorial.setStepIndex(outcome.index)
|
|
118
210
|
}
|
|
119
211
|
|
|
120
|
-
function measure() {
|
|
212
|
+
function measure(options?: { requery?: boolean }) {
|
|
121
213
|
const s = step.value
|
|
122
214
|
if (!s) return
|
|
123
215
|
if (!s.target) {
|
|
124
216
|
targetRect.value = null
|
|
125
217
|
} else {
|
|
126
|
-
const el =
|
|
218
|
+
const el = resolveAnchor(s, options?.requery === true)
|
|
127
219
|
if (!el) {
|
|
128
220
|
targetRect.value = null
|
|
129
221
|
// Centered while searching: the card must not sit at the PREVIOUS step's anchor —
|
|
130
222
|
// nor at its off-screen initial position — pointing at nothing.
|
|
131
|
-
layout.value = computeCoachMarkLayout(null, cardSize(),
|
|
223
|
+
layout.value = computeCoachMarkLayout(null, cardSize(), screenSize())
|
|
132
224
|
if (performance.now() >= searchDeadline.value) skipMissingStep(s)
|
|
133
225
|
return
|
|
134
226
|
}
|
|
135
227
|
const r = el.getBoundingClientRect()
|
|
136
|
-
|
|
228
|
+
const rect = { top: r.top, left: r.left, width: r.width, height: r.height }
|
|
229
|
+
// Reveal BEFORE publishing the rect, so the ring and tooltip are placed from the
|
|
230
|
+
// post-move position on the next tick rather than flashing at the off-screen one.
|
|
231
|
+
if (revealedForStep.value !== tutorial.stepIndex && needsReveal(rect, screenSize())) {
|
|
232
|
+
revealedForStep.value = tutorial.stepIndex
|
|
233
|
+
revealAnchor(el)
|
|
234
|
+
// Centre the card for the same reason the searching branch above does, and it is the
|
|
235
|
+
// same failure: returning without touching `layout` would render THIS step's copy at
|
|
236
|
+
// the PREVIOUS step's coordinates, pointing at a control the user has already left.
|
|
237
|
+
// Usually one frame, since the move emits scroll/camera events that re-enter here —
|
|
238
|
+
// but a reveal that moves nothing (a `fitView` over a node the canvas has dropped)
|
|
239
|
+
// emits none at all, and then it is the whole backstop tick.
|
|
240
|
+
layout.value = computeCoachMarkLayout(null, cardSize(), screenSize())
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
revealedForStep.value = tutorial.stepIndex
|
|
244
|
+
targetRect.value = rect
|
|
137
245
|
}
|
|
138
246
|
layout.value = computeCoachMarkLayout(
|
|
139
247
|
s.target ? targetRect.value : null,
|
|
140
248
|
cardSize(),
|
|
141
|
-
|
|
249
|
+
screenSize(),
|
|
142
250
|
s.placement,
|
|
143
251
|
)
|
|
144
252
|
}
|
|
@@ -149,19 +257,42 @@ function measure() {
|
|
|
149
257
|
watch(step, async (s) => {
|
|
150
258
|
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
151
259
|
targetRect.value = null
|
|
260
|
+
anchorEl.value = null
|
|
261
|
+
revealedForStep.value = null
|
|
152
262
|
await nextTick()
|
|
153
|
-
measure()
|
|
263
|
+
measure({ requery: true })
|
|
154
264
|
})
|
|
155
265
|
|
|
156
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Put focus on the tooltip so the tour's own controls are one Tab away. WHETHER to do that is
|
|
268
|
+
* `shouldFocusCard`'s call, in the logic module, so it is pinned by a test — this function is
|
|
269
|
+
* only the DOM half. `preventScroll` because the card is already placed.
|
|
270
|
+
*/
|
|
271
|
+
async function focusCard(cause: TutorialAdvanceCause) {
|
|
272
|
+
if (!shouldFocusCard(cause)) return
|
|
273
|
+
await nextTick()
|
|
274
|
+
cardEl.value?.focus({ preventScroll: true })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Move the cursor forward. The cause is REQUIRED rather than defaulted: it decides whether
|
|
279
|
+
* focus moves, and a new call site inheriting a default silently is exactly how a
|
|
280
|
+
* `target-click` advance came to steal focus from the modal it had just opened.
|
|
281
|
+
*/
|
|
282
|
+
function advance(cause: TutorialAdvanceCause) {
|
|
157
283
|
direction.value = 'forward'
|
|
158
|
-
if (isLast.value)
|
|
159
|
-
|
|
284
|
+
if (isLast.value) {
|
|
285
|
+
tutorial.completeTour()
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
tutorial.setStepIndex(tutorial.stepIndex + 1)
|
|
289
|
+
void focusCard(cause)
|
|
160
290
|
}
|
|
161
291
|
|
|
162
292
|
function back() {
|
|
163
293
|
direction.value = 'back'
|
|
164
294
|
tutorial.setStepIndex(tutorial.stepIndex - 1)
|
|
295
|
+
void focusCard('nav-control')
|
|
165
296
|
}
|
|
166
297
|
|
|
167
298
|
// "Now click this" steps: watch real clicks (capture phase, so a stopPropagation inside a
|
|
@@ -169,30 +300,129 @@ function back() {
|
|
|
169
300
|
// the real handler open its modal/submit its form before the tour moves its anchor.
|
|
170
301
|
function onDocumentClick(event: MouseEvent) {
|
|
171
302
|
if (isTargetClickAdvance(step.value, event.target)) {
|
|
172
|
-
window.setTimeout(advance, 0)
|
|
303
|
+
window.setTimeout(() => advance('target-click'), 0)
|
|
173
304
|
}
|
|
174
305
|
}
|
|
175
306
|
|
|
176
|
-
/**
|
|
307
|
+
/**
|
|
308
|
+
* Esc ends the tour, matching every other dismissible surface in the app — and, like Skip,
|
|
309
|
+
* leaves a resume point, because it is by far the easiest key to hit by accident and the
|
|
310
|
+
* position it discards is the entire walkthrough.
|
|
311
|
+
*/
|
|
177
312
|
function onKeydown(event: KeyboardEvent) {
|
|
178
313
|
if (event.key === 'Escape') tutorial.stopTour()
|
|
179
314
|
}
|
|
180
315
|
|
|
181
|
-
|
|
316
|
+
/**
|
|
317
|
+
* What a screen reader should be told about the current step.
|
|
318
|
+
*
|
|
319
|
+
* A dedicated `role="status"` region rather than `aria-live` on the card itself: the card is a
|
|
320
|
+
* `dialog` whose entire contents are replaced per step, and assistive tech does not reliably
|
|
321
|
+
* announce a wholesale subtree replacement inside a dialog. One key with placeholders, not
|
|
322
|
+
* concatenated fragments, per the i18n rules.
|
|
323
|
+
*
|
|
324
|
+
* This is also the SOLE announcement of the step — the card carries no `aria-describedby`,
|
|
325
|
+
* which would have the body read a second time on every focus move.
|
|
326
|
+
*/
|
|
327
|
+
const announcementText = computed(() =>
|
|
328
|
+
step.value
|
|
329
|
+
? t('tutorial.overlay.announcement', {
|
|
330
|
+
current: tutorial.stepIndex + 1,
|
|
331
|
+
total: total.value,
|
|
332
|
+
title: t(step.value.titleKey),
|
|
333
|
+
body: t(step.value.bodyKey, step.value.bodyParams ?? {}),
|
|
334
|
+
})
|
|
335
|
+
: '',
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* What the live region actually holds, lagging {@link announcementText} by a tick.
|
|
340
|
+
*
|
|
341
|
+
* Assistive tech announces a CHANGE to a live region, and routinely says nothing at all about
|
|
342
|
+
* one that was INSERTED already populated — the same unreliability that moved this out of the
|
|
343
|
+
* card in the first place. The overlay mounts with a step already resolved, so a region bound
|
|
344
|
+
* straight to the text above would arrive full and go unread, silently costing the first step
|
|
345
|
+
* of every tour. Publishing a tick later guarantees the empty region is in the DOM first, so
|
|
346
|
+
* the text is always a change to an existing node.
|
|
347
|
+
*/
|
|
348
|
+
const announcement = ref('')
|
|
349
|
+
watch(
|
|
350
|
+
announcementText,
|
|
351
|
+
async (text) => {
|
|
352
|
+
await nextTick()
|
|
353
|
+
announcement.value = text
|
|
354
|
+
},
|
|
355
|
+
{ immediate: true },
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
// Anchor tracking is EVENT-DRIVEN once an anchor is held, with a slow backstop tick behind it;
|
|
359
|
+
// only the hunt for a not-yet-mounted anchor polls fast, and that is bounded by the step's own
|
|
360
|
+
// wait budget. The events below are the ways a control that is already on screen can move:
|
|
361
|
+
// something scrolled (capture phase, so it catches every scroll container, not just the
|
|
362
|
+
// window), the window resized, the control itself resized, or the board camera panned/zoomed.
|
|
363
|
+
/**
|
|
364
|
+
* Re-measure from the held anchor — the cheap path every motion event takes — coalesced to at
|
|
365
|
+
* most once per frame.
|
|
366
|
+
*
|
|
367
|
+
* `measure()` READS layout (`getBoundingClientRect`, the card's offset size) and then WRITES
|
|
368
|
+
* it (the ring and tooltip styles), so running it per event thrashes layout. Capture-phase
|
|
369
|
+
* scroll is the one that makes this bite: it fires for every scroll container on the page and
|
|
370
|
+
* many times a frame under a momentum scroll, where the poll it replaced ran every 150 ms. A
|
|
371
|
+
* frame is also the most the user can see, so nothing is lost by waiting for one.
|
|
372
|
+
*/
|
|
373
|
+
let frameHandle = 0
|
|
374
|
+
function remeasure() {
|
|
375
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
376
|
+
measure()
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (frameHandle !== 0) return
|
|
380
|
+
frameHandle = requestAnimationFrame(() => {
|
|
381
|
+
frameHandle = 0
|
|
382
|
+
measure()
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let trackTimer: ReturnType<typeof setTimeout> | undefined
|
|
387
|
+
/** Re-resolve the selector on a cadence set by whether we currently HAVE an anchor. */
|
|
388
|
+
function scheduleTrack() {
|
|
389
|
+
const delay = targetRect.value ? TARGET_IDLE_INTERVAL_MS : TARGET_TRACK_INTERVAL_MS
|
|
390
|
+
trackTimer = setTimeout(() => {
|
|
391
|
+
measure({ requery: true })
|
|
392
|
+
scheduleTrack()
|
|
393
|
+
}, delay)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Follows the held anchor's own size changes (a card growing as its run progresses). */
|
|
397
|
+
const anchorResize =
|
|
398
|
+
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => remeasure())
|
|
399
|
+
watch(anchorEl, (el) => {
|
|
400
|
+
anchorResize?.disconnect()
|
|
401
|
+
if (el) anchorResize?.observe(el)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
// The board camera: a pan or zoom moves every canvas anchor without any scroll event at all.
|
|
405
|
+
watch(() => [viewport.value.x, viewport.value.y, viewport.value.zoom], remeasure)
|
|
406
|
+
|
|
182
407
|
onMounted(() => {
|
|
183
408
|
const s = step.value
|
|
184
409
|
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
185
410
|
document.addEventListener('click', onDocumentClick, true)
|
|
186
411
|
document.addEventListener('keydown', onKeydown)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
412
|
+
document.addEventListener('scroll', remeasure, { capture: true, passive: true })
|
|
413
|
+
window.addEventListener('resize', remeasure)
|
|
414
|
+
scheduleTrack()
|
|
415
|
+
measure({ requery: true })
|
|
416
|
+
void focusCard('tour-start')
|
|
190
417
|
})
|
|
191
418
|
onUnmounted(() => {
|
|
192
419
|
document.removeEventListener('click', onDocumentClick, true)
|
|
193
420
|
document.removeEventListener('keydown', onKeydown)
|
|
194
|
-
|
|
195
|
-
|
|
421
|
+
document.removeEventListener('scroll', remeasure, true)
|
|
422
|
+
window.removeEventListener('resize', remeasure)
|
|
423
|
+
anchorResize?.disconnect()
|
|
424
|
+
if (trackTimer !== undefined) clearTimeout(trackTimer)
|
|
425
|
+
if (frameHandle !== 0) cancelAnimationFrame(frameHandle)
|
|
196
426
|
})
|
|
197
427
|
</script>
|
|
198
428
|
|
|
@@ -200,10 +430,20 @@ onUnmounted(() => {
|
|
|
200
430
|
<!-- Teleported so board/panel stacking contexts can't clip the marks; z-[70] sits above
|
|
201
431
|
the app's modals (z-50s), since steps legitimately point INTO an open modal. -->
|
|
202
432
|
<Teleport to="body">
|
|
433
|
+
<!-- The step-change announcement. Visually hidden, and outside BOTH the dialog and the
|
|
434
|
+
`v-if` below, so the live region is a stable node whose TEXT changes for the whole
|
|
435
|
+
life of the overlay — never a subtree replaced wholesale, and never one inserted with
|
|
436
|
+
its content already in place. Assistive tech announces neither of those reliably. The
|
|
437
|
+
text itself also lands a tick after the node does; see `announcement`. -->
|
|
438
|
+
<div class="sr-only" role="status" aria-live="polite" data-testid="tutorial-announcement">
|
|
439
|
+
{{ announcement }}
|
|
440
|
+
</div>
|
|
203
441
|
<div v-if="step" data-testid="tutorial-overlay">
|
|
442
|
+
<!-- `motion-safe:` on the ring's transition: it slides between controls on every step,
|
|
443
|
+
which is exactly the involuntary movement `prefers-reduced-motion` is about. -->
|
|
204
444
|
<div
|
|
205
445
|
v-if="targetRect"
|
|
206
|
-
class="ring-primary-400 outline-primary-400/25 pointer-events-none fixed z-[70] rounded-lg outline-4 ring-2 transition-all duration-150"
|
|
446
|
+
class="ring-primary-400 outline-primary-400/25 pointer-events-none fixed z-[70] rounded-lg outline-4 ring-2 motion-safe:transition-all motion-safe:duration-150"
|
|
207
447
|
:style="{
|
|
208
448
|
top: `${targetRect.top - 4}px`,
|
|
209
449
|
left: `${targetRect.left - 4}px`,
|
|
@@ -217,12 +457,19 @@ onUnmounted(() => {
|
|
|
217
457
|
layer, which sets `body { pointer-events: none }` (leaving this card inert) and
|
|
218
458
|
dismisses on a document-level pointerdown outside its own content (so a press on
|
|
219
459
|
this card would close the user's half-filled form instead of pressing a button). -->
|
|
460
|
+
<!-- `tabindex="-1"` so `focusCard()` can put focus here when the tour starts and on every
|
|
461
|
+
Next/Back — without it a keyboard user has to tab the whole page to reach Next, since
|
|
462
|
+
this is teleported to the end of `body`. No `aria-modal`: a coach mark is NOT modal,
|
|
463
|
+
and half the catalog asks the user to operate the real control behind it. No
|
|
464
|
+
`aria-describedby` on the body either: the live region above already reads the body
|
|
465
|
+
as part of a complete announcement, and pointing at it here would have every focus
|
|
466
|
+
move read it a second time. -->
|
|
220
467
|
<div
|
|
221
468
|
ref="cardEl"
|
|
222
469
|
role="dialog"
|
|
223
|
-
|
|
470
|
+
tabindex="-1"
|
|
224
471
|
:aria-label="t('tutorial.overlay.ariaLabel')"
|
|
225
|
-
class="pointer-events-auto fixed z-[70] w-80 max-w-[calc(100vw-16px)] rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-2xl"
|
|
472
|
+
class="pointer-events-auto fixed z-[70] w-80 max-w-[calc(100vw-16px)] rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-2xl outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
|
226
473
|
:style="{ top: `${layout.top}px`, left: `${layout.left}px` }"
|
|
227
474
|
data-testid="tutorial-tooltip"
|
|
228
475
|
@pointerdown.stop
|
|
@@ -236,13 +483,15 @@ onUnmounted(() => {
|
|
|
236
483
|
<!-- `bodyParams` carries the fixed proper nouns a step names (a repository slug),
|
|
237
484
|
which live in the catalog's `{named}` placeholders rather than in nine
|
|
238
485
|
translations of the same literal. Absent for most steps. -->
|
|
239
|
-
<p class="text-sm text-slate-300">
|
|
486
|
+
<p class="text-sm text-slate-300">
|
|
487
|
+
{{ t(step.bodyKey, step.bodyParams ?? {}) }}
|
|
488
|
+
</p>
|
|
240
489
|
<p
|
|
241
490
|
v-if="searching"
|
|
242
491
|
class="mt-2 flex items-center gap-1.5 text-xs text-slate-400"
|
|
243
492
|
data-testid="tutorial-searching"
|
|
244
493
|
>
|
|
245
|
-
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
|
|
494
|
+
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 motion-safe:animate-spin" />
|
|
246
495
|
{{ t('tutorial.overlay.searching') }}
|
|
247
496
|
</p>
|
|
248
497
|
<p
|
|
@@ -295,7 +544,7 @@ onUnmounted(() => {
|
|
|
295
544
|
size="xs"
|
|
296
545
|
color="primary"
|
|
297
546
|
data-testid="tutorial-next"
|
|
298
|
-
@click="advance()"
|
|
547
|
+
@click="advance('nav-control')"
|
|
299
548
|
>
|
|
300
549
|
{{ isLast ? t('tutorial.overlay.done') : t('tutorial.overlay.next') }}
|
|
301
550
|
</UButton>
|
|
@@ -18,6 +18,22 @@ const open = computed({
|
|
|
18
18
|
// Only an unanswered prompt offers the persistent "No thanks"; once a decision exists this
|
|
19
19
|
// is just a picker, and the only dismissal left is a plain close.
|
|
20
20
|
const undecided = computed(() => tutorial.decision === null)
|
|
21
|
+
|
|
22
|
+
// A tour broken off mid-way (Esc, or Skip to get the overlay out of the way) offers to RESUME
|
|
23
|
+
// where it stopped rather than only to start over. Session-scoped, so this is only ever offered
|
|
24
|
+
// while the board is still in the state the tour left it in — see the store.
|
|
25
|
+
const isResumable = (tourId: string) => tutorial.interruptedAt(tourId) !== null
|
|
26
|
+
|
|
27
|
+
function launch(tourId: string) {
|
|
28
|
+
if (isResumable(tourId)) tutorial.resumeTour(tourId)
|
|
29
|
+
else tutorial.startTour(tourId)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resume beats Completed: a tour taken again and broken off is offered where it stopped. */
|
|
33
|
+
function launchLabel(tourId: string): string {
|
|
34
|
+
if (isResumable(tourId)) return t('tutorial.prompt.resume')
|
|
35
|
+
return tutorial.isCompleted(tourId) ? t('tutorial.prompt.restart') : t('tutorial.prompt.start')
|
|
36
|
+
}
|
|
21
37
|
</script>
|
|
22
38
|
|
|
23
39
|
<template>
|
|
@@ -57,15 +73,11 @@ const undecided = computed(() => tutorial.decision === null)
|
|
|
57
73
|
<UButton
|
|
58
74
|
size="sm"
|
|
59
75
|
color="primary"
|
|
60
|
-
:variant="tutorial.isCompleted(tour.id) ? 'soft' : 'solid'"
|
|
76
|
+
:variant="tutorial.isCompleted(tour.id) && !isResumable(tour.id) ? 'soft' : 'solid'"
|
|
61
77
|
:data-testid="`tutorial-start-${tour.id}`"
|
|
62
|
-
@click="
|
|
78
|
+
@click="launch(tour.id)"
|
|
63
79
|
>
|
|
64
|
-
{{
|
|
65
|
-
tutorial.isCompleted(tour.id)
|
|
66
|
-
? t('tutorial.prompt.restart')
|
|
67
|
-
: t('tutorial.prompt.start')
|
|
68
|
-
}}
|
|
80
|
+
{{ launchLabel(tour.id) }}
|
|
69
81
|
</UButton>
|
|
70
82
|
</li>
|
|
71
83
|
</ul>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
1
3
|
import { describe, expect, it } from 'vitest'
|
|
2
4
|
import en from '../../i18n/locales/en.json'
|
|
3
5
|
import { TUTORIAL_TOURS, tutorialToursModule } from '~/modular/tutorial-tours'
|
|
@@ -51,6 +53,72 @@ function lookupKey(key: string): unknown {
|
|
|
51
53
|
.reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], en)
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The layer's srcDir. Anchored on the package directory rather than on `import.meta.url`,
|
|
58
|
+
* which under the happy-dom test environment is not a `file:` URL at all.
|
|
59
|
+
*/
|
|
60
|
+
const SRC_DIR = join(process.cwd(), 'app')
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The two ways this layer names a test id, both of which a tour may legitimately anchor on.
|
|
64
|
+
*
|
|
65
|
+
* - written straight onto an element, in either quoting style, including the bound form
|
|
66
|
+
* (`:data-testid="'foo'"`);
|
|
67
|
+
* - declared as a `testId` field on a DATA contribution — the nav catalog's items carry one
|
|
68
|
+
* and `SideBar.vue` renders it as `:data-testid="item.testId"`, which is how the whole
|
|
69
|
+
* `nav-*` family (`nav-add-from-repo` among them) reaches the DOM.
|
|
70
|
+
*
|
|
71
|
+
* A template literal (`` `tutorial-start-${id}` ``) matches neither, which is correct: that is
|
|
72
|
+
* not an id, it is a family of them, and no built-in step anchors on one.
|
|
73
|
+
*/
|
|
74
|
+
const ID_PATTERNS = [
|
|
75
|
+
/data-testid\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
|
|
76
|
+
/\btestId\s*:\s*(?:'([^']*)'|"([^"]*)")/g,
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every `.vue`/`.ts` file the layer SHIPS. Test sources are excluded in both spellings: an id
|
|
81
|
+
* that exists only in a spec or a fixture is not rendered by anything, so counting one would
|
|
82
|
+
* let the guard pass on a tour anchored to a control that no longer exists.
|
|
83
|
+
*/
|
|
84
|
+
function walk(dir: string): string[] {
|
|
85
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
86
|
+
const path = join(dir, entry.name)
|
|
87
|
+
if (entry.isDirectory()) return walk(path)
|
|
88
|
+
return /\.(vue|ts)$/.test(entry.name) && !/\.(spec|test)\.ts$/.test(entry.name) ? [path] : []
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every anchor the built-in catalog declares, labelled with the step that declares it. */
|
|
93
|
+
function declaredAnchors(): { label: string; id: string }[] {
|
|
94
|
+
const out: { label: string; id: string }[] = []
|
|
95
|
+
for (const tour of TUTORIAL_TOURS) {
|
|
96
|
+
for (const s of tour.steps) {
|
|
97
|
+
for (const id of [s.target, ...(s.altTargets ?? [])]) {
|
|
98
|
+
if (id !== undefined) out.push({ label: `${tour.id}/${s.id}: ${id}`, id })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every static test id this layer actually renders. */
|
|
106
|
+
function renderedTestIds(): Set<string> {
|
|
107
|
+
const ids = new Set<string>()
|
|
108
|
+
for (const file of walk(SRC_DIR)) {
|
|
109
|
+
// The catalog itself declares the ids under test; counting it would make this vacuous.
|
|
110
|
+
if (file.endsWith(join('modular', 'tutorial-tours.ts'))) continue
|
|
111
|
+
const source = readFileSync(file, 'utf8')
|
|
112
|
+
for (const pattern of ID_PATTERNS) {
|
|
113
|
+
for (const match of source.matchAll(pattern)) {
|
|
114
|
+
const raw = (match[1] ?? match[2] ?? '').trim().replace(/^['"]|['"]$/g, '')
|
|
115
|
+
if (isSafeTargetId(raw)) ids.add(raw)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return ids
|
|
120
|
+
}
|
|
121
|
+
|
|
54
122
|
describe('the built-in tutorial tour catalog', () => {
|
|
55
123
|
it('has unique tour ids and unique step ids within each tour', () => {
|
|
56
124
|
const tourIds = TUTORIAL_TOURS.map((t) => t.id)
|
|
@@ -92,19 +160,38 @@ describe('the built-in tutorial tour catalog', () => {
|
|
|
92
160
|
})
|
|
93
161
|
|
|
94
162
|
it('names plain data-testid values as targets, never selectors', () => {
|
|
95
|
-
for (const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// drops an id this rejects, so a built-in tour that tripped it would silently
|
|
101
|
-
// lose the step rather than fail here.
|
|
102
|
-
expect(isSafeTargetId(target), `${tour.id}/${s.id}: ${target}`).toBe(true)
|
|
103
|
-
}
|
|
104
|
-
}
|
|
163
|
+
for (const anchor of declaredAnchors()) {
|
|
164
|
+
// Asserted through the runtime's OWN guard, not a copy of its regex: the overlay
|
|
165
|
+
// drops an id this rejects, so a built-in tour that tripped it would silently
|
|
166
|
+
// lose the step rather than fail here.
|
|
167
|
+
expect(isSafeTargetId(anchor.id), anchor.label).toBe(true)
|
|
105
168
|
}
|
|
106
169
|
})
|
|
107
170
|
|
|
171
|
+
it('anchors every step on a data-testid this layer actually renders', () => {
|
|
172
|
+
// The drift guard. A tour's anchors are the ONE thing about it that nothing else in the
|
|
173
|
+
// build checks: a renamed `data-testid` passes typecheck, lint and the whole e2e suite,
|
|
174
|
+
// and five of the ids below (`nav-add-from-repo`, `add-service-repo-search`,
|
|
175
|
+
// `add-service-submit`, `pipeline-picker-trigger`, `inspector-merge-pr`) have no other
|
|
176
|
+
// consumer at all — the tour is the only thing that names them.
|
|
177
|
+
//
|
|
178
|
+
// The failure it prevents is worse than a dead step. None of those steps carries a `when`,
|
|
179
|
+
// so `unexpectedlySkippedSteps` counts the miss and EVERY user who takes that tour lands on
|
|
180
|
+
// a permanent "you missed N steps" notice: the tour would go on making a false claim about
|
|
181
|
+
// itself, in production, with nothing red anywhere.
|
|
182
|
+
//
|
|
183
|
+
// Scoped to the built-in catalog on purpose — a consumer deployment's tours anchor on
|
|
184
|
+
// controls that live in ITS layer, which this repo cannot see and must not fail over.
|
|
185
|
+
const rendered = renderedTestIds()
|
|
186
|
+
// Guard the guard: a scan that silently matched nothing would pass every assertion below.
|
|
187
|
+
expect(rendered.size).toBeGreaterThan(100)
|
|
188
|
+
|
|
189
|
+
const missing = declaredAnchors()
|
|
190
|
+
.filter((anchor) => !rendered.has(anchor.id))
|
|
191
|
+
.map((anchor) => anchor.label)
|
|
192
|
+
expect(missing).toEqual([])
|
|
193
|
+
})
|
|
194
|
+
|
|
108
195
|
it('is contributed to the tutorialTours slot by the module', () => {
|
|
109
196
|
expect(tutorialToursModule.slots?.tutorialTours).toEqual([...TUTORIAL_TOURS])
|
|
110
197
|
})
|