Skip to content

PlTour

A guided walk over a screen that already exists. The three things a new reader has to be shown once, pointed at where they actually are.

React
tsx
import { PlTour } from 'plass-ui';

const filter = useRef<HTMLDivElement>(null);

<PlTour
  open={running}
  onOpenChange={setRunning}
  steps={[
    { target: filter, title: 'Narrow the list', content: 'Type here.' },
    { target: '#export', title: 'Take it with you', side: 'left' },
    { title: 'That is all of it' }
  ]}
/>;
dart
import 'package:plass_ui/plass_ui.dart';

PlTour(
  open: _running,
  onOpenChanged: (bool next) => setState(() => _running = next),
  steps: <PlTourStep>[
    PlTourStep(target: _filterKey, title: const Text('Narrow the list')),
    PlTourStep(target: _exportKey, title: const Text('Take it with you')),
    const PlTourStep(title: Text('That is all of it')),
  ],
);

Before you start

The dimming takes the pointer and the light does not.

The scrim is one layer covering the whole viewportscreen with the target cut out of it, and the cut-out is a clip rather than a painted hole. A clipped-away region is not hit-tested, so the reader can use the control being pointed at and nothing else, which is the difference between a tour and a dialog with a picture of a control in it.

That falls out of the geometry rather than being a second mechanism that has to agree with it, and it is what the whole component is built on:

React

The same clip buys the second thing: the dimming can blur. A hole drawn as a shadow or as four rectangles around the target can only paint a colour, where a clipped layer carries a backdrop filter, so the page around the light is out of focus as well as dark, which is this library's own material rather than a grey wash over it.

PlTour or PlHowToSteps

PlHowToSteps puts the instructions in the page and the reader follows them. PlTour leaves the page as it is and stands over it.

So a step says what it is about rather than describing it. What a tour points at is already on screen, and a second copy inside the card is a second copy to keep in step, which is why there is no image or example on a step and why the card is as small as it is.

Reach for the steps when the reader will come back to them, and for a tour when they will not.

Props

PropTypeDefaultDescription
steps * readonly PlTourStep[]The stops, in order
openbooleanWhether the tour is running. Pass it with onOpenChange to control one
defaultOpenbooleanfalseWhether it starts running, when the tour keeps that itself
onOpenChange(open: boolean) => voidCalled with what open should become
stepnumberWhich stop, counted from 0. Pass it with onStepChange to control one
defaultStepnumber0Which one it starts on, when the tour keeps that itself
onStepChange(step: number) => voidCalled with the stop a Next or Previous press asks for
onFinish() => voidCalled when the last step's button is pressed, before the tour closes
maskbooleantrueDims the page and cuts the target out of the dimming
skippablebooleantrueDraws the Skip button beside the counter
dismissiblebooleantrueWhether Escape and the × end the tour
scrollIntoViewbooleantrueScrolls each target into view as the tour reaches it
previousLabelReactNodelabels.previousThe Previous button
nextLabelReactNodelabels.nextThe Next button
doneLabelReactNodelabels.doneWhat Next becomes on the last step
skipLabelReactNodelabels.skipThe Skip button
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Type scale and the card's width
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role: the buttons and the focus ring
densityshared'default' | 'compact''default'The card's padding. Never the type scale
classNamesPlTourClassNamesClass names for the parts around the card: mask, title, content, close and footer
PropTypeDefaultDescription
steps * List<PlTourStep>The stops, in order
openboolfalseWhether the tour is running. Pass it with onOpenChange to control one
onOpenChangedValueChanged<bool>?Called with what open should become
stepint?Which stop, counted from 0. Pass it with onStepChange to control one
initialStepint0Which one it starts on, when the tour keeps that itself
onStepChangedValueChanged<int>?Called with the stop a Next or Previous press asks for
onFinishVoidCallback?Called when the last step's button is pressed, before the tour closes
controllerScrollController?The scroll the targets live in, so the light follows them. The same parameter PlAnchor takes and for the same reason
maskbooltrueDims the screen and cuts the target out of the dimming
skippablebooltrueDraws the Skip button beside the counter
dismissiblebooltrueWhether Escape and the × end the tour
scrollIntoViewbooltrueScrolls each target into view as the tour reaches it
previousLabelWidget?Text(labels.previous)The Previous button
nextLabelWidget?Text(labels.next)The Next button
doneLabelWidget?Text(labels.done)What Next becomes on the last step
skipLabelWidget?Text(labels.skip)The Skip button
closeLabelString?labels.closeThe name a screen reader gives the ×
sizesharedPlassSize?PlassSize.mdType scale and the card's width
colorsharedPlassColor?PlassColor.primarySemantic colour role: the buttons and the focus ring
densitysharedPlassDensity?PlassDensity.standardThe card's padding. Never the type scale

variant and elevation are absent. The card is the frosted panel a PlPopover draws, at the top of the shadow ladder, and it is meant to float. A tour card made of solid would be a control, and one at elevation 0 would be flat against a page it is standing over.

variant and elevation are absent for the same reason the React build gives.

It is not built on the internal portal every other layer in this package uses, and the reason is the whole design: that helper holds focus inside itself, which is right for a modal and wrong here. A tour whose reader cannot reach the control it is pointing at has pointed at a picture.

The widget draws nothing where it is written, so it can go anywhere under an Overlay, WidgetsApp with a navigator and MaterialApp both provide one.

PlTourStep

PropTypeDefaultDescription
targetstring | RefObject<Element | null> | (() => Element | null)What this step is about. Left out, the card is centred over the page and nothing is cut out
titleReactNodeThe step's heading
contentReactNodeWhat it says
sideshared'top' | 'right' | 'bottom' | 'left''bottom'Which edge of the target the card sits on
alignshared'start' | 'center' | 'end''center'Where along that edge
paddingnumber6How far the cut-out is grown past the target, in pixels
radiusnumberThe cut-out's corner radius in pixels. Defaults to the size's own
PropTypeDefaultDescription
targetGlobalKey?What this step is about, as a GlobalKey on the widget itself. Left out, the card is centred over the screen and nothing is cut out
titleWidget?The step's heading
contentWidget?What it says
sidesharedPlassSidePlassSide.bottomWhich edge of the target the card sits on
alignsharedPlassAlignPlassAlign.centerWhere along that edge
paddingdouble6How far the cut-out is grown past the target, in logical pixels
radiusdouble?The cut-out's corner radius. Defaults to the size's own

target takes three forms, and a ref is the one to use. A ref is checked by the compiler and survives a rename. A selector is a string that stops matching the moment somebody renames a class, and the tour keeps running with its hole over empty background; it is here because it is the only form that works when the target belongs to something this page does not render. The getter is for a target that takes more than one query to find.

target is a GlobalKey and nothing else. Every widget on the screen was written by somebody who can put a key on it, and a key is checked by the compiler; the React build offers a selector as well only because a web page can contain elements it did not render.

What the shared axes (size color density) mean across the library is in prop conventions.

Examples

Where the card goes

side and align place the card against the light, and it flips to the opposite side when the one asked for has no room. A step with no target puts the card in the middle of the viewportscreen and cuts nothing out, which is what a welcome step and a closing step are.

React

The flip is a flip and not a slide, which is the same bargain PlPopover makes: a card that crept sideways as its target neared the edge would be a card that no longer looks like it is pointing at anything. What does move is the cross axis, and only far enough to keep the card on screen.

Following the target

A tour runs over a live page. Something below it can finish loading, an image can arrive, the window can be resized, and the light would be left over a piece of empty background.

The measurement is re-read on a scroll, on a resize and on the target changing size, coalesced to one read per frame: a scroll fires far more often than the page paints, and each read forces a layout.

The page is not pinned while the tour runs, which is deliberate. The reader is meant to be able to use what is being pointed at, and that sometimes means scrolling to it.

The light is measured when the step changes and when the window changes size. For a screen that scrolls, hand the tour the ScrollController the targets live in: it is lifted into the Overlay and cannot see a scroll notification from down there, so the one thing it cannot work out for itself is given to it. It is the same parameter PlAnchor takes, for the same reason.

scrollIntoView brings each target on screen as the tour reaches it, and is on by default. Turn it off for a tour whose targets are all visible already, a smooth scroll that moves nothing is a frame spent on nothing.

Controlled, or not

open and step are each controllable on their own. A tour that runs once on a first visit is defaultOpen and nothing else; a tour whose progress is saved somewhere passes step and onStepChange.

onFinish is called when the last step's button is pressed, before the tour closes, which is where "remember that this reader has seen it" goes.

Accessibility

  • The card is a dialog, named by the step's title and described by its content.
  • The dimming is aria-hidden: it is a drawing, and everything it says is already in the card.
  • Escape ends the tour unless dismissible is false. A press outside the card does not, and neither does the focus leaving it. Using the page is exactly what a tour is for, so the only ways out are Escape, the ×, Skip and Done.
  • The counter is two numbers rather than a sentence. "3 of 7" is a string that has to be translated and a word order that differs by language; the count itself does not.
  • The buttons take their words from the label set, so a tour in a translated application is translated with it.
  • The card is announced as its own thing and the screen under it is still there to be reached. It deliberately does not take the route: a tour that did would be a modal, and the reader could not get to the control the tour is telling them about.
  • Escape ends the tour unless dismissible is false.
  • The counter is two numbers, for the reason the React build gives.
  • The card's buttons wrap to a second line rather than running off the edge, because a translation whose words are longer than English's is three buttons wider than the card.

Notes

  • A tour is not documentation. Three steps is a tour; nine is a manual that nobody will read standing up, and every one of them is between the reader and the thing they opened the product to do.
  • mask={false} is a real option, not a degraded one. A tour over a page the reader is meant to keep working in, a walkthrough beside a form they are filling, is better without the dimming at all.
  • Nothing is remembered. Whether this reader has seen the tour is the application's to store, and onFinish is where to store it.

Released under the MIT License