Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion docs-src/src/content/docs/guides/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ const myTour = new Shepherd.Tour(options);
- `off(eventName, [handler])`: Unbind an event
- `once(eventName, handler, [context])`: Bind just the next instance of an event

`start()` always returns a promise, and `show()`, `next()`, and `back()` return
one while a step is waiting on its `attachTo.element` (see `waitForElement`
below). Await them if you need to know the step is on screen — otherwise they
can be called and ignored, as before.

##### Tour Events

- `complete`: Triggered when the last step is advanced
Expand Down Expand Up @@ -212,7 +217,9 @@ const new Step(tour, {

If you don’t specify an `attachTo` the element will appear in the middle of the
screen. The same will happen if your `attachTo.element` callback returns `null`,
`undefined`, or a selector that does not exist in the DOM.
`undefined`, or a selector that does not exist in the DOM. The `waitForElement`
and `skipMissingElement` options described below let you change this behavior
for elements that are missing or rendered late.

If you omit the `on` portion of `attachTo`, the element will still be
highlighted, but the tooltip will appear in the middle of the screen, without an
Expand Down Expand Up @@ -256,6 +263,10 @@ function will be called in the `before-show` phase.
- `label` The label to add for `aria-label`

- `classes`: A string of extra classes to add to the step's content element.
- `data`: Arbitrary, JSON-serializable data to associate with the step. Shepherd
does not use it internally; read it back from `step.options.data` in your
event handlers and button actions. Useful for analytics ids or other metadata,
e.g. on generated tour definitions.
- `buttons`: An array of buttons to add to the step. These will be rendered in a
footer below the main body text. Each button in the array is an object of the
format:
Expand Down Expand Up @@ -302,6 +313,27 @@ function will be called in the `before-show` phase.
[Floating UI](https://floating-ui.com/docs/getting-started)
- `showOn`: A function that, when it returns true, will show the step. If it
returns false, the step will be skipped.
- `skipMissingElement`: A boolean. When true, a step whose `attachTo.element`
cannot be found in the DOM is skipped (like `showOn` returning false) instead
of being shown centered. If all remaining steps are skipped, the tour
completes going forward, or cancels going backward. It can also be set on
`defaultStepOptions` to apply to every step. Steps without an `attachTo`
element are never skipped, since they are intentionally centered.
- `waitForElement`: The maximum amount of time, in milliseconds, to wait for the
`attachTo.element` to appear in the DOM before showing the step. The DOM is
watched with a `MutationObserver`, falling back to polling where
`MutationObserver` is unavailable, so the step attaches as soon as the element
appears. If the timeout expires, the step falls back to its default behavior:
skipped when `skipMissingElement` is true, otherwise shown centered. It can
also be set on `defaultStepOptions` to apply to every step. Useful for targets
that are rendered asynchronously.

Both options look the target up before the step's own `beforeShowPromise` and
`before-show` handlers run, so they cannot see an element that those handlers
create — keep using `beforeShowPromise` for targets the step itself renders.
Both are plain JSON values, so a tour definition using them stays
serializable.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `scrollTo`: Should the element be scrolled to when this step is shown? If
true, uses the default `scrollIntoView`, if an object, passes that object as
the params to `scrollIntoView` i.e. `{behavior: 'smooth', block: 'center'}`
Expand Down
41 changes: 41 additions & 0 deletions shepherd.js/src/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ export interface StepOptions {
*/
classes?: string;

/**
* Arbitrary, JSON-serializable data to associate with the step. Shepherd
* does not use this value internally; it is a place to store your own
* metadata (for example analytics ids, or context produced by a tour
* generator) and read it back from `step.options.data` in event handlers
* and button actions.
*/
data?: Record<string, unknown>;

/**
* An array of extra element selectors to highlight when the overlay is shown
* The tooltip won't be fixed to these elements, but they will be highlighted
Expand Down Expand Up @@ -174,6 +183,24 @@ export interface StepOptions {
*/
showOn?: () => boolean;

/**
* When `true`, a step whose `attachTo.element` selector (or function
* locator) does not resolve to an element in the DOM is skipped, advancing
* to the next step (or the previous step when navigating backwards) instead
* of being shown centered. If all remaining steps are skipped, the tour
* completes (going forward) or cancels (going backward), mirroring the
* `showOn` semantics. Can be set on `defaultStepOptions` to apply to every
* step. Combine with `waitForElement` to give the element time to appear
* before skipping. Steps without an `attachTo` element are never skipped,
* since they are intentionally centered.
*
* Note that the target is looked up before the step's own `beforeShowPromise`
* and `before-show` handlers run, so an element that those handlers create is
* not visible to this check. Use `beforeShowPromise` on its own for targets
* the step itself renders.
*/
skipMissingElement?: boolean;

/**
* The text in the body of the step. It can be one of four types:
* ```
Expand All @@ -194,6 +221,20 @@ export interface StepOptions {
*/
title?: StringOrStringFunction;

/**
* The maximum amount of time, in milliseconds, to wait for the
* `attachTo.element` to appear in the DOM before showing the step. The DOM
* is watched with a `MutationObserver` (falling back to polling when it is
* unavailable), so the step attaches as soon as the element appears. If the
* timeout expires, the step falls back to its default behavior: skipped
* when `skipMissingElement` is `true`, otherwise shown centered.
*
* The wait starts before the step's own `beforeShowPromise` and `before-show`
* handlers run, so it cannot observe a target that those handlers create, and
* a function locator is re-evaluated on each DOM change until it resolves.
*/
waitForElement?: number;

/**
* You can define `show`, `hide`, etc events inside `when`. For example:
* ```js
Expand Down
106 changes: 87 additions & 19 deletions shepherd.js/src/tour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
isUndefined
} from './utils/type-check.ts';
import { cleanupSteps } from './utils/cleanup.ts';
import { normalizePrefix, uuid } from './utils/general.ts';
import {
normalizePrefix,
resolveAttachToElement,
uuid,
waitForAttachToElement
} from './utils/general.ts';
import {
createShepherdModal,
type ShepherdModalAPI
Expand Down Expand Up @@ -113,6 +118,13 @@ export class Tour extends Evented {
options: TourOptions;
steps: Array<Step>;

/**
* Monotonically increasing id of `show()` calls, used to invalidate pending
* `waitForElement` waits that were superseded by a newer `show()`.
* @private
*/
_showGeneration = 0;

constructor(options: TourOptions = {}) {
super();

Expand Down Expand Up @@ -196,7 +208,7 @@ export class Tour extends Evented {
*/
back() {
const index = this.steps.indexOf(this.currentStep as Step);
this.show(index - 1, false);
return this.show(index - 1, false);
}

/**
Expand Down Expand Up @@ -279,7 +291,7 @@ export class Tour extends Evented {
if (index === this.steps.length - 1) {
this.complete();
} else {
this.show(index + 1, true);
return this.show(index + 1, true);
}
}

Expand Down Expand Up @@ -316,29 +328,85 @@ export class Tour extends Evented {
* Show a specific step in the tour
* @param {number | string} key - The key to look up the step by
* @param {boolean} forward - True if we are going forward, false if backward
* @returns A promise when the step's `waitForElement` option makes showing
* asynchronous, otherwise `undefined`
*/
show(key: number | string = 0, forward = true) {
show(key: number | string = 0, forward = true): void | Promise<void> {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const step = isString(key) ? this.getById(key) : this.steps[key];

if (step) {
this._updateStateBeforeShow();
if (!step) {
return;
}

const shouldSkipStep =
isFunction(step.options.showOn) && !step.options.showOn();
this._updateStateBeforeShow();

// If `showOn` returns false, we want to skip the step, otherwise, show the step like normal
if (shouldSkipStep) {
this._skipStep(step, forward);
} else {
this.currentStep = step;
this.trigger('show', {
step,
previous: this.currentStep
// Invalidate any pending `waitForElement` wait from a previous call.
const generation = ++this._showGeneration;

const shouldSkipStep =
isFunction(step.options.showOn) && !step.options.showOn();

// If `showOn` returns false, we want to skip the step, otherwise, show the step like normal
// Return the result, since the step we skip to may itself be waiting on an element.
if (shouldSkipStep) {
return this._skipStep(step, forward);
}

const { skipMissingElement, waitForElement } = step.options;
const attachToElement =
step.options.attachTo && step.options.attachTo.element;
// `waitForElement` and `skipMissingElement` only apply to steps that
// locate their target dynamically. Steps without an `attachTo` element
// are intentionally centered and never treated as missing.
const hasElementLocator =
isString(attachToElement) || isFunction(attachToElement);
const waitTimeout =
typeof waitForElement === 'number' && waitForElement > 0
? waitForElement
: 0;

if (
hasElementLocator &&
(skipMissingElement || waitTimeout > 0) &&
!resolveAttachToElement(step)
) {
if (waitTimeout > 0) {
return waitForAttachToElement(step, waitTimeout).then((element) => {
// A newer `show()`, or cancelling/completing the tour, supersedes
// this pending wait.
if (generation !== this._showGeneration || !this.isActive()) {
return;
}

if (!element && skipMissingElement) {
return this._skipStep(step, forward);
}

this._showStep(step);
});
}

step.show();
if (skipMissingElement) {
return this._skipStep(step, forward);
}
}

this._showStep(step);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Sets the given step as the current step and shows it
* @param {Step} step - The step to show
* @private
*/
_showStep(step: Step) {
this.currentStep = step;
this.trigger('show', {
step,
previous: this.currentStep
});

step.show();
Comment thread
chuckcarpenter marked this conversation as resolved.
}

/**
Expand All @@ -355,7 +423,7 @@ export class Tour extends Evented {
this.setupModal();

this._setupActiveTour();
this.next();
return this.next();
}

/**
Expand Down Expand Up @@ -425,7 +493,7 @@ export class Tour extends Evented {
} else if (nextIndex >= this.steps.length) {
this.complete();
} else {
this.show(nextIndex, forward);
return this.show(nextIndex, forward);
}
}

Expand Down
88 changes: 87 additions & 1 deletion shepherd.js/src/utils/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function parseAttachTo(step: Step) {
} catch (_e) {
// TODO
}
if (!returnOpts.element) {
if (!returnOpts.element && !step.options.skipMissingElement) {
console.error(
`The element for this Shepherd step was not found ${options.element}`
);
Expand All @@ -63,6 +63,92 @@ export function parseAttachTo(step: Step) {
return returnOpts;
}

/**
* Resolves a step's `attachTo.element` to an HTMLElement, evaluating function
* locators and querying selector strings. Unlike `parseAttachTo`, this never
* logs and simply returns `null` when the element cannot be resolved.
* @param step - The step instance
* @returns The resolved element, or `null` if it could not be found
*/
export function resolveAttachToElement(step: Step): HTMLElement | null {
const attachTo = step.options.attachTo || {};
let element = isFunction(attachTo.element)
? attachTo.element.call(step)
: attachTo.element;

if (isString(element)) {
try {
element = document.querySelector(element) as HTMLElement | null;
} catch (_e) {
element = null;
}
}

return element || null;
}

/**
* Waits for a step's `attachTo.element` to be resolvable, watching the DOM
* with a `MutationObserver` (falling back to polling when it is unavailable)
* until `timeout` milliseconds have elapsed.
* @param step - The step instance
* @param timeout - The maximum amount of time, in milliseconds, to wait
* @returns A promise resolving to the element, or `null` if it did not appear
* within `timeout` milliseconds
*/
export function waitForAttachToElement(
step: Step,
timeout: number
): Promise<HTMLElement | null> {
return new Promise((resolve) => {
const element = resolveAttachToElement(step);

if (element || !(timeout > 0)) {
resolve(element);
return;
}

let observer: MutationObserver | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;

const finish = (result: HTMLElement | null) => {
observer?.disconnect();

if (pollTimer !== null) {
clearInterval(pollTimer);
}

if (timeoutTimer !== null) {
clearTimeout(timeoutTimer);
}

resolve(result);
};

const check = () => {
const found = resolveAttachToElement(step);

if (found) {
finish(found);
}
};

timeoutTimer = setTimeout(() => finish(null), timeout);

if (typeof MutationObserver !== 'undefined') {
observer = new MutationObserver(check);
observer.observe(document.documentElement, {
attributes: true,
childList: true,
subtree: true
});
} else {
pollTimer = setInterval(check, 50);
}
});
}

/*
* Resolves the step's `extraHighlights` option, converting any locator values to HTMLElements.
*/
Expand Down
Loading
Loading