Story and step
A story is one route through the application, start to finish, and a list of
steps. An application hands one to start and the tour shows
its steps in order, one at a time. A step is one of two shapes, told apart by
whether it names a target: one that cuts a hole around something on the page,
and one that covers the page and points at nothing.
LekoStory
Section titled “LekoStory”interface LekoStory { id: string steps: LekoStep[] next?: LekoStory | (() => LekoStory | undefined) onEnter?: (story: LekoStory) => void onLeave?: (story: LekoStory, next: LekoStory | undefined) => void}An application registers as many stories as it has, and only one of them ever runs at a time.
| Field | Type | Required |
|---|---|---|
id |
string |
yes |
steps |
LekoStep[] |
yes |
next |
story or function | no |
onEnter |
(story) => void |
no |
onLeave |
(story, next) => void |
no |
story.id
Section titled “story.id”The name the story goes by outside Leko: in a LekoProblem a diagnostic hands
over, and in whatever onStep reports to. Leko never reads it to run a story,
so a fresh object with the same id is a fresh story. The one place it is
matched is pickUp, which compares the id a previous document kept against
the stories it is handed —
Starting a story.
A handler that cares about one story in particular can compare the object
instead: it is the one that was passed to start, and the compiler checks
that where it cannot check a string.
The steps, in the order they are shown. A story with none is refused by
start with story-empty.
The story the tour goes on to when this one runs out of steps: a story, or a
function that answers with one or with undefined.
next: summarynext: () => (order.needsReview ? review : summary)Only a story that ran to the end is followed. stop(), the close control, a
target that never turned up and an onEnter that threw all end the tour where
it stands, and next is not asked.
Nothing is stored. The function is asked when the last step advances, and
answering undefined ends the tour; running the same story again asks again.
It must be cheap and have no side effects, and where it is a branch, what it
reads is the application’s own state.
Across a page load, next is what the arriving document runs, but only when
this story’s last step declares awaits: { url } and the document is left
while the tour stands on that step. next is asked on the page being left, as
that page goes, and answering undefined hands nothing on. The arriving
document takes it up with pickUp —
A page load ends the story, and hands it on.
story.onEnter
Section titled “story.onEnter”onEnter?: (story: LekoStory) => voidBuilds the state the whole story assumes, before its first step is entered: a
screen to be on, a record to run against, fixtures standing in for data the
user has not got yet. A step’s own onEnter is the same job one level down.
Whatever it hands back is dropped, so an async handler runs its first line
here and the rest after the call that moved the tour has returned. A story
whose setup has to finish before anything is measured puts a step that waits at
the top of steps, and that step starts the work in its own onEnter. A throw
ends the tour, and onLeave still runs.
story.onLeave
Section titled “story.onLeave”onLeave?: (story: LekoStory, next: LekoStory | undefined) => voidUndoes what onEnter set up. It is called once for every call to onEnter,
when the story stops being the one that runs, whichever way that happens.
next is the story next answered with, and undefined when the tour is
simply over, so teardown the next story would only have to rebuild can be
skipped.
A promise it hands back is not waited for.
A throw from it is thrown again from a microtask and stops nothing: the tour goes on as though it had returned — What a step and a story assume.
LekoStep
Section titled “LekoStep”type LekoStep = LekoTargetedStep | LekoUntargetedStepOne step of a story: a LekoTargetedStep when it names a
target, a LekoUntargetedStep when it does not.
step.target !== undefined narrows to the first.
Every step has the fields below, whichever shape it is. Where the last column
names an option, the instance sets the same thing for every step, and the step
wins where it says anything, so a step writing 0 beats an instance writing a
number —
LekoOptions.
| Field | Type | Overrides |
|---|---|---|
id |
string |
— |
message |
string |
— |
padding |
number |
padding |
radius |
number |
radius |
duration |
number |
duration |
easing |
(t: number) => number |
easing |
scroll |
boolean | 'direct' | 'staged' |
scroll |
awaits |
LekoKnownSignal | LekoUrlAwait |
— |
onEnter |
(step) => void |
— |
onLeave |
(step, next) => void |
— |
step.id
Section titled “step.id”Required. The name the step goes by outside Leko: in a LekoProblem, in
whatever counts steps from onStep, and as the key for anything an
application wants to hang on a step, a chapter included. Leko never reads it.
message
Section titled “message”The words shown beside the cutout, or docked at the foot of the viewport on a step with no target.
padding
Section titled “padding”The space between the target’s border box and the edge of its cutout, in px.
radius
Section titled “radius”The corner radius of the cutout, in px.
duration
Section titled “duration”How long the movement into this step runs, in ms, and the least a glide
bringing its target in runs for —
duration.
easing
Section titled “easing”The curve the movement into this step follows —
easing.
scroll
Section titled “scroll”Whether this step brings its target into view before drawing it. true is
'direct': every scrollport carrying the target moves in one movement, and the
step is drawn once the page has stopped. 'staged' moves one scrollport at a
time, outermost first, for a target several scrollers deep. false leaves the
page where it is.
It is the step’s first region that is centred, the hole around every element
that region names, and later regions stay where they are. A step with no
target, or a position: fixed one, is never scrolled —
Bringing a target into view.
awaits
Section titled “awaits”What this step waits for, in one of two shapes.
- A name, which
reached(name)reports from the application. The step moves on when the call names the same string, and a call naming anything else does nothing at all. { url }, aLekoUrlAwait: the step moves on when the page’s own URL changes to match, with no call to make.
Which names compile is LekoKnownSignal:
any string until the project has a vocabulary.
The call site names what happened in the application and never which step should move, so steps can be inserted or reordered and the call still fires at the moment it always meant — Signals and steps.
A step that declares nothing here gets the next control, and a step that declares either shape does not. That is the only thing deciding whether the control appears — The next control.
step.onEnter
Section titled “step.onEnter”onEnter?: (step: LekoStep) => voidBuilds the state this step assumes, before anything about it is measured: a record exists, a panel is open. It is not where the step is decorated, because nothing has been drawn yet.
Whatever it hands back is dropped. Work that has to finish before the step is
drawn goes on a step of its own, with no target and an awaits, started
here —
A step that waits.
A throw ends the tour, and onLeave still runs, because a handler that failed
halfway may already have set something up.
step.onLeave
Section titled “step.onLeave”onLeave?: (step: LekoStep, next: LekoStep | undefined) => voidUndoes what onEnter set up. It is called once for every arrival at this step,
when it stops being the current one, also where onEnter threw halfway and on
a step with no onEnter at all.
next is the step the tour moves to, and undefined when the tour is ending:
past the last step, after stop(), and when next hands the tour on to another
story, whose steps this one knows nothing about. A panel that two steps use in
turn can stay open when next is the other one.
A throw from it is thrown again from a microtask and stops nothing: the tour goes on as though it had returned — What a step and a story assume.
LekoTargetedStep
Section titled “LekoTargetedStep”interface LekoTargetedStep { target: LekoTarget | LekoRegion | [LekoTarget | LekoRegion, ...(LekoTarget | LekoShownRegion)[]] resolve?: 'first' | 'visible-first' | 'in-viewport-first' validate?: (targetEl: Element) => boolean error?: string | ((targetEl: Element) => string)}A step that points at something. It has every field of
LekoStep, and these four besides. Only this kind of step may
declare validate and error, because both are questions about the element
target names.
| Field | Type | Required |
|---|---|---|
target |
see below | yes |
resolve |
string union | no |
validate |
(el: Element) => boolean |
no |
error |
string | (el) => string |
no |
target
Section titled “target”What the step cuts holes in the page for. Each entry of the list is one cutout:
a LekoRegion, where several elements become one hole by being
named together, or a bare LekoTarget, which is the region of
one element. A list never nests.
const steps: LekoStep[] = [ // one hole { id: 'save', target: '#save' }, // one hole around both { id: 'quantity', target: { elements: ['#qty-label', '#qty'] } }, // two holes, three elements { id: 'total', target: ['#save', { elements: ['#tax', '#total'] }] }, // two holes, and the first is open { id: 'terms', target: [{ elements: '#terms', interactive: true }, '#summary'] },]The first region is the one the step is about, and it is the only one that may
open. Its first element is what validate is handed, what the message sits
beside, and the one Leko looks for. A later region that resolves to nothing is
a hole this step does not cut, and nothing else happens —
A target is a question.
If that element is not on the page when the step arrives, it is waited for a
moment, even where the rest of the region is there, and if it does not turn up
the tour ends with target-lost —
Target loss & recovery.
resolve
Section titled “resolve”Which element this step means where its target matches several. It overrides
the instance’s resolve and takes the same three
values.
'visible-first'takes the first match the visitor could see, and is the default.'first'takes the first match, hidden or not.'in-viewport-first'asks what'visible-first'asks, and then that some of the match is inside the viewport.
It applies to every element of every region, a function target’s answer
included. 'in-viewport-first' and scroll do not combine: the rule is asked
before the glide, so a match off screen is never found. Ask for one or the
other —
Which of several matches a selector means.
validate
Section titled “validate”validate?: (targetEl: Element) => booleanA guard on the next control, and on nothing else. It is called when the
control is pressed, and answering false keeps the tour where it is. On a step
that declares awaits it is ignored: that step has no control, and it moves on
because the application said the thing happened.
It is handed the first element of the first region, looked for afresh on the
press. A target gone by then ends the tour with target-lost, with no moment
to wait for it —
Target loss & recovery.
A refusal is never silent. The cutout shakes, or dims under
prefers-reduced-motion, whether or not the step has an error to show —
A failed attempt.
error?: string | ((targetEl: Element) => string)What to say when validate answers false. It is shown under message and
never replaces it, so a user told they were wrong can still read what they
were asked for.
error: 'That does not look like an email address yet.'error: (el) => `${(el as HTMLInputElement).value} is already taken.`It is asked once per failed attempt, and the words are held from there until
the next attempt succeeds or the step changes. A function is handed the same
element validate was, and must be cheap and have no side effects.
LekoUntargetedStep
Section titled “LekoUntargetedStep”interface LekoUntargetedStep { target?: never resolve?: never validate?: never error?: never}A step that points at nothing. It has every field of LekoStep,
and the four that are about a target are never. The page is covered with no
hole in it, and the message docks at the foot of the viewport.
It is the step to write for a wait: give it awaits, start the work in
onEnter, and the tour stands there with the page held until the application
reports the name.
const waitForDraft: LekoUntargetedStep = { id: 'load-draft', message: 'Loading the draft order…', onEnter: () => void loadDraft(), awaits: 'draft-loaded',}Nothing bounds the wait, so an application whose work can fail catches the
failure and calls stop() —
A step that waits.
A step with no target and no awaits is a full-page message with a next
control.
LekoTarget
Section titled “LekoTarget”type LekoTarget = string | (() => Element | null)Anything a step can point at: a CSS selector, or a function that hands back the
element now, or null where there is none yet.
target: '#order-form'target: () => panel.shadowRoot?.querySelector('.send') ?? nulltarget: () => sendRef.currentBoth forms are a question, asked every time the box is needed and never held on to, which is why there is no element form. Reach for a function where a selector cannot say what you mean: a node inside a shadow root, a framework ref, a row your own code picks out of a list. It must be cheap and have no side effects, and what it hands back is the answer for that moment only — A target is a question.
An element that is not rendered is not found: display: none on it or on
anything it is inside, so a closed tab and a collapsed panel come to the same
thing. Open the panel in the step’s onEnter, which runs before the target is
looked for. visibility: hidden and opacity: 0 keep a box, and whether the
step takes one is resolve’s question.
The page is measured when the step is drawn, and whether the target is still
there is not watched after that. The target is asked again only when the next
control is pressed, for validate, and on a resize, which puts the holes back —
What Leko does not do.
An element inside an <svg> is a target like any other, viewBox scaling and
transforms included —
Resolution & custom functions.
LekoRegion
Section titled “LekoRegion”interface LekoRegion { elements: LekoTarget | LekoTarget[] interactive?: boolean}One cutout, and whether the step opens it. A bare LekoTarget anywhere a
region is wanted reads as { elements: target }: one element, one hole, not
opened.
const save: LekoRegion = { elements: '#save', interactive: true }const quantity: LekoRegion = { elements: ['#quantity-label', '#quantity'] }| Field | Type | Default |
|---|---|---|
elements |
LekoTarget | LekoTarget[] |
— |
interactive |
boolean |
false |
elements
Section titled “elements”One target, or several made into one hole. The hole is the bounding box of everything named, and whatever sits between the elements is inside it too, so name elements that are next to each other: a label and its input, two neighbouring columns, the first and last row of a table.
interactive
Section titled “interactive”Whether the user may operate what is under the hole. It is off by default: a cutout shows what is under it either way, and this decides whether the page also takes the pointer or a blocking rectangle sits over it. Only the first region of a step may declare it — A hole, and whether it is open.
Tab is held to the same answer. Focus walks a ring of what the step opened and the controls Leko drew, so a hole that is only shown cannot be reached with the keyboard either — The ring focus cannot leave.
LekoShownRegion
Section titled “LekoShownRegion”interface LekoShownRegion { elements: LekoTarget | LekoTarget[] interactive?: never}Every region after a step’s first. It is shown and blocked: the hole shows what
is under it, and a blocking rectangle keeps it from taking a click. These are
there to be looked at, such as a summary figure beside the row it was worked
out from. interactive is never, so the flag does not compile here, even on
an object built somewhere else.
LekoUrlAwait
Section titled “LekoUrlAwait”interface LekoUrlAwait { url: RegExp}The other shape of awaits: the step moves on when the page’s own
URL changes to match url, with no call the application has to make.
const checkout: LekoUrlAwait = { url: /^\/checkout(?:[/?#]|$)/ }const hashRouted: LekoUrlAwait = { url: /^\/#\/checkout(?:[/?]|$)/ } // a hash routerThe pattern is tested against everything after the origin:
pathname + search + hash of location. Anchor it: an unanchored
/checkout/ also matches /checkout-history. g and y are ignored.
The step moves on a change, never on an arrival: a URL that already matches when the step is drawn does nothing, and the next URL that matches after that moves it — A URL is a signal the page reports.
A navigation that loads a new document is not heard this way. On a story’s
last step, { url } instead hands the tour on to the story’s next in the
document that loads —
A page load ends the story, and hands it on.
The order the hooks run in
Section titled “The order the hooks run in”A step’s lifecycle draws this. What follows is the order a handler can rely on.
A story is entered outermost first: the story’s onEnter, then the first
step’s onEnter, then the target is resolved and the step handed over to be
drawn, then onStep reports it. All of that runs inside the one call that
moved the tour. It is not the same as the step being on screen: a step that
glides into view, or whose target is not on the page yet, is drawn after
onStep and outside the call, and one whose target never turns up is reported
and then followed by the report of the ending.
On a move to the next step, the tour’s position changes before any handler
runs. So while the leaving step’s onLeave and the arriving step’s onEnter
run, step and index on the instance already name the arriving step. The
leaving step’s onLeave runs first, then the arriving step’s onEnter.
onLeave(step, next) is handed both, so a handler reads those rather than the
getters.
An ending runs innermost first. The tour is emptied before any of it, so
state reads 'idle' inside every onLeave of a teardown. Then the step’s
onLeave runs with next as undefined, then the story’s onLeave with
next as the story it hands on to or undefined, then onStep reports the
ending with step as undefined. The step’s onLeave is skipped only where
its onEnter was never called, which happens two ways: the story’s own
onEnter threw or called stop() before a step was entered, or the onLeave
of the step being left on a move called stop(). In the second case the step
ahead, which step and index already name, gets neither handler, and only
the story’s onLeave runs after the leaving step’s.
A story that hands on through next ends that way: the step’s onLeave gets
undefined, the story’s onLeave gets the story it hands on to, and that
story’s onEnter runs in the same call.
A throw from an onEnter ends the tour where it stands, and the onLeave
that pairs with it still runs. The reason is thrown again once the ending has
been reported, and onStep never names the step whose onEnter threw.
A throw from an onLeave, onStep or onDiagnostic ends nothing: the tour
goes into the step ahead, through the rest of an ending and its report, and
into the story next names, and the reason is thrown again from a microtask —
What a step and a story assume.
validate runs only when the next control is pressed, and only on a step with
no awaits. A reached() that matches an awaits step moves the tour on
straight away. A refusal shakes the cutout (or dims it under reduced motion),
shows error where there is one, and the tour stays put.
From inside onEnter or onLeave, start and pickUp are refused and a
reached() that matches the step being entered is dropped. stop() is the one
call that goes through —
Leko and
One gate, and what it refuses.
What onEnter and onLeave hand back is dropped, and nothing is awaited.