APOLLO VISION LABS

Checklist

Une liste de premiers pas, ce qui coche vraiment un élément, son lien avec les visites, et les deux composants qui l'affichent.

Une checklist est une liste fixe de premiers pas présentée à côté de l’application. C’est une fonctionnalité distincte des visites, avec son propre provider.

Checklist et ChecklistItem

interface Checklist {
  id: string;
  items: ChecklistItem[];
}

interface ChecklistItem {
  id: string;
  title?: string;
  titleKey?: string;
  body?: string;
  bodyKey?: string;
  tourId?: string;
  href?: string;
}

title et body sont du texte littéral ; titleKey et bodyKey passent par la prop translate du provider, exactement comme une étape de visite.

tourId et href décrivent ce qui se passe quand l’élément est activé, et tourId l’emporte si les deux sont fournis. Montez ChecklistProvider à l’intérieur de GuideProvider pour qu’un élément portant un tourId puisse l’atteindre :

import {
  ChecklistProvider,
  GuideProvider,
  createBrowserStorage,
  type Checklist,
} from '@apollovisionlabs/guide-core';
import { ChecklistLauncher, GuideTour } from '@apollovisionlabs/guide-mui';
import { productTour } from './tours';

const storage = createBrowserStorage('my-app');

const onboarding: Checklist = {
  id: 'onboarding',
  items: [
    { id: 'tour', title: 'Suivre la visite du produit', tourId: 'product' },
    { id: 'projects', title: 'Ouvrir la page projets', href: '/projects' },
    { id: 'theme', title: 'Essayer le thème sombre' },
  ],
};

export function App({ navigate }: { navigate: (path: string) => void }) {
  return (
    <GuideProvider tours={[productTour]} navigate={navigate} storage={storage}>
      <ChecklistProvider checklists={[onboarding]} navigate={navigate} storage={storage}>
        <AppRoutes />
        <GuideTour />
        <ChecklistLauncher checklistId="onboarding" title="Premiers pas" />
      </ChecklistProvider>
    </GuideProvider>
  );
}

Les deux providers partagent une même instance de stockage dans le cas normal : la progression des visites vit sous tour:<id>, celle des checklists sous checklist:<id>, donc une seule implémentation de GuideStorage sert les deux.

Ce qui coche un élément, et ce qui ne le coche pas

Trois choses cochent un élément : l’utilisateur qui clique sa case, votre appel à complete, et la fin d’une visite dont l’élément porte l’identifiant.

Un élément href navigue, et rien d’autre. Arriver sur une page ne prouve pas que quelqu’un y a fait quoi que ce soit : seule une coche délibérée le ferme. Attendre que la navigation coche l’élément est la première surprise.

Un élément sans tourId ni href se coche en l’activant : cliquer sa ligne le bascule, comme la case.

complete(itemId) est idempotente. Cocher un élément déjà coché ne change rien et n’émet rien : l’appeler depuis un effet qui se déclenche souvent est sans risque. toggle(itemId) décoche un élément déjà coché, et décocher n’émet aucun événement : checklist:item-complete décrit une progression, pas chaque changement d’état. checklist:complete est émis au moment où le dernier élément se coche, une fois par franchissement.

Un élément dont le tourId ne désigne aucune visite du GuideProvider échoue en silence. Le démarrage est rejeté, le provider le rattrape et avertit une fois avec [guide] starting a tour for a checklist item failed, et rien d’autre ne se passe : aucun événement, rien à l’écran, et l’élément reste décoché. Cet avertissement est émis une fois par provider et non par clic : un second élément cassé après le premier est donc muet. Gardez les tourId des checklists et les Tour.id dans le même module, ou couverts par le même test.

useChecklist

import { useChecklist } from '@apollovisionlabs/guide-core';

function Progress() {
  const {
    items,          // ResolvedChecklistItem[] : id, title, body, completed, tourId, href
    completedCount,
    total,
    isComplete,
    dismissed,
    restored,       // la lecture initiale de stockage de cette checklist est-elle arrivée ?
    activate,       // (itemId) => void : lance la visite, l'href, ou bascule la coche
    toggle,         // (itemId) => void
    complete,       // (itemId) => void, idempotente
    dismiss,        // () => void
    reset,          // () => void
  } = useChecklist('onboarding');

  if (!restored) return null;
  if (dismissed) return null;
  return <p>{`${completedCount} sur ${total}`}</p>;
}

Toutes les actions sont déjà liées à l’identifiant de la checklist : aucune ne le redemande. items porte du texte déjà résolu par translate, donc un rendu personnalisé ne touche jamais aux clés de traduction.

restored empêche une checklist d’afficher un instant son état initial vide avant que le vrai n’arrive. Il vaut true immédiatement quand ChecklistProvider n’a pas de prop storage, puisqu’il n’y a rien à attendre, et il devient true une fois que la lecture propre à cette checklist s’est terminée, qu’elle ait abouti ou échoué. Il se règle indépendamment par checklist : une lecture lente ou bloquée pour une checklist d’un provider qui en tient plusieurs ne bloque jamais le restored d’une autre, et un rendu personnalisé qui l’attend, comme le fait la garde ci-dessus, n’affiche jamais le lanceur d’une checklist fermée pendant une image avant qu’il ne disparaisse, ni le compte périmé d’une checklist en cours avant qu’il ne saute à la bonne valeur.

useChecklist lève une erreur hors d’un ChecklistProvider, et pour un identifiant que le provider ne possède pas. Contrairement aux visites, les identifiants de checklists en double ne sont pas rejetés : la dernière déclarée l’emporte, en silence.

Le lien avec les visites

Un élément portant un tourId lance cette visite quand il est activé, et terminer la visite coche l’élément. Le lien se fait par identifiant et n’est pas exclusif : tous les éléments de toutes les checklists qui nomment la visite terminée sont cochés dans la même passe. Deux éléments qui pointent vers une même visite sont donc fermés par un seul parcours, ce qui est en général une erreur de modélisation à repérer.

Fermeture et remise à zéro

dismiss() masque la checklist et émet checklist:dismiss. L’état est persisté avec les coches : une liste fermée le reste après rechargement. reset() efface les deux : aucune coche, plus de fermeture. Il n’y a pas d’autre moyen de rouvrir la liste que reset.

La progression restaurée depuis le stockage est fusionnée avec ce qui est à l’écran, et non substituée, parce qu’une lecture lente ne doit pas défaire une coche que l’utilisateur vient de poser. Cette fusion est une union et ne peut donc pas retrancher : décocher un élément, ou appeler reset, pendant que la lecture initiale est encore en vol sera annulé quand elle arrivera. La fenêtre se limite à une lecture au montage.

Les deux composants

Checklist affiche la liste elle-même : une barre de progression, une ligne par élément avec sa case, et un bouton de fermeture. Elle rend null tant que restored n’est pas vrai, donc avec une prop storage une checklist fermée ou partiellement complétée n’affiche jamais son état vide d’abord, et elle rend null une fois la checklist fermée.

import { Checklist } from '@apollovisionlabs/guide-mui';

<Checklist
  checklistId="onboarding"
  title="Premiers pas"
  onDismiss={() => console.info('fermée')}
  onActivate={(item) => console.info(item.id)}
/>;

ChecklistLauncher enveloppe cette liste dans un bouton flottant entouré d’un anneau de progression, et l’ouvre dans une bulle.

<ChecklistLauncher checklistId="onboarding" title="Premiers pas" placement="bottom-left" />

Il est positionné en fixed dans un coin de la fenêtre, à 24 pixels des bords, selon placement (bottom-right par défaut). C’est ce qu’il faut savoir avant de l’intégrer : il ne suit pas votre mise en page, il se pose par-dessus ce qui occupe déjà ce coin, et il se dessine juste sous la couche modale, au-dessus d’une barre d’application ou d’un tiroir et sous le projecteur des visites. Si vous avez déjà quelque chose ancré dans ce coin, prenez l’autre coin, ou posez votre propre Checklist dans la page.

La bulle reste ouverte pendant qu’on coche les éléments les uns après les autres. Elle ne se ferme que si l’élément activé passe la main à quelque chose qui a besoin de l’écran : une visite ou une navigation.

Traduire les textes propres aux composants

Les titres et les corps des éléments passent déjà par le translate du provider, via titleKey et bodyKey. Les textes que les composants dessinent autour d’eux sont à part, et se remplacent par labels, comme GuideTour accepte déjà labels pour les boutons du popover.

ChecklistLabels couvre Checklist :

interface ChecklistLabels {
  dismiss: string;
  progress: (completedCount: number, total: number) => string;
  markComplete: (itemTitle: string) => string;
  markNotComplete: (itemTitle: string) => string;
}

ChecklistLauncherLabels l’étend avec les deux textes propres au lanceur :

interface ChecklistLauncherLabels extends ChecklistLabels {
  fabLabel: (title: string, completedCount: number, total: number) => string;
  dismissed: (title: string) => string;
}

Les entrées qui insèrent une valeur sont des fonctions et non des gabarits de chaîne. La place d’un compte ou d’un titre dans la phrase change d’une langue à l’autre, et une fonction vous laisse cet ordre des mots.

Les deux props acceptent un Partial : vous ne remplacez que ce dont vous avez besoin, le reste garde sa valeur anglaise par défaut, soit Dismiss, 2 of 5, Mark <titre> as complete, Mark <titre> as not complete, <titre>, 2 of 5 complete et <titre> dismissed.

<ChecklistLauncher
  checklistId="onboarding"
  title="Premiers pas"
  labels={{
    dismiss: 'Masquer',
    progress: (done, total) => `${done} sur ${total}`,
    markComplete: (item) => `Marquer « ${item} » comme fait`,
    markNotComplete: (item) => `Marquer « ${item} » comme non fait`,
    fabLabel: (title, done, total) => `${title}, ${done} sur ${total} fait`,
    dismissed: (title) => `${title} masquée`,
  }}
/>;

Le lanceur transmet les libellés résolus à la Checklist de son popover : un seul objet suffit pour les deux composants. Deux textes restent hors de labels : le title que vous passez vous-même, et le repli Checklist employé comme nom accessible du popover quand aucun title n’est fourni.