APOLLO VISION LABS

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’attribut data-guide de l’élément. Le seul champ obligatoire.
  • route : le motif de la page à laquelle appartient l’étape. Accepte les segments :param et *.
  • navigateTo : le chemin concret passé à votre fonction navigate.
  • placement : le côté de la cible où se place la bulle. bottom par 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.
  • title et body : du texte littéral. titleKey et bodyKey passent par translate. Le littéral l’emporte si les deux sont fournis, et une clé sans translate s’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.