APOLLO VISION LABS

Checklist

A list of first steps, what actually completes an item, its link with tours, and the two components that render it.

A checklist is a fixed list of first steps shown next to the application. It is a separate feature from a tour, and it has its own provider.

Checklist and ChecklistItem

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

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

title and body are literal text; titleKey and bodyKey go through the provider’s translate instead, exactly as a tour step does.

tourId and href are what happens when the item is activated, and tourId wins if both are set. Mount ChecklistProvider inside GuideProvider so an item carrying a tourId can reach it:

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: 'Take the product tour', tourId: 'product' },
    { id: 'projects', title: 'Open the projects page', href: '/projects' },
    { id: 'theme', title: 'Try dark mode' },
  ],
};

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="Get started" />
      </ChecklistProvider>
    </GuideProvider>
  );
}

The two providers share one storage instance in the normal case: tour progress lives at tour:<id> and checklist progress at checklist:<id>, so a single GuideStorage implementation serves both.

What completes an item, and what does not

Three things tick an item: the user clicking its checkbox, your own call to complete, and finishing a tour whose id the item carries.

An href item navigates and nothing more. Arriving on a page is not evidence that anyone did anything there, so only a deliberate tick closes it. Expecting the navigation to complete the item is the first surprise people hit.

An item with neither tourId nor href is completed by activating it: clicking its row toggles it, like the checkbox does.

complete(itemId) is idempotent. Ticking an item that is already ticked changes nothing and emits nothing, so calling it from an effect that runs repeatedly is safe. toggle(itemId) unticks an already ticked item, and unticking emits no event: checklist:item-complete describes progress, not every change of state. checklist:complete is emitted the moment the last item ticks, once per crossing of that line.

An item whose tourId names no tour on the GuideProvider fails quietly. The start rejects, the provider catches it and warns once with [guide] starting a tour for a checklist item failed, and nothing else happens: no event, nothing on screen, and the item does not tick. That warning fires once per provider, not once per click, so a second broken item after the first one is silent. Keep checklist tourId values and Tour.id values in the same module, or covered by the same test.

useChecklist

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

function Progress() {
  const {
    items,          // ResolvedChecklistItem[]: id, title, body, completed, tourId, href
    completedCount,
    total,
    isComplete,
    dismissed,
    restored,       // has this checklist's own initial storage read settled?
    activate,       // (itemId) => void, runs the item's tour, href, or toggle
    toggle,         // (itemId) => void
    complete,       // (itemId) => void, idempotent
    dismiss,        // () => void
    reset,          // () => void
  } = useChecklist('onboarding');

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

Every action is already bound to the checklist id, so nothing takes it twice. items carries text already resolved through translate, so a custom rendering never touches translation keys.

restored is what keeps a checklist from flashing its empty initial state before the real one arrives. It is true immediately when ChecklistProvider has no storage prop, since there is nothing to wait for, and it becomes true once this checklist’s own read has settled, whether the read resolved or rejected. It settles independently per checklist: a slow or hung read for one checklist on a provider holding several never holds another checklist’s restored false, and a custom renderer that waits for it, the way the guard above does, never shows a dismissed checklist’s launcher for one paint before it disappears, or a partly completed one’s stale count before it jumps to the right value.

useChecklist throws outside a ChecklistProvider, and throws for a checklist id the provider does not hold. Unlike duplicate tour ids, duplicate checklist ids are not rejected: the last one declared wins, silently.

Tours and checklists together

An item carrying a tourId starts that tour when activated, and finishing the tour ticks the item. The link is by id and it is not exclusive: every item in every checklist that names the finished tour is ticked in the same pass. Two items pointing at one tour are both closed by one run of it, which is usually a modelling mistake worth catching.

Dismissal and reset

dismiss() hides the checklist and emits checklist:dismiss. It is persisted alongside the completions, so a dismissed list stays dismissed across reloads. reset() clears both: no completions, not dismissed. There is no undismiss beyond reset.

Progress restored from storage is merged with what is on screen rather than replacing it, because a slow read must not undo a tick the user just made. The merge is a union, so it cannot subtract: unticking an item, or calling reset, while the initial read is still in flight will be undone when that read lands. The window is one read at mount.

The two components

Checklist renders the list itself: a progress bar, one row per item with a checkbox, and a Dismiss button. It renders null until restored is true, so with a storage prop a dismissed or partly completed checklist never flashes its empty state first, and it renders null once the checklist is dismissed.

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

<Checklist
  checklistId="onboarding"
  title="Get started"
  onDismiss={() => console.info('dismissed')}
  onActivate={(item) => console.info(item.id)}
/>;

ChecklistLauncher wraps that list in a floating button with a progress ring, and opens it in a popover.

<ChecklistLauncher checklistId="onboarding" title="Get started" placement="bottom-left" />

It is positioned fixed against a corner of the viewport, 24 pixels in, at placement (bottom-right by default). This matters if you embed it: it does not flow with your layout, it will sit on top of whatever occupies that corner, and it paints one layer below the modal layer, above an application bar or a drawer and below the tour’s spotlight. Anything of your own anchored to the same corner needs the other one, or your own Checklist in the page.

The popover stays open while items are ticked one after another, and closes only when the activated item hands off to something needing the screen: a tour or a navigation.

Translating what the components draw themselves

Item titles and bodies already go through the provider’s translate, via titleKey and bodyKey. The chrome around them is separate, and is overridden through labels, the way GuideTour already takes labels for the popover buttons.

ChecklistLabels covers Checklist:

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

ChecklistLauncherLabels extends it with the launcher’s own two strings:

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

The entries that interpolate a value are functions rather than template strings. Where a count or an item title falls in the sentence is not the same from one language to the next, and a function leaves that word order to you.

Both props take a Partial, so you override only what you need and everything else keeps its English default: Dismiss, 2 of 5, Mark <item title> as complete, Mark <item title> as not complete, <title>, 2 of 5 complete and <title> 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`,
  }}
/>;

The launcher hands its resolved labels to the Checklist inside its popover, so one object covers both components. Two strings stay outside labels: the title you pass yourself, and the Checklist fallback used as the popover’s accessible name when no title is given.