@tekkare/romulus 0.1.148 → 0.1.150
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/module.json +1 -1
- package/dist/runtime/components/SidebarRight.vue +4 -0
- package/dist/runtime/components/Tour.vue +413 -0
- package/dist/runtime/components/Tutorials.vue +130 -0
- package/dist/runtime/utils/tours.d.ts +45 -0
- package/dist/runtime/utils/tours.js +0 -0
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -192,12 +192,16 @@ onBeforeUnmount(() => {
|
|
|
192
192
|
<span class="rm-rail__caption">{{ profileLabel }}</span>
|
|
193
193
|
</NuxtLink>
|
|
194
194
|
|
|
195
|
+
<!-- La cle de la tuile sort aussi en attribut : une visite guidee designe
|
|
196
|
+
une commande par selecteur (`[data-tile="Views"]`), et le rail est le
|
|
197
|
+
seul endroit qui sait quelle tuile porte quelle cle. -->
|
|
195
198
|
<button
|
|
196
199
|
v-for="tile in tiles"
|
|
197
200
|
:key="tile.key"
|
|
198
201
|
class="rm-rail__item"
|
|
199
202
|
:class="{ 'rm-rail__item--active': active === tile.key }"
|
|
200
203
|
type="button"
|
|
204
|
+
:data-tile="tile.key"
|
|
201
205
|
:title="tileTitle(tile)"
|
|
202
206
|
:aria-pressed="active === tile.key"
|
|
203
207
|
@click="emit('select', tile.key)"
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmTour - la visite guidee : le moteur qui montre une partie de l'ecran et
|
|
3
|
+
explique ce qu'elle fait.
|
|
4
|
+
|
|
5
|
+
Une note explique une page, une visite explique un geste. Les deux repondent
|
|
6
|
+
a des questions differentes : « d'ou vient ce chiffre » se lit dans
|
|
7
|
+
`RmNotes`, « comment j'enregistre mes filtres » ne se lit nulle part, parce
|
|
8
|
+
que la reponse tient dans un enchainement de trois commandes posees a trois
|
|
9
|
+
endroits du cadre. Le texte seul obligerait le lecteur a les retrouver ; la
|
|
10
|
+
visite les designe.
|
|
11
|
+
|
|
12
|
+
Le composant ne connait ni l'app ni ses ecrans. Une etape porte un selecteur
|
|
13
|
+
CSS, et c'est tout ce qui la relie a la page : le moteur cherche le noeud, le
|
|
14
|
+
detoure, pose sa bulle a cote et laisse l'appelant decider du reste. Une
|
|
15
|
+
etape peut donc viser le cadre (une tuile du rail) comme le contenu (une
|
|
16
|
+
carte), sans que le moteur ait a distinguer les deux.
|
|
17
|
+
|
|
18
|
+
L'appelant garde l'index de l'etape (`v-model:step`) : c'est lui qui sait ce
|
|
19
|
+
qu'il faut ouvrir pour qu'une cible existe — un panneau replie n'a pas de
|
|
20
|
+
noeud a detourer. Le moteur, lui, attend : tant que le selecteur ne rend
|
|
21
|
+
rien, la bulle reste centree et reprend sa place des que la cible parait.
|
|
22
|
+
-->
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
25
|
+
import type { RmTourStep } from '../utils/tours'
|
|
26
|
+
|
|
27
|
+
export type { RmTourStep }
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(defineProps<{
|
|
30
|
+
steps: RmTourStep[]
|
|
31
|
+
open?: boolean
|
|
32
|
+
/** Nom de la visite, rappele au-dessus du titre de l'etape. */
|
|
33
|
+
title?: string
|
|
34
|
+
lang?: 'fr' | 'en'
|
|
35
|
+
}>(), {
|
|
36
|
+
open: false,
|
|
37
|
+
title: undefined,
|
|
38
|
+
lang: 'fr',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** Index de l'etape affichee. Tenu par l'appelant : cf. l'en-tete. */
|
|
42
|
+
const step = defineModel<number>('step', { default: 0 })
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{
|
|
45
|
+
/** Visite quittee avant la fin (croix, Echap, « Quitter »). */
|
|
46
|
+
close: []
|
|
47
|
+
/** Derniere etape validee. */
|
|
48
|
+
finish: []
|
|
49
|
+
}>()
|
|
50
|
+
|
|
51
|
+
const WORDS = {
|
|
52
|
+
fr: {
|
|
53
|
+
prev: 'Précédent',
|
|
54
|
+
next: 'Suivant',
|
|
55
|
+
finish: 'Terminer',
|
|
56
|
+
quit: 'Quitter la visite',
|
|
57
|
+
step: 'Étape',
|
|
58
|
+
of: 'sur',
|
|
59
|
+
},
|
|
60
|
+
en: {
|
|
61
|
+
prev: 'Back',
|
|
62
|
+
next: 'Next',
|
|
63
|
+
finish: 'Done',
|
|
64
|
+
quit: 'Leave the tour',
|
|
65
|
+
step: 'Step',
|
|
66
|
+
of: 'of',
|
|
67
|
+
},
|
|
68
|
+
} as const
|
|
69
|
+
|
|
70
|
+
const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
|
|
71
|
+
|
|
72
|
+
const current = computed<RmTourStep | undefined>(() => props.steps[step.value])
|
|
73
|
+
const isLast = computed(() => step.value >= props.steps.length - 1)
|
|
74
|
+
|
|
75
|
+
/** Cadre de la cible, en coordonnees de fenetre. Nul tant qu'elle manque. */
|
|
76
|
+
const spot = ref<{ top: number, left: number, width: number, height: number } | null>(null)
|
|
77
|
+
const card = ref<HTMLElement | null>(null)
|
|
78
|
+
const cardSize = ref({ width: 340, height: 220 })
|
|
79
|
+
|
|
80
|
+
const PAD = 8
|
|
81
|
+
const GAP = 14
|
|
82
|
+
/** Marge minimale entre la bulle et le bord de la fenetre. */
|
|
83
|
+
const EDGE = 16
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Le noeud vise. Une etape peut donner plusieurs selecteurs separes par une
|
|
87
|
+
* virgule : ils sont essayes DANS L'ORDRE ECRIT, du plus precis au repli.
|
|
88
|
+
* `document.querySelector` avec la meme liste rendrait le premier noeud dans
|
|
89
|
+
* l'ordre du document, donc souvent le repli — un panneau plutot que la liste
|
|
90
|
+
* qu'il contient.
|
|
91
|
+
*/
|
|
92
|
+
function findTarget(selector: string): HTMLElement | null {
|
|
93
|
+
for (const part of selector.split(',')) {
|
|
94
|
+
const node = document.querySelector(part.trim()) as HTMLElement | null
|
|
95
|
+
if (node) return node
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function measure() {
|
|
101
|
+
const selector = current.value?.target
|
|
102
|
+
if (!selector) {
|
|
103
|
+
spot.value = null
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const node = findTarget(selector)
|
|
107
|
+
if (!node) {
|
|
108
|
+
spot.value = null
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const rect = node.getBoundingClientRect()
|
|
112
|
+
// Un noeud replie (panneau ferme, transition en cours) mesure zero : le
|
|
113
|
+
// detourer dessinerait un point au coin de l'ecran. On le traite comme
|
|
114
|
+
// absent, et la mesure suivante le rattrapera quand il se sera deploye.
|
|
115
|
+
if (rect.width < 2 || rect.height < 2) {
|
|
116
|
+
spot.value = null
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const pad = current.value?.padding ?? PAD
|
|
120
|
+
spot.value = {
|
|
121
|
+
top: rect.top - pad,
|
|
122
|
+
left: rect.left - pad,
|
|
123
|
+
width: rect.width + pad * 2,
|
|
124
|
+
height: rect.height + pad * 2,
|
|
125
|
+
}
|
|
126
|
+
const box = card.value?.getBoundingClientRect()
|
|
127
|
+
if (box) cardSize.value = { width: box.width, height: box.height }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* La mesure tourne image par image tant que la visite est ouverte.
|
|
132
|
+
*
|
|
133
|
+
* Les cibles bougent sans evenement qu'on puisse ecouter : le panneau du rail
|
|
134
|
+
* s'ouvre en glissant, la page se refloue derriere lui, une carte se charge et
|
|
135
|
+
* grandit. Ecouter `scroll` et `resize` laissait le detourage a cote de sa
|
|
136
|
+
* cible pendant toute la transition. Lire un `getBoundingClientRect` par image
|
|
137
|
+
* sur un seul noeud coute moins qu'un observateur par cause de mouvement.
|
|
138
|
+
*/
|
|
139
|
+
let frame: number | null = null
|
|
140
|
+
|
|
141
|
+
function loop() {
|
|
142
|
+
measure()
|
|
143
|
+
frame = requestAnimationFrame(loop)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function start() {
|
|
147
|
+
if (frame === null) frame = requestAnimationFrame(loop)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function stop() {
|
|
151
|
+
if (frame !== null) cancelAnimationFrame(frame)
|
|
152
|
+
frame = null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** La cible est amenee a l'ecran au changement d'etape, jamais apres. */
|
|
156
|
+
function reveal() {
|
|
157
|
+
const selector = current.value?.target
|
|
158
|
+
if (!selector) return
|
|
159
|
+
const node = findTarget(selector)
|
|
160
|
+
if (!node) return
|
|
161
|
+
const still = document.body.classList.contains('reduce-motion')
|
|
162
|
+
|| window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
163
|
+
node.scrollIntoView({ block: 'center', inline: 'nearest', behavior: still ? 'auto' : 'smooth' })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function onKeydown(event: KeyboardEvent) {
|
|
167
|
+
if (!props.open) return
|
|
168
|
+
if (event.key === 'Escape') {
|
|
169
|
+
event.preventDefault()
|
|
170
|
+
emit('close')
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
if (event.key === 'ArrowRight') {
|
|
174
|
+
event.preventDefault()
|
|
175
|
+
next()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if (event.key === 'ArrowLeft') {
|
|
179
|
+
event.preventDefault()
|
|
180
|
+
prev()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function next() {
|
|
185
|
+
if (isLast.value) {
|
|
186
|
+
emit('finish')
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
step.value = step.value + 1
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function prev() {
|
|
193
|
+
if (step.value > 0) step.value = step.value - 1
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
watch(() => props.open, (open) => {
|
|
197
|
+
if (open) {
|
|
198
|
+
step.value = Math.min(Math.max(step.value, 0), Math.max(props.steps.length - 1, 0))
|
|
199
|
+
document.addEventListener('keydown', onKeydown)
|
|
200
|
+
start()
|
|
201
|
+
nextTick(() => {
|
|
202
|
+
reveal()
|
|
203
|
+
measure()
|
|
204
|
+
card.value?.focus()
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
document.removeEventListener('keydown', onKeydown)
|
|
209
|
+
stop()
|
|
210
|
+
spot.value = null
|
|
211
|
+
}
|
|
212
|
+
}, { immediate: false })
|
|
213
|
+
|
|
214
|
+
// Chaque etape amene sa cible a l'ecran et reprend la main au clavier : la
|
|
215
|
+
// bulle est le seul element focalisable de la visite.
|
|
216
|
+
watch(step, () => {
|
|
217
|
+
if (!props.open) return
|
|
218
|
+
nextTick(() => {
|
|
219
|
+
reveal()
|
|
220
|
+
measure()
|
|
221
|
+
card.value?.focus()
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
onBeforeUnmount(() => {
|
|
226
|
+
document.removeEventListener('keydown', onKeydown)
|
|
227
|
+
stop()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Position de la bulle : le premier cote qui la contient entierement, en
|
|
232
|
+
* partant du cote demande. Sans cible, ou si aucun cote ne tient, elle est
|
|
233
|
+
* centree — et le detourage, lui, reste en place.
|
|
234
|
+
*/
|
|
235
|
+
const cardStyle = computed(() => {
|
|
236
|
+
const rect = spot.value
|
|
237
|
+
const { width, height } = cardSize.value
|
|
238
|
+
const vw = typeof window === 'undefined' ? 1280 : window.innerWidth
|
|
239
|
+
const vh = typeof window === 'undefined' ? 800 : window.innerHeight
|
|
240
|
+
|
|
241
|
+
if (!rect || current.value?.placement === 'center') {
|
|
242
|
+
return {
|
|
243
|
+
top: `${Math.max(EDGE, (vh - height) / 2)}px`,
|
|
244
|
+
left: `${Math.max(EDGE, (vw - width) / 2)}px`,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const clamp = (value: number, max: number) =>
|
|
249
|
+
Math.min(Math.max(value, EDGE), Math.max(EDGE, max - EDGE))
|
|
250
|
+
|
|
251
|
+
const fits = {
|
|
252
|
+
bottom: vh - (rect.top + rect.height) - GAP >= height,
|
|
253
|
+
top: rect.top - GAP >= height,
|
|
254
|
+
right: vw - (rect.left + rect.width) - GAP >= width,
|
|
255
|
+
left: rect.left - GAP >= width,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const order: ('top' | 'bottom' | 'left' | 'right')[] = current.value?.placement
|
|
259
|
+
&& current.value.placement !== 'center'
|
|
260
|
+
? [current.value.placement, 'bottom', 'top', 'right', 'left']
|
|
261
|
+
: ['bottom', 'top', 'right', 'left']
|
|
262
|
+
|
|
263
|
+
const side = order.find(item => fits[item])
|
|
264
|
+
|
|
265
|
+
if (side === 'bottom' || side === 'top') {
|
|
266
|
+
const top = side === 'bottom' ? rect.top + rect.height + GAP : rect.top - GAP - height
|
|
267
|
+
return {
|
|
268
|
+
top: `${clamp(top, vh - height)}px`,
|
|
269
|
+
left: `${clamp(rect.left + rect.width / 2 - width / 2, vw - width)}px`,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (side === 'right' || side === 'left') {
|
|
273
|
+
const left = side === 'right' ? rect.left + rect.width + GAP : rect.left - GAP - width
|
|
274
|
+
return {
|
|
275
|
+
top: `${clamp(rect.top + rect.height / 2 - height / 2, vh - height)}px`,
|
|
276
|
+
left: `${clamp(left, vw - width)}px`,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Aucun cote ne tient (une cible qui occupe l'ecran) : la bulle se pose en
|
|
281
|
+
// bas a droite plutot que par-dessus le milieu de la cible.
|
|
282
|
+
return {
|
|
283
|
+
top: `${Math.max(EDGE, vh - height - EDGE)}px`,
|
|
284
|
+
left: `${Math.max(EDGE, vw - width - EDGE)}px`,
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const spotStyle = computed(() => {
|
|
289
|
+
const rect = spot.value
|
|
290
|
+
if (!rect) return { display: 'none' }
|
|
291
|
+
return {
|
|
292
|
+
top: `${rect.top}px`,
|
|
293
|
+
left: `${rect.left}px`,
|
|
294
|
+
width: `${rect.width}px`,
|
|
295
|
+
height: `${rect.height}px`,
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Les bandes du voile : au-dessus, en dessous, a gauche et a droite de la
|
|
301
|
+
* cible. Sans cible, une seule bande couvre l'ecran.
|
|
302
|
+
*/
|
|
303
|
+
const veil = computed(() => {
|
|
304
|
+
const rect = spot.value
|
|
305
|
+
if (!rect) return [{ top: '0px', left: '0px', right: '0px', bottom: '0px' }]
|
|
306
|
+
return [
|
|
307
|
+
{ top: '0px', left: '0px', right: '0px', height: `${Math.max(rect.top, 0)}px` },
|
|
308
|
+
{
|
|
309
|
+
top: `${rect.top + rect.height}px`,
|
|
310
|
+
left: '0px',
|
|
311
|
+
right: '0px',
|
|
312
|
+
bottom: '0px',
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
top: `${rect.top}px`,
|
|
316
|
+
left: '0px',
|
|
317
|
+
width: `${Math.max(rect.left, 0)}px`,
|
|
318
|
+
height: `${rect.height}px`,
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
top: `${rect.top}px`,
|
|
322
|
+
left: `${rect.left + rect.width}px`,
|
|
323
|
+
right: '0px',
|
|
324
|
+
height: `${rect.height}px`,
|
|
325
|
+
},
|
|
326
|
+
]
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
/** `**gras**`, comme dans les notes. Rien d'autre n'est interprete. */
|
|
330
|
+
function rich(text: string) {
|
|
331
|
+
return text
|
|
332
|
+
.replace(/&/g, '&')
|
|
333
|
+
.replace(/</g, '<')
|
|
334
|
+
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
|
335
|
+
}
|
|
336
|
+
</script>
|
|
337
|
+
|
|
338
|
+
<template>
|
|
339
|
+
<Teleport to="body">
|
|
340
|
+
<div v-if="open && current" class="rm-tour" role="dialog" aria-modal="true" :aria-label="title">
|
|
341
|
+
<!-- Le voile est decoupe en quatre bandes autour de la cible, qui reste
|
|
342
|
+
donc visible et nette dans le trou, sans qu'on ait a deplacer son
|
|
343
|
+
noeud ni a lui poser un z-index. Une seule bande plein ecran quand
|
|
344
|
+
l'etape ne vise rien.
|
|
345
|
+
|
|
346
|
+
Il laisse passer les clics : la visite explique des gestes, elle ne
|
|
347
|
+
doit pas empecher de les faire. Quitter passe par « Quitter » ou
|
|
348
|
+
Echap, pas par un clic dans le vide. -->
|
|
349
|
+
<div
|
|
350
|
+
v-for="(part, index) in veil"
|
|
351
|
+
:key="index"
|
|
352
|
+
class="rm-tour__veil"
|
|
353
|
+
:style="part"
|
|
354
|
+
/>
|
|
355
|
+
|
|
356
|
+
<!-- Le cadre de la cible, pose par-dessus les bandes : c'est lui qui
|
|
357
|
+
dit ce dont parle la bulle. -->
|
|
358
|
+
<div class="rm-tour__spot" :style="spotStyle" />
|
|
359
|
+
|
|
360
|
+
<div ref="card" class="rm-tour__card" :style="cardStyle" tabindex="-1">
|
|
361
|
+
<div class="rm-tour__head">
|
|
362
|
+
<p v-if="title" class="rm-tour__eyebrow">{{ title }}</p>
|
|
363
|
+
<button
|
|
364
|
+
class="rm-tour__close"
|
|
365
|
+
type="button"
|
|
366
|
+
:title="T.quit"
|
|
367
|
+
:aria-label="T.quit"
|
|
368
|
+
@click="emit('close')"
|
|
369
|
+
>
|
|
370
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
371
|
+
<path d="M5 5l10 10M15 5L5 15" />
|
|
372
|
+
</svg>
|
|
373
|
+
</button>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<h2 class="rm-tour__title">{{ current.title }}</h2>
|
|
377
|
+
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
378
|
+
<p class="rm-tour__text" v-html="rich(current.text)" />
|
|
379
|
+
|
|
380
|
+
<ul v-if="current.bullets?.length" class="rm-tour__list">
|
|
381
|
+
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
382
|
+
<li v-for="(item, index) in current.bullets" :key="index" v-html="rich(item)" />
|
|
383
|
+
</ul>
|
|
384
|
+
|
|
385
|
+
<div class="rm-tour__foot">
|
|
386
|
+
<span class="rm-tour__count">{{ T.step }} {{ step + 1 }} {{ T.of }} {{ steps.length }}</span>
|
|
387
|
+
|
|
388
|
+
<div class="rm-tour__dots" aria-hidden="true">
|
|
389
|
+
<span
|
|
390
|
+
v-for="(item, index) in steps"
|
|
391
|
+
:key="index"
|
|
392
|
+
class="rm-tour__dot"
|
|
393
|
+
:class="{ 'rm-tour__dot--on': index === step, 'rm-tour__dot--done': index < step }"
|
|
394
|
+
/>
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<div class="rm-tour__actions">
|
|
398
|
+
<button v-if="step > 0" class="rm-tour__btn" type="button" @click="prev">
|
|
399
|
+
{{ T.prev }}
|
|
400
|
+
</button>
|
|
401
|
+
<button class="rm-tour__btn rm-tour__btn--primary" type="button" @click="next">
|
|
402
|
+
{{ isLast ? T.finish : T.next }}
|
|
403
|
+
</button>
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</Teleport>
|
|
409
|
+
</template>
|
|
410
|
+
|
|
411
|
+
<style scoped>
|
|
412
|
+
.rm-tour{inset:0;position:fixed;z-index:900}.rm-tour__veil{backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);background:rgba(15,23,42,.45)}.rm-tour__spot,.rm-tour__veil{pointer-events:none;position:fixed}.rm-tour__spot{border:2px solid var(--color-secondary);border-radius:var(--radius-lg);transition:all var(--duration-fast) var(--ease-out)}.rm-tour__card{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-2xl);box-shadow:var(--shadow-lg);flex-direction:column;max-width:calc(100vw - 32px);outline:none;padding:var(--space-16);position:fixed;width:340px}.rm-tour__card,.rm-tour__head{display:flex;gap:var(--space-8)}.rm-tour__head{align-items:center;justify-content:space-between}.rm-tour__eyebrow{color:var(--color-secondary);font-size:var(--font-10);font-weight:var(--weight-semibold);letter-spacing:.08em;text-transform:uppercase}.rm-tour__close{align-items:center;background:none;border:none;border-radius:var(--radius-md);color:var(--text-muted);cursor:pointer;display:flex;height:24px;justify-content:center;margin-left:auto;padding:0;width:24px}.rm-tour__close:hover{background:var(--bg-surface-3);color:var(--text-primary)}.rm-tour__close svg{height:14px;width:14px}.rm-tour__title{color:var(--text-primary);font-size:var(--font-16);font-weight:var(--weight-semibold);line-height:1.3}.rm-tour__text{color:var(--text-secondary);font-size:var(--font-13);line-height:1.55}.rm-tour__list{color:var(--text-secondary);display:flex;flex-direction:column;font-size:var(--font-12);gap:var(--space-4);line-height:1.5;margin:0;padding-left:var(--space-16)}.rm-tour__foot{align-items:center;border-top:1px solid var(--border-default);display:flex;gap:var(--space-8);margin-top:var(--space-4);padding-top:var(--space-12)}.rm-tour__count{color:var(--text-muted);font-size:var(--font-11);font-variant-numeric:tabular-nums}.rm-tour__dots{display:flex;gap:4px}.rm-tour__dot{background:var(--border-default);border-radius:var(--radius-pill);height:5px;width:5px}.rm-tour__dot--done{background:var(--color-violet-pale)}.rm-tour__dot--on{background:var(--color-secondary);width:14px}.rm-tour__actions{display:flex;gap:var(--space-6);margin-left:auto}.rm-tour__btn{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);color:var(--text-secondary);cursor:pointer;font-family:inherit;font-size:var(--font-12);padding:5px var(--space-12)}.rm-tour__btn:hover{background:var(--bg-surface-3)}.rm-tour__btn--primary{background:var(--color-secondary);border-color:var(--color-secondary);color:#fff;font-weight:var(--weight-semibold)}.rm-tour__btn--primary:hover{background:var(--color-secondary);filter:brightness(1.05)}
|
|
413
|
+
</style>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmTutorials - le panneau « Tutoriels » du rail : la liste des visites
|
|
3
|
+
guidees que l'ecran ouvert sait jouer.
|
|
4
|
+
|
|
5
|
+
C'est le pendant de `RmNotes` pour les gestes. La note repond a « d'ou vient
|
|
6
|
+
ce chiffre », le tutoriel a « comment je m'en sers » : il ne se lit pas, il
|
|
7
|
+
se joue, et c'est `RmTour` qui le joue. Ce panneau ne fait que presenter le
|
|
8
|
+
catalogue et rendre la main a l'appelant sur « Démarrer ».
|
|
9
|
+
|
|
10
|
+
Une visite peut ne pas etre jouable sur l'ecran affiche (elle designe une
|
|
11
|
+
commande que la page n'a pas). Elle reste alors listee, avec sa raison sous
|
|
12
|
+
elle : la masquer ferait croire que la fonction n'existe pas, alors que
|
|
13
|
+
c'est l'ecran qui ne la porte pas.
|
|
14
|
+
|
|
15
|
+
Les visites deja terminees gardent leur place et changent de bouton
|
|
16
|
+
(« Revoir ») : une visite se rejoue, ce n'est pas une case a cocher.
|
|
17
|
+
-->
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { computed } from 'vue'
|
|
20
|
+
import type { RmTutorial } from '../utils/tours'
|
|
21
|
+
|
|
22
|
+
export type { RmTutorial }
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<{
|
|
25
|
+
tutorials: RmTutorial[]
|
|
26
|
+
/** Identifiants des visites deja terminees par le lecteur. */
|
|
27
|
+
done?: string[]
|
|
28
|
+
title?: string
|
|
29
|
+
/** Phrase d'introduction du panneau. */
|
|
30
|
+
description?: string
|
|
31
|
+
/** Pastille d'auteur en haut a droite (« TEKKARE »). */
|
|
32
|
+
badge?: string
|
|
33
|
+
lang?: 'fr' | 'en'
|
|
34
|
+
}>(), {
|
|
35
|
+
done: () => [],
|
|
36
|
+
title: '',
|
|
37
|
+
description: undefined,
|
|
38
|
+
badge: undefined,
|
|
39
|
+
lang: 'fr',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const emit = defineEmits<{ start: [id: string] }>()
|
|
43
|
+
|
|
44
|
+
const WORDS = {
|
|
45
|
+
fr: {
|
|
46
|
+
start: 'Démarrer',
|
|
47
|
+
again: 'Revoir',
|
|
48
|
+
steps: 'étapes',
|
|
49
|
+
minutes: 'min',
|
|
50
|
+
done: 'Terminé',
|
|
51
|
+
empty: 'Aucune visite pour cet écran',
|
|
52
|
+
emptyText: "Les visites sont rattachées aux fonctions du cadre : ouvrez une page d'analyse pour les retrouver.",
|
|
53
|
+
},
|
|
54
|
+
en: {
|
|
55
|
+
start: 'Start',
|
|
56
|
+
again: 'Replay',
|
|
57
|
+
steps: 'steps',
|
|
58
|
+
minutes: 'min',
|
|
59
|
+
done: 'Done',
|
|
60
|
+
empty: 'No tour for this screen',
|
|
61
|
+
emptyText: 'Tours belong to the frame features: open an analysis page to find them again.',
|
|
62
|
+
},
|
|
63
|
+
} as const
|
|
64
|
+
|
|
65
|
+
const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
|
|
66
|
+
|
|
67
|
+
const isDone = (id: string) => props.done.includes(id)
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
<div class="rm-tuto">
|
|
72
|
+
<div v-if="title || badge" class="rm-tuto__head">
|
|
73
|
+
<h2 v-if="title" class="rm-tuto__title">{{ title }}</h2>
|
|
74
|
+
<span v-if="badge" class="rm-tuto__badge">{{ badge }}</span>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<p v-if="description" class="rm-tuto__desc">{{ description }}</p>
|
|
78
|
+
|
|
79
|
+
<div v-if="tutorials.length" class="rm-tuto__list">
|
|
80
|
+
<article
|
|
81
|
+
v-for="item in tutorials"
|
|
82
|
+
:key="item.id"
|
|
83
|
+
class="rm-tuto__card"
|
|
84
|
+
:class="{ 'rm-tuto__card--off': !!item.disabledReason }"
|
|
85
|
+
>
|
|
86
|
+
<div class="rm-tuto__row">
|
|
87
|
+
<span class="rm-tuto__icon">
|
|
88
|
+
<RmIcon :name="item.icon || 'Compass'" :size="18" />
|
|
89
|
+
</span>
|
|
90
|
+
|
|
91
|
+
<div class="rm-tuto__body">
|
|
92
|
+
<div class="rm-tuto__line">
|
|
93
|
+
<h3 class="rm-tuto__name">{{ item.title }}</h3>
|
|
94
|
+
<span v-if="item.tag" class="rm-tuto__tag">{{ item.tag }}</span>
|
|
95
|
+
<span v-if="isDone(item.id)" class="rm-tuto__done">
|
|
96
|
+
<RmIcon name="CheckCircle" :size="13" weight="fill" />
|
|
97
|
+
{{ T.done }}
|
|
98
|
+
</span>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<p class="rm-tuto__summary">{{ item.summary }}</p>
|
|
102
|
+
|
|
103
|
+
<p class="rm-tuto__meta">
|
|
104
|
+
{{ item.steps }} {{ T.steps }}
|
|
105
|
+
<template v-if="item.minutes"> · {{ item.minutes }} {{ T.minutes }}</template>
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<RmButton
|
|
110
|
+
variant="secondary"
|
|
111
|
+
size="sm"
|
|
112
|
+
prefix-icon="Play"
|
|
113
|
+
:disabled="!!item.disabledReason"
|
|
114
|
+
@click="emit('start', item.id)"
|
|
115
|
+
>
|
|
116
|
+
{{ isDone(item.id) ? T.again : T.start }}
|
|
117
|
+
</RmButton>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<p v-if="item.disabledReason" class="rm-tuto__reason">{{ item.disabledReason }}</p>
|
|
121
|
+
</article>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<RmEmptyState v-else :title="T.empty" :text="T.emptyText" />
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<style scoped>
|
|
129
|
+
.rm-tuto{display:flex;flex-direction:column;gap:var(--space-12);width:100%}.rm-tuto__head{align-items:center;display:flex;gap:var(--space-8);justify-content:space-between}.rm-tuto__title{color:var(--text-primary);font-size:var(--font-16)}.rm-tuto__badge,.rm-tuto__title{font-weight:var(--weight-semibold)}.rm-tuto__badge{background:var(--bg-hover);border-radius:var(--radius-pill);color:var(--color-primary);font-size:var(--font-10);letter-spacing:.06em;padding:2px var(--space-8);text-transform:uppercase}.rm-tuto__desc{color:var(--text-secondary);font-size:var(--font-12);line-height:1.55}.rm-tuto__card,.rm-tuto__list{display:flex;flex-direction:column;gap:var(--space-8)}.rm-tuto__card{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);padding:var(--space-16);transition:border-color var(--duration-fast) var(--ease-out)}.rm-tuto__card:hover{border-color:var(--color-violet-pale)}.rm-tuto__card--off{opacity:.72}.rm-tuto__row{align-items:flex-start;display:flex;gap:var(--space-12)}.rm-tuto__icon{align-items:center;background:var(--bg-hover);border-radius:var(--radius-lg);color:var(--color-secondary);display:flex;flex-shrink:0;height:36px;justify-content:center;width:36px}.rm-tuto__body{display:flex;flex:1;flex-direction:column;gap:3px;min-width:0}.rm-tuto__line{align-items:center;display:flex;flex-wrap:wrap;gap:var(--space-6)}.rm-tuto__name{color:var(--text-primary);font-size:var(--font-13);font-weight:var(--weight-semibold)}.rm-tuto__tag{background:var(--bg-surface-3);border-radius:var(--radius-pill);color:var(--text-muted);font-size:var(--font-10);padding:1px var(--space-6)}.rm-tuto__done{align-items:center;color:var(--color-success);display:inline-flex;font-size:var(--font-10);font-weight:var(--weight-semibold);gap:3px}.rm-tuto__summary{color:var(--text-secondary);font-size:var(--font-12);line-height:1.5}.rm-tuto__meta{color:var(--text-muted);font-size:var(--font-11);font-variant-numeric:tabular-nums}.rm-tuto__reason{color:var(--text-muted);font-size:var(--font-11);line-height:1.5;padding-left:48px}
|
|
130
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types des visites guidees et de leur catalogue.
|
|
3
|
+
*
|
|
4
|
+
* Ils vivent ici plutot que dans les composants parce que le paquet n'expose
|
|
5
|
+
* que `utils/*` : une app qui ecrit ses tutoriels doit pouvoir les typer sans
|
|
6
|
+
* importer un `.vue`. Meme raison que pour `utils/alerts.ts`.
|
|
7
|
+
*/
|
|
8
|
+
/** Une etape de visite : ce qu'on montre, et ce qu'on en dit. */
|
|
9
|
+
export interface RmTourStep {
|
|
10
|
+
/** Titre de l'etape, lu en tete de la bulle. */
|
|
11
|
+
title: string;
|
|
12
|
+
/** Texte de l'etape. `**gras**` est reconnu, rien d'autre. */
|
|
13
|
+
text: string;
|
|
14
|
+
/** Points de detail, sous le texte. */
|
|
15
|
+
bullets?: string[];
|
|
16
|
+
/**
|
|
17
|
+
* Selecteur CSS de la cible. Sans lui, l'etape est centree et rien n'est
|
|
18
|
+
* detoure : c'est la forme des etapes d'introduction et de conclusion.
|
|
19
|
+
*/
|
|
20
|
+
target?: string;
|
|
21
|
+
/** Cote ou poser la bulle. Par defaut, le premier cote qui a la place. */
|
|
22
|
+
placement?: 'top' | 'bottom' | 'left' | 'right' | 'center';
|
|
23
|
+
/** Marge entre le cadre de la cible et son detourage, en pixels. */
|
|
24
|
+
padding?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Une visite, telle que le panneau « Tutoriels » la presente. */
|
|
27
|
+
export interface RmTutorial {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
/** Ce que la visite apprend, en une phrase. */
|
|
31
|
+
summary: string;
|
|
32
|
+
/** Icone Phosphor, nom PascalCase (cf. RmIcon). */
|
|
33
|
+
icon?: string;
|
|
34
|
+
/** Nombre d'etapes, annonce avant de partir. */
|
|
35
|
+
steps: number;
|
|
36
|
+
/** Duree estimee, en minutes. */
|
|
37
|
+
minutes?: number;
|
|
38
|
+
/** Rubrique, en pastille : « Vues », « Alertes »... */
|
|
39
|
+
tag?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Raison pour laquelle la visite ne peut pas partir d'ici. Presente, elle
|
|
42
|
+
* desactive le bouton et s'ecrit sous la carte.
|
|
43
|
+
*/
|
|
44
|
+
disabledReason?: string;
|
|
45
|
+
}
|
|
File without changes
|