UNPKG

@rently-team/shepherd.js

Version:

Guide your users through a tour of your app.

484 lines (413 loc) 11.9 kB
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 };