UNPKG

@rently-team/shepherd.js

Version:

Guide your users through a tour of your app.

918 lines (786 loc) 23.9 kB
import { deepmerge } from 'deepmerge-ts'; import { Evented } from './evented.ts'; import autoBind from './utils/auto-bind.ts'; import { isElement, isHTMLElement, isFunction, isUndefined } from './utils/type-check.ts'; import { bindAdvance } from './utils/bind.ts'; import { parseAttachTo, normalizePrefix, uuid, parseExtraHighlights } from './utils/general.ts'; import { setupTooltip, destroyTooltip, mergeTooltipConfig, positionOverlay } from './utils/floating-ui.ts'; // @ts-expect-error TODO: we don't have Svelte .d.ts files until we generate the dist import ShepherdElement from './components/shepherd-element.svelte'; import { type Tour } from './tour.ts'; import type { ComputePositionConfig, OffsetOptions } from '@floating-ui/dom'; export type StepText = | string | ReadonlyArray<string> | HTMLElement | (() => string | ReadonlyArray<string> | HTMLElement); export type StringOrStringFunction = string | (() => string); /** * The options for the step */ export interface StepOptions { /** * The element the step should be attached to on the page. * An object with properties `element` and `on`. * * ```js * const step = new Step(tour, { * attachTo: { element: '.some .selector-path', on: 'left' }, * ...moreOptions * }); * ``` * * If you don’t specify an attachTo the element will appear in the middle of the screen. * 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 arrow pointing to the target. */ attachTo?: StepOptionsAttachTo; /** * An action on the page which should advance shepherd to the next step. * It should be an object with a string `selector` and an `event` name * ```js * const step = new Step(tour, { * advanceOn: { selector: '.some .selector-path', event: 'click' }, * ...moreOptions * }); * ``` * `event` doesn’t have to be an event inside the tour, it can be any event fired on any element on the page. * You can also always manually advance the Tour by calling `myTour.next()`. */ advanceOn?: StepOptionsAdvanceOn; /** * Whether to display the arrow for the tooltip or not, or options for the arrow. */ arrow?: boolean | StepOptionsArrow; /** * A function that returns a promise. * When the promise resolves, the rest of the `show` code for the step will execute. */ beforeShowPromise?: () => Promise<unknown>; /** * An array of buttons to add to the step. These will be rendered in a * footer below the main body text. */ buttons?: ReadonlyArray<StepOptionsButton>; /** * Should a cancel “✕” be shown in the header of the step? */ cancelIcon?: StepOptionsCancelIcon; /** * A boolean, that when set to false, will set `pointer-events: none` on the target. */ canClickTarget?: boolean; /** * A string of extra classes to add to the step's content element. */ classes?: string; /** * 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 * just like the `attachTo` element. * ```js * const step = new Step(tour, { * extraHighlights: [ '.pricing', '#docs' ], * ...moreOptions * }); * ``` */ extraHighlights?: ReadonlyArray<string>; /** * An extra class to apply to the `attachTo` element when it is * highlighted (that is, when its step is active). You can then target that selector in your CSS. */ highlightClass?: string; /** * The string to use as the `id` for the step. */ id?: string; /** * An amount of padding to add around the modal overlay opening */ modalOverlayOpeningPadding?: number; /** * An amount of border radius to add around the modal overlay opening */ modalOverlayOpeningRadius?: | number | { topLeft?: number; bottomLeft?: number; bottomRight?: number; topRight?: number; }; /** * An amount to offset the modal overlay opening in the x-direction */ modalOverlayOpeningXOffset?: number; /** * An amount to offset the modal overlay opening in the y-direction */ modalOverlayOpeningYOffset?: number; /** * Extra [options to pass to FloatingUI]{@link https://floating-ui.com/docs/tutorial/} */ floatingUIOptions?: ComputePositionConfig; /** * Should the element be scrolled to when this step is shown? */ scrollTo?: boolean | ScrollIntoViewOptions; /** * A function that lets you override the default scrollTo behavior and * define a custom action to do the scrolling, and possibly other logic. */ scrollToHandler?: (element: HTMLElement) => void; /** * A function that, when it returns `true`, will show the step. * If it returns `false`, the step will be skipped. */ showOn?: () => boolean; /** * The text in the body of the step. It can be one of four types: * ``` * - HTML string * - Array of HTML strings * - `HTMLElement` object * - `Function` to be executed when the step is built. It must return one of the three options above. * ``` */ text?: StepText; /** * The step's title. It becomes an `h3` at the top of the step. * ``` * - HTML string * - `Function` to be executed when the step is built. It must return HTML string. * ``` */ title?: StringOrStringFunction; /** * The titleIcon will be added ot the header * ``` * - HTML string * - `Function` to be executed when the step is built. It must return HTML string. * ``` */ titleIcon?: StringOrStringFunction; /** * The step's footer. It becomes a span in the footer's beginning. * ``` * - HTML string * - `Function` to be executed when the step is built. It must return HTML string. * ``` */ footerText?: StringOrStringFunction; /** * Doesnt have any impact on the step */ pageUrl?: string; /** * Doesnt have any impact on the step */ static?: boolean; /** * You can define `show`, `hide`, etc events inside `when`. For example: * ```js * when: { * show: function() { * window.scrollTo(0, 0); * } * } * ``` */ when?: StepOptionsWhen; /** * The offset will be used as offset value for the @floating-ui/dom * It will adjust the position of the popover from the target * ``` * - OffsetOptions * ``` */ offset?: OffsetOptions; /** * Configuration for an overlay element positioned over the target element. */ overlay?: StepOverlay; } export type PopperPlacement = | 'auto' | 'auto-start' | 'auto-end' | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'right' | 'right-start' | 'right-end' | 'left' | 'left-start' | 'left-end'; export interface StepOptionsArrow { /* * The padding from the edge for the arrow. * Not used if this is not a -start or -end placement. */ padding?: number; } export interface StepOptionsAttachTo { element?: | HTMLElement | string | null | (() => HTMLElement | string | null | undefined); on?: PopperPlacement; /* * The amount of time waited for the element to appear in ms */ wait?: number; strict?: boolean; } export interface StepOverlay { /** * CSS class applied to the overlay element. */ class: string; /** * Optional horizontal padding (in pixels) applied around the overlay. * This affects the size and position of the overlay. */ paddingX?: number; /** * Optional vertical padding (in pixels) applied around the overlay. * This affects the size and position of the overlay. */ paddingY?: number; } export interface StepOptionsAdvanceOn { event: string; selector: string; } export interface StepOptionsButton { /** * A function executed when the button is clicked on * It is automatically bound to the `tour` the step is associated with, so things like `this.next` will * work inside the action. * You can use action to skip steps or navigate to specific steps, with something like: * ```js * action() { * return this.show('some_step_name'); * } * ``` */ action?: (this: Tour) => void; /** * Extra classes to apply to the `<a>` */ classes?: string; /** * Whether the button should be disabled * When the value is `true`, or the function returns `true` the button will be disabled */ disabled?: boolean | (() => boolean); /** * The aria-label text of the button */ label?: StringOrStringFunction; /** * A boolean, that when true, adds a `shepherd-button-secondary` class to the button. */ secondary?: boolean; /** * The HTML text of the button */ text?: StringOrStringFunction; } export interface StepOptionsButtonEvent { [key: string]: () => void; } export interface StepOptionsCancelIcon { enabled?: boolean; label?: string; } export interface StepOptionsWhen { [key: string]: (this: Step) => void; } export interface Overlay { element: HTMLElement; cleanup?: () => void; } /** * A class representing steps to be added to a tour. * @extends {Evented} */ export class Step extends Evented { _resolvedAttachTo: StepOptionsAttachTo | null; _resolvedExtraHighlightElements?: HTMLElement[]; classPrefix?: string; // eslint-disable-next-line @typescript-eslint/ban-types declare cleanup: Function | null; el?: HTMLElement | null; declare id: string; declare options: StepOptions; target?: HTMLElement | null; tour: Tour; observer?: MutationObserver; _overlay?: Overlay; advanceEl?: HTMLElement | null; constructor(tour: Tour, options: StepOptions = {}) { super(); this.tour = tour; this.classPrefix = this.tour.options ? normalizePrefix(this.tour.options.classPrefix) : ''; // @ts-expect-error TODO: investigate where styles comes from this.styles = tour.styles; /** * Resolved attachTo options. Due to lazy evaluation, we only resolve the options during `before-show` phase. * Do not use this directly, use the _getResolvedAttachToOptions method instead. * @type {StepOptionsAttachTo | null} * @private */ this._resolvedAttachTo = null; autoBind(this); this._setOptions(options); return this; } /** * Cancel the tour * Triggers the `cancel` event */ cancel() { this.tour.cancel(); this.trigger('cancel'); } /** * Complete the tour * Triggers the `complete` event */ complete() { this.tour.complete(); this.trigger('complete'); } /** * Remove the step, delete the step's element, and destroy the FloatingUI instance for the step. * Triggers `destroy` event */ destroy() { destroyTooltip(this); if (isHTMLElement(this.el)) { this.el.remove(); this.el = null; } this._removeOverlay(); this._updateStepTargetOnHide(); this.trigger('destroy'); } /** * Returns the tour for the step * @return The tour instance */ getTour() { return this.tour; } /** * Hide the step */ hide() { this.tour.modal?.hide(); this.trigger('before-hide'); if (this.el) { this.el.hidden = true; } if (this._overlay) { this._removeOverlay(); } this._updateStepTargetOnHide(); this.trigger('hide'); } /** * Resolves attachTo options. * @returns {{}|{element, on}} */ _resolveExtraHiglightElements() { this._resolvedExtraHighlightElements = parseExtraHighlights(this); return this._resolvedExtraHighlightElements; } /** * Resolves attachTo options. * @returns {{}|{element, on}} */ _resolveAttachToOptions() { this._resolvedAttachTo = parseAttachTo(this); return this._resolvedAttachTo; } /** * A selector for resolved attachTo options. * @returns {{}|{element, on}} * @private */ _getResolvedAttachToOptions() { if (this._resolvedAttachTo === null) { return this._resolveAttachToOptions(); } return this._resolvedAttachTo; } /** * Check if the step is open and visible * @return True if the step is open and visible */ isOpen() { return Boolean(this.el && !this.el.hidden); } _waitForElement() { return new Promise((resolve) => { const intervalTime = 100; const attachTo = this.options.attachTo; const checkElement = () => { let element; if (isFunction(attachTo?.element)) { element = attachTo.element.call(this); if (element) { resolve(element); } } element = document.querySelector(attachTo?.element as string); if (element) { resolve(element); } else { setTimeout(checkElement, intervalTime); } }; checkElement(); }); } /** * Wraps `_show` and ensures `beforeShowPromise` resolves before calling show * Wraps `_waitForElement` to wait for the element to appear before showing */ show() { const promises = []; if (isFunction(this.options.beforeShowPromise)) { promises.push(Promise.resolve(this.options.beforeShowPromise())); } if (this.options.attachTo?.wait) { promises.push(this._waitForElement()); } // Promise.all([]) resolves immediately if the array is empty return Promise.all(promises).then(() => this._show()).catch(console.error); } /** * Updates the options of the step. * * @param {StepOptions} options The options for the step */ updateStepOptions(options: StepOptions) { Object.assign(this.options, options); // @ts-expect-error TODO: get types for Svelte components if (this.shepherdElementComponent) { // @ts-expect-error TODO: get types for Svelte components this.shepherdElementComponent.$set({ step: this }); } } /** * Returns the element for the step * @return {HTMLElement|null|undefined} The element instance. undefined if it has never been shown, null if it has been destroyed */ getElement() { return this.el; } /** * Returns the target for the step * @return {HTMLElement|null|undefined} The element instance. undefined if it has never been shown, null if query string has not been found */ getTarget() { return this.target; } /** * Creates Shepherd element for step based on options * * @return {HTMLElement} The DOM element for the step tooltip * @private */ _createTooltipContent() { const descriptionId = `${this.id}-description`; const labelId = `${this.id}-label`; // @ts-expect-error TODO: get types for Svelte components this.shepherdElementComponent = new ShepherdElement({ target: this.tour.options?.stepsContainer || document.body, props: { classPrefix: this.classPrefix, descriptionId, labelId, step: this, // @ts-expect-error TODO: investigate where styles comes from styles: this.styles } }); // @ts-expect-error TODO: get types for Svelte components return this.shepherdElementComponent.getElement(); } /** * If a custom scrollToHandler is defined, call that, otherwise do the generic * scrollIntoView call. * * @param {boolean | ScrollIntoViewOptions} scrollToOptions - If true, uses the default `scrollIntoView`, * if an object, passes that object as the params to `scrollIntoView` i.e. `{ behavior: 'smooth', block: 'center' }` * @private */ _scrollTo(scrollToOptions: boolean | ScrollIntoViewOptions) { const { element } = this._getResolvedAttachToOptions(); if (isFunction(this.options.scrollToHandler)) { this.options.scrollToHandler(element as HTMLElement); } else if ( isElement(element) && typeof element.scrollIntoView === 'function' ) { element.scrollIntoView(scrollToOptions); } } /** * _getClassOptions gets all possible classes for the step * @param {StepOptions} stepOptions The step specific options * @returns {string} unique string from array of classes */ _getClassOptions(stepOptions: StepOptions) { const defaultStepOptions = this.tour && this.tour.options && this.tour.options.defaultStepOptions; const stepClasses = stepOptions.classes ? stepOptions.classes : ''; const defaultStepOptionsClasses = defaultStepOptions && defaultStepOptions.classes ? defaultStepOptions.classes : ''; const allClasses = [ ...stepClasses.split(' '), ...defaultStepOptionsClasses.split(' ') ]; const uniqClasses = new Set(allClasses); return Array.from(uniqClasses).join(' ').trim(); } /** * Sets the options for the step, maps `when` to events, sets up buttons * @param options - The options for the step */ _setOptions(options: StepOptions = {}) { let tourOptions = this.tour && this.tour.options && this.tour.options.defaultStepOptions; tourOptions = deepmerge({}, tourOptions || {}); this.options = Object.assign( { arrow: true }, tourOptions, options, mergeTooltipConfig(tourOptions, options) ); const { when } = this.options; this.options.classes = this._getClassOptions(options); this.destroy(); this.id = this.options.id || `step-${uuid()}`; if (when) { Object.keys(when).forEach((event) => { // @ts-expect-error TODO: fix this type error this.on(event, when[event], this); }); } } /** * Creates a overlay element that appears above the target * @returns HTMLElement */ _createOverlay() { this._overlay = { element: document.createElement('div') }; Object.assign(this._overlay.element.style, { position: 'absoulte', zIndex: '9999' }); this._overlay.element.className = `${this.classPrefix}shepherd-overlay`; document.body.appendChild(this._overlay.element); return this._overlay.element; } /** * Deletes the created overlay element and * cleanup the autoupdate of its pos * @returns void */ _removeOverlay() { if (this._overlay) { this._overlay?.cleanup?.(); if (isHTMLElement(this._overlay.element)) { this._overlay.element.remove(); } } } /** * Create the element and set up the FloatingUI instance * @private */ _setupElements() { if (!isUndefined(this.el)) { this.destroy(); } this.el = this._createTooltipContent(); if (this.options.advanceOn) { bindAdvance(this); } // create the overlay element and position itself with autoupdate if ( this.options.overlay && this.options.attachTo?.element && this._resolvedAttachTo?.element ) { const overlay = this._createOverlay(); overlay.classList.add(this.options.overlay.class); positionOverlay(this); } // The tooltip implementation details are handled outside of the Step // object. setupTooltip(this); } /** * Triggers `before-show`, generates the tooltip DOM content, * sets up a FloatingUI instance for the tooltip, then triggers `show`. * @private */ _show(trigger = true) { if (trigger) this.trigger('before-show'); // Force resolve to make sure the options are updated on subsequent shows. this._resolveAttachToOptions(); this._resolveExtraHiglightElements(); this._setupElements(); if (!this.tour.modal) { this.tour.setupModal(); } this.tour.modal?.setupForStep(this); this._styleTargetElementForStep(this); if (this.el) { this.el.hidden = false; } // start scrolling to target before showing the step if (this.options.scrollTo) { setTimeout(() => { this._scrollTo( this.options.scrollTo as boolean | ScrollIntoViewOptions ); }); } if (this.el) { this.el.hidden = false; } // @ts-expect-error TODO: get types for Svelte components const content = this.shepherdElementComponent.getElement(); const target = this.target || document.body; const extraHighlightElements = this._resolvedExtraHighlightElements; target.classList.add(`${this.classPrefix}shepherd-enabled`); target.classList.add(`${this.classPrefix}shepherd-target`); content.classList.add('shepherd-enabled'); extraHighlightElements?.forEach((el) => { el.classList.add(`${this.classPrefix}shepherd-enabled`); el.classList.add(`${this.classPrefix}shepherd-target`); }); // Cancel the tour when the target is removed from the body if (this.options.attachTo && target && !this.observer) { this.observer = new MutationObserver(() => { // hide element if the target is removed if (!document.body.contains(target)) { //Hide the elements if the target is removed if (isHTMLElement(this.el)) this.el.hidden = true; if (isHTMLElement(this._overlay?.element)) this._overlay.element.hidden = true; } // restart the step if the element appeared again const attachTo = this._resolveAttachToOptions(); if ( isHTMLElement(attachTo.element) && attachTo.element !== target ) { this._show(false); } if (this.options.advanceOn?.selector && isHTMLElement(document.querySelector(this.options.advanceOn.selector)) && (!this.advanceEl || document.body.contains(this.advanceEl)) ) { bindAdvance(this); } }); this.observer.observe(document.body, { childList: true, subtree: true }); } if (trigger) this.trigger('show'); } /** * Modulates the styles of the passed step's target element, based on the step's options and * the tour's `modal` option, to visually emphasize the element * * @param {Step} step The step object that attaches to the element * @private */ _styleTargetElementForStep(step: Step) { const targetElement = step.target; const extraHighlightElements = step._resolvedExtraHighlightElements; if (!targetElement) { return; } const highlightClass = step.options.highlightClass; if (highlightClass) { targetElement.classList.add(highlightClass); extraHighlightElements?.forEach((el) => el.classList.add(highlightClass)); } targetElement.classList.remove('shepherd-target-click-disabled'); extraHighlightElements?.forEach((el) => el.classList.remove('shepherd-target-click-disabled') ); if (step.options.canClickTarget === false) { targetElement.classList.add('shepherd-target-click-disabled'); extraHighlightElements?.forEach((el) => el.classList.add('shepherd-target-click-disabled') ); } } /** * When a step is hidden, remove the highlightClass and 'shepherd-enabled' * and 'shepherd-target' classes * @private */ _updateStepTargetOnHide() { const target = this.target || document.body; const extraHighlightElements = this._resolvedExtraHighlightElements; this.observer?.disconnect(); this.observer = undefined; const highlightClass = this.options.highlightClass; if (highlightClass) { target.classList.remove(highlightClass); extraHighlightElements?.forEach((el) => el.classList.remove(highlightClass) ); } target.classList.remove( 'shepherd-target-click-disabled', `${this.classPrefix}shepherd-enabled`, `${this.classPrefix}shepherd-target` ); extraHighlightElements?.forEach((el) => { el.classList.remove( 'shepherd-target-click-disabled', `${this.classPrefix}shepherd-enabled`, `${this.classPrefix}shepherd-target` ); }); } }