@rently-team/shepherd.js
Version:
Guide your users through a tour of your app.
484 lines (413 loc) • 11.9 kB
text/typescript
import { Evented } from './evented.ts';
import { Step, type StepOptions } from './step.ts';
import autoBind from './utils/auto-bind.ts';
import {
isHTMLElement,
isFunction,
isString,
isUndefined
} from './utils/type-check.ts';
import { cleanupSteps } from './utils/cleanup.ts';
import { normalizePrefix, uuid } from './utils/general.ts';
// @ts-expect-error TODO: we don't have Svelte .d.ts files until we generate the dist
import ShepherdModal from './components/shepherd-modal.svelte';
export interface EventOptions {
previous?: Step | null;
step?: Step | null;
tour: Tour;
}
export type TourConfirmCancel =
| boolean
| (() => boolean)
| Promise<boolean>
| (() => Promise<boolean>);
/**
* The options for the tour
*/
export interface TourOptions {
/**
* If true, will issue a `window.confirm` before cancelling.
* If it is a function(support Async Function), it will be called and wait for the return value,
* and will only be cancelled if the value returned is true.
*/
confirmCancel?: TourConfirmCancel;
/**
* The message to display in the `window.confirm` dialog.
*/
confirmCancelMessage?: string;
/**
* The prefix to add to the `shepherd-enabled` and `shepherd-target` class names as well as the `data-shepherd-step-id`.
*/
classPrefix?: string;
/**
* Default options for Steps ({@link Step#constructor}), created through `addStep`.
*/
defaultStepOptions?: StepOptions;
/**
* Exiting the tour with the escape key will be enabled unless this is explicitly
* set to false.
*/
exitOnEsc?: boolean;
/**
* Explicitly set the id for the tour. If not set, the id will be a generated uuid.
*/
id?: string;
/**
* Navigating the tour via left and right arrow keys will be enabled
* unless this is explicitly set to false.
*/
keyboardNavigation?: boolean;
/**
* An optional container element for the modal.
* If not set, the modal will be appended to `document.body`.
*/
modalContainer?: HTMLElement;
/**
* An optional container element for the steps.
* If not set, the steps will be appended to `document.body`.
*/
stepsContainer?: HTMLElement;
/**
* An array of step options objects or Step instances to initialize the tour with.
*/
steps?: Array<StepOptions> | Array<Step>;
/**
* An optional "name" for the tour. This will be appended to the the tour's
* dynamically generated `id` property.
*/
tourName?: string;
/**
* Whether or not steps should be placed above a darkened
* modal overlay. If true, the overlay will create an opening around the target element so that it
* can remain interactive
*/
useModalOverlay?: boolean;
}
export class ShepherdBase extends Evented {
activeTour?: Tour | null;
declare Step: typeof Step;
declare Tour: typeof Tour;
constructor() {
super();
autoBind(this);
}
}
/**
* Class representing the site tour
* @extends {Evented}
*/
export class Tour extends Evented {
trackedEvents = ['active', 'cancel', 'complete', 'show'];
classPrefix: string;
currentStep?: Step | null;
focusedElBeforeOpen?: HTMLElement | null;
id?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
modal?: any | null;
options: TourOptions;
steps: Array<Step>;
constructor(options: TourOptions = {}) {
super();
autoBind(this);
const defaultTourOptions = {
exitOnEsc: true,
keyboardNavigation: true
};
this.options = Object.assign({}, defaultTourOptions, options);
this.classPrefix = normalizePrefix(this.options.classPrefix);
this.steps = [];
this.addSteps(this.options.steps);
// Pass these events onto the global Shepherd object
const events = [
'active',
'cancel',
'complete',
'inactive',
'show',
'start'
];
events.map((event) => {
((e) => {
this.on(e, (opts?: { [key: string]: unknown }) => {
opts = opts || {};
opts['tour'] = this;
Shepherd.trigger(e, opts);
});
})(event);
});
this._setTourID(options.id);
return this;
}
/**
* Adds a new step to the tour
* @param {StepOptions} options - An object containing step options or a Step instance
* @param {number | undefined} index - The optional index to insert the step at. If undefined, the step
* is added to the end of the array.
* @return The newly added step
*/
addStep(options: StepOptions | Step, index?: number) {
let step = options;
const copy = this.steps.findIndex((s) => s.id == step.id);
// Remove existing step if ID is duplicated
if (copy !== -1) {
this.steps.splice(copy, 1);
}
if (!(step instanceof Step)) {
step = new Step(this, step);
} else {
step.tour = this;
}
if (!isUndefined(index)) {
this.steps.splice(index, 0, step as Step);
} else {
this.steps.push(step as Step);
}
return step;
}
/**
* Add multiple steps to the tour
* @param {Array<StepOptions> | Array<Step> | undefined} steps - The steps to add to the tour
*/
addSteps(steps?: Array<StepOptions> | Array<Step>) {
if (Array.isArray(steps)) {
steps.forEach((step) => {
this.addStep(step);
});
}
return this;
}
/**
* Go to the previous step in the tour
*/
back() {
const index = this.steps.indexOf(this.currentStep as Step);
this.show(index - 1, false);
}
/**
* Calls _done() triggering the 'cancel' event
* If `confirmCancel` is true, will show a window.confirm before cancelling
* If `confirmCancel` is a function, will call it and wait for the return value,
* and only cancel when the value returned is true
*/
async cancel() {
if (this.options.confirmCancel) {
const cancelMessage =
this.options.confirmCancelMessage ||
'Are you sure you want to stop the tour?';
let stopTour;
if (isFunction(this.options.confirmCancel)) {
stopTour = await this.options.confirmCancel();
} else {
stopTour = window.confirm(cancelMessage);
}
if (stopTour) {
this._done('cancel');
}
} else {
this._done('cancel');
}
}
/**
* Calls _done() triggering the `complete` event
*/
complete() {
this._done('complete');
}
/**
* Gets the step from a given id
* @param {number | string} id - The id of the step to retrieve
* @return The step corresponding to the `id`
*/
getById(id: number | string) {
return this.steps.find((step) => {
return step.id === id;
});
}
/**
* Gets the current step
*/
getCurrentStep() {
return this.currentStep;
}
/**
* Hide the current step
*/
hide() {
const currentStep = this.getCurrentStep();
if (currentStep) {
return currentStep.hide();
}
}
/**
* Check if the tour is active
*/
isActive() {
return Shepherd.activeTour === this;
}
/**
* Go to the next step in the tour
* If we are at the end, call `complete`
*/
next() {
const index = this.steps.indexOf(this.currentStep as Step);
if (index === this.steps.length - 1) {
this.complete();
} else {
this.show(index + 1, true);
}
}
/**
* Removes the step from the tour
* @param {string} name - The id for the step to remove
*/
removeStep(name: string) {
const current = this.getCurrentStep();
// Find the step, destroy it and remove it from this.steps
this.steps.some((step, i) => {
if (step.id === name) {
if (step.isOpen()) {
step.hide();
}
step.destroy();
this.steps.splice(i, 1);
return true;
}
});
if (current && current.id === name) {
this.currentStep = undefined;
// If we have steps left, show the first one, otherwise just cancel the tour
this.steps.length ? this.show(0) : this.cancel();
}
}
/**
* 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
*/
show(key: number | string = 0, forward = true) {
const step = isString(key) ? this.getById(key) : this.steps[key];
if (step) {
this._updateStateBeforeShow();
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
if (shouldSkipStep) {
this._skipStep(step, forward);
} else {
this.currentStep = step;
this.trigger('show', {
step,
previous: this.currentStep
});
step.show();
}
}
}
/**
* Start the tour
*/
async start() {
this.trigger('start');
// Save the focused element before the tour opens
this.focusedElBeforeOpen = document.activeElement as HTMLElement | null;
this.currentStep = null;
this.setupModal();
this._setupActiveTour();
this.next();
}
/**
* Called whenever the tour is cancelled or completed, basically anytime we exit the tour
* @param {string} event - The event name to trigger
* @private
*/
_done(event: string) {
const index = this.steps.indexOf(this.currentStep as Step);
if (Array.isArray(this.steps)) {
this.steps.forEach((step) => step.destroy());
}
cleanupSteps(this);
this.trigger(event, { index });
Shepherd.activeTour = null;
this.trigger('inactive', { tour: this });
if (this.modal) {
this.modal.hide();
}
if (event === 'cancel' || event === 'complete') {
if (this.modal) {
const modalContainer = document.querySelector(
'.shepherd-modal-overlay-container'
);
if (modalContainer) {
modalContainer.remove();
this.modal = null;
}
}
}
// Focus the element that was focused before the tour started
if (isHTMLElement(this.focusedElBeforeOpen)) {
this.focusedElBeforeOpen.focus();
}
}
/**
* Make this tour "active"
*/
_setupActiveTour() {
this.trigger('active', { tour: this });
Shepherd.activeTour = this;
}
/**
* setupModal create the modal container and instance
*/
setupModal() {
this.modal = new ShepherdModal({
target: this.options.modalContainer || document.body,
props: {
// @ts-expect-error TODO: investigate where styles comes from
styles: this.styles
}
});
}
/**
* Called when `showOn` evaluates to false, to skip the step or complete the tour if it's the last step
* @param {Step} step - The step to skip
* @param {boolean} forward - True if we are going forward, false if backward
* @private
*/
_skipStep(step: Step, forward: boolean) {
const index = this.steps.indexOf(step);
if (index === this.steps.length - 1) {
this.complete();
} else {
const nextIndex = forward ? index + 1 : index - 1;
this.show(nextIndex, forward);
}
}
/**
* Before showing, hide the current step and if the tour is not
* already active, call `this._setupActiveTour`.
* @private
*/
_updateStateBeforeShow() {
if (this.currentStep) {
this.currentStep.hide();
}
if (!this.isActive()) {
this._setupActiveTour();
}
}
/**
* Sets this.id to a provided tourName and id or `${tourName}--${uuid}`
* @param {string} optionsId - True if we are going forward, false if backward
* @private
*/
_setTourID(optionsId: string | undefined) {
const tourName = this.options.tourName || 'tour';
const tourId = optionsId || uuid();
this.id = `${tourName}--${tourId}`;
}
}
/**
* @public
*/
const Shepherd = new ShepherdBase();
export { Shepherd };