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.