Visites
La forme d'une visite et de ses étapes, la politique de cible absente, les visites multipages, les étapes interactives, l'avancée sur un clic et le pilotage manuel.
Une visite est une donnée : un identifiant et une liste ordonnée d’étapes. Il n’y a ni éditeur visuel, ni enregistreur, ni écran d’administration. Les visites sont des objets TypeScript de votre dépôt, relus comme le reste du code.
Tour et Step
interface Tour {
id: string;
steps: Step[];
}
id est ce que nomment useTour et le tourId d’un élément de checklist. Deux
visites qui partagent un identifiant font échouer GuideProvider au rendu,
immédiatement, plutôt que d’en choisir une.
interface Step {
target: string;
route?: string;
navigateTo?: string;
placement?: 'top' | 'bottom' | 'left' | 'right';
interactive?: boolean;
advanceOn?: 'click';
title?: string;
titleKey?: string;
body?: string;
bodyKey?: string;
onMissingTarget?: 'skip' | 'wait' | 'error';
}
target: la clé logique portée par l’attributdata-guidede l’élément. Le seul champ obligatoire.route: le motif de la page à laquelle appartient l’étape. Accepte les segments:paramet*.navigateTo: le chemin concret passé à votre fonctionnavigate.placement: le côté de la cible où se place la bulle.bottompar défaut.interactive: laisse l’utilisateur atteindre la page pendant l’étape. Voir plus bas.advanceOn:'click'fait avancer l’étape quand l’utilisateur clique la cible, au lieu d’attendre le bouton de la bulle. Voir plus bas.titleetbody: du texte littéral.titleKeyetbodyKeypassent partranslate. Le littéral l’emporte si les deux sont fournis, et une clé sanstranslates’affiche telle quelle.onMissingTarget: remplace la politique du provider pour cette étape seulement.
Un tableau steps vide fait rejeter start() : une visite sans étape est une
erreur, pas un parcours vide.
Quand la cible n’est pas là
La cible peut manquer au moment où l’étape arrive : une requête lente, un panneau
replié, une fonctionnalité que cet utilisateur n’a pas. Le moteur observe le DOM
pendant targetTimeoutMs (5000 ms par défaut), émet target:missing, puis applique
une politique définie sur le provider et remplaçable par étape.
| Politique | Ce que voit l’utilisateur |
|---|---|
wait (défaut) |
Rien, tant que la cible est absente. La visite est en pause, pas morte : elle reprend d’elle-même dès que l’élément apparaît. Juste quand l’élément est en retard, faux quand il ne viendra jamais. |
skip |
La visite passe à l’étape suivante après le délai. Un temps mort, puis la visite continue. Juste pour une étape facultative ou liée à une fonctionnalité que tout le monde n’a pas. |
error |
La visite s’arrête. Juste quand la suite n’a aucun sens sans cette étape. |
Le même minuteur couvre une étape dont la route ne correspond jamais : un motif
faux ou une navigation ratée ne peut donc pas laisser une visite tourner
indéfiniment sans rien afficher.
Pendant l’attente, GuideTour ne dessine rien : ni indicateur de chargement, ni
voile, ni bulle. Dessiner un projecteur sans trou par-dessus la page serait pire que
de ne rien dessiner. La visite tourne pourtant, et useTour(id).status le dit :
affichez votre propre retour à partir de là si une attente longue est probable. Une
seule chose est montée pendant l’attente, un gestionnaire de la touche Échap, pour
que l’utilisateur ne soit jamais coincé.
Les visites qui traversent plusieurs pages
Le core ne connaît pas votre routeur. Il lit location pour savoir si la page
courante satisfait déjà une étape, et appelle navigate sinon.
{
target: 'project.share',
route: '/projects/:id',
navigateTo: '/projects/42',
title: 'Partagez-le',
body: 'Vous avez été amené ici automatiquement.',
}
route et navigateTo sont deux choses différentes, et les confondre est la
première erreur classique. route est un motif, comparé segment par segment au
chemin courant : :id accepte n’importe quel segment non vide, et * accepte tout
à partir de sa position. navigateTo est un chemin littéral, la chaîne exacte
passée à navigate. Un motif ne peut pas servir de destination, parce que :id
n’est pas un vrai segment de chemin.
Quand route ne contient ni : ni *, elle est littérale et sert aussi de
destination : navigateTo devient facultatif. Dès que le motif porte un paramètre
ou un joker, navigateTo est obligatoire ; sans lui l’étape n’a pas de destination
et la visite reste sur la mauvaise page jusqu’à ce que la politique s’applique.
Une étape qui déclare une route alors que le provider n’a pas de fonction
navigate produit un avertissement, et rien de plus.
Les étapes interactives
Une étape ordinaire est une démonstration. Le voile avale les clics, donc l’élément mis en avant n’est pas cliquable : un clic dans le trou éclairé est ignoré, un clic en dehors arrête la visite. Le masque SVG découpe le rendu du voile, pas sa zone cliquable, d’où un bouton qui paraît inerte plutôt qu’un bouton qui ferme la visite.
Quand l’étape demande une action, dites-le :
{
target: 'project.share',
interactive: true,
title: 'Partagez-le',
body: 'Cliquez vous-même sur le bouton.',
placement: 'left',
}
interactive: true change deux choses. Le voile laisse passer les clics, donc
l’élément les reçoit. Et la bulle devient non modale : plus de piège à focus, plus
d’aria-modal.
Ce second point est délibéré. Une boîte de dialogue modale est une prison au clavier par construction : la tabulation y tourne en rond sans jamais atteindre la page. Une étape qui demande de cliquer sur un bouton tout en retenant le clavier loin de ce bouton demande l’impossible. Une étape interactive rend le clavier. Le prix est que le focus n’est plus retenu, donc l’étape est plus facile à perdre de vue. Réservez-la aux étapes qui demandent une action.
Poser interactive: true à la main et appeler next() soi-même une fois l’action
faite fonctionne toujours, et la section suivante décrit ce chemin. Dans le cas
courant, où l’étape demande un seul clic sur sa propre cible, advanceOn
ci-dessous est la meilleure réponse : il fait ce que interactive plus un appel
manuel à next() feraient, sans gestionnaire à écrire soi-même.
Avancer sur une action
Une étape peut déclarer advanceOn: 'click' au lieu de se terminer sur le bouton
de la bulle :
{
target: 'project.share',
title: 'Partagez-le',
body: 'Cliquez vous-même sur le bouton, cette étape est interactive.',
advanceOn: 'click',
}
L’étape avance quand l’utilisateur clique la cible, pas la bulle. advanceOn
implique interactive : une étape qui attend un clic doit laisser le clic passer,
donc le core dérive interactive et awaitsAction sur l’étape active à partir de
advanceOn, plutôt que de vous demander de poser les deux à la main. Un rendu
personnalisé lit activeStep.interactive et activeStep.awaitsAction, pas
step.interactive, qui reste undefined sur une étape qui ne déclare que
advanceOn.
La bulle de GuideTour reflète awaitsAction : pas de bouton principal, un court
texte à sa place, et ArrowRight est ignorée, car l’une ou l’autre serait un moyen
de contourner ce que l’étape demande. Précédent, fermer et Échap fonctionnent
toujours.
L’écouteur de clic est posé, en phase de bouillonnement, sur l’élément résolu au
moment où l’étape s’est ouverte, sans preventDefault ni stopPropagation, donc
votre propre gestionnaire de clic sur la cible continue de s’exécuter.
Si votre application remplace ce nœud DOM ensuite, par exemple en redessinant une
liste, l’écouteur part avec lui et l’étape cesse d’avancer. Rien ne le signale : la
cible a été trouvée une fois, donc le délai a déjà été annulé, aucun
target:missing n’est émis et aucune politique wait, skip ou error ne
s’applique. La visite reste simplement sur cette étape. La conséquence est propre
à advanceOn, même si la cause ne l’est pas : une étape advanceOn n’offre pas de
bouton principal et ignore ArrowRight, donc un nœud remplacé laisse Échap comme
seule sortie. Si l’élément visé par une étape peut être recréé sous elle, donnez-lui
une cible stable qui survit au nouveau rendu, ou utilisez une étape ordinaire avec
un bouton Suivant.
Piloter une visite depuis votre code
import { useTour } from '@apollovisionlabs/guide-core';
function TourControls() {
const tour = useTour('product');
return (
<>
<button onClick={() => tour.start()}>Lancer</button>
<button onClick={() => tour.start({ from: 2 })}>Reprendre à la troisième étape</button>
<button onClick={tour.previous}>Précédent</button>
<button onClick={tour.next}>Suivant</button>
<button onClick={tour.stop} disabled={tour.status === 'idle'}>
Arrêter
</button>
</>
);
}
useTour rend start, next, previous, stop, status et stepIndex.
status vaut idle, running, paused ou completed, et vaut idle dès qu’une
autre visite est celle qui tourne : deux composants qui surveillent chacun leur
visite ne voient jamais l’état de l’autre.
Il n’y a pas de complete(). Une visite se termine quand next() est appelée sur
sa dernière étape, ce qui est aussi le moment où tour:complete est émis. start
rend une promesse parce qu’elle peut d’abord lire la progression persistée ;
l’appeler à nouveau alors que la même visite tourne déjà ne fait rien, donc un
double clic ne fait revenir personne en arrière.
La bulle répond aussi directement au clavier : flèche droite pour avancer, flèche
gauche pour revenir, Échap pour arrêter. Ces touches sont ignorées quand
l’utilisateur saisit du texte dans un champ, une zone de texte, une liste
déroulante ou un élément contenteditable.
La reprise
Avec une prop storage, le provider écrit { status, stepIndex } sous
tour:<id> à chaque avancée et à la fin, et le relit au démarrage de la visite.
tour.start(); // reprend là où l'utilisateur s'était arrêté
tour.start({ resume: false }); // repart toujours de la première étape
tour.start({ from: 2 }); // démarre à l'index donné, sans lire le stockage
Seul un enregistrement in-progress fait reprendre. Une visite enregistrée comme
completed repart du début, ce qu’attend une personne qui redemande la visite. La
valeur lue est validée avant d’être utilisée : une entrée modifiée à la main ou
périmée donne un démarrage propre au lieu d’une erreur.
Un stockage en échec ne bloque jamais une visite. La lecture est rattrapée, un avertissement est écrit une fois par provider, et la visite démarre à la première étape.