@rently-team/shepherd.js
Version:
Guide your users through a tour of your app.
918 lines (786 loc) • 23.9 kB
text/typescript
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`
);
});
}
}