import merge from 'deepmerge';
import { Evented } from './evented.js';
import autoBind from './utils/auto-bind.js';
import {
isElement,
isHTMLElement,
isFunction,
isUndefined
} from './utils/type-check.js';
import { bindAdvance } from './utils/bind.js';
import { parseAttachTo, normalizePrefix, uuid } from './utils/general.js';
import {
setupTooltip,
destroyTooltip,
mergeTooltipConfig
} from './utils/floating-ui.js';
import ShepherdElement from './components/shepherd-element.svelte';
/**
* A class representing steps to be added to a tour.
* @extends {Evented}
*/
export class Step extends Evented {
/**
* Create a step
* @param {Tour} tour The tour for the step
* @param {object} options The options for the step
* @param {boolean} options.arrow Whether to display the arrow for the tooltip or not. Defaults to `true`.
* @param {object} options.attachTo 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. The same will happen if your `attachTo.element` callback returns `null`, `undefined`, or a selector that does not exist in the DOM.
* 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.
* If the element to highlight does not yet exist while instantiating tour steps, you may use lazy evaluation by supplying a function to `attachTo.element`. The function will be called in the `before-show` phase.
* @param {string|HTMLElement|function} options.attachTo.element An element selector string, DOM element, or a function (returning a selector, a DOM element, `null` or `undefined`).
* @param {string} options.attachTo.on The optional direction to place the FloatingUI tooltip relative to the element.
* - Possible string values: 'top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end', 'right', 'right-start', 'right-end', 'left', 'left-start', 'left-end'
* @param {Object} options.advanceOn 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()`.
* @param {function} options.beforeShowPromise A function that returns a promise.
* When the promise resolves, the rest of the `show` code for the step will execute.
* @param {Object[]} options.buttons An array of buttons to add to the step. These will be rendered in a
* footer below the main body text.
* @param {function} options.buttons.button.action 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');
* }
* ```
* @param {string} options.buttons.button.classes Extra classes to apply to the `<a>`
* @param {boolean} options.buttons.button.disabled Should the button be disabled?
* @param {string} options.buttons.button.label The aria-label text of the button
* @param {boolean} options.buttons.button.secondary If true, a shepherd-button-secondary class is applied to the button
* @param {string} options.buttons.button.text The HTML text of the button
* @param {boolean} options.canClickTarget A boolean, that when set to false, will set `pointer-events: none` on the target
* @param {object} options.cancelIcon Options for the cancel icon
* @param {boolean} options.cancelIcon.enabled Should a cancel “✕” be shown in the header of the step?
* @param {string} options.cancelIcon.label The label to add for `aria-label`
* @param {string} options.classes A string of extra classes to add to the step's content element.
* @param {string} options.highlightClass 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.
* @param {string} options.id The string to use as the `id` for the step.
* @param {number} options.modalOverlayOpeningPadding An amount of padding to add around the modal overlay opening
* @param {number | { topLeft: number, bottomLeft: number, bottomRight: number, topRight: number }} options.modalOverlayOpeningRadius An amount of border radius to add around the modal overlay opening
* @param {object} options.floatingUIOptions Extra options to pass to FloatingUI
* @param {boolean|Object} options.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'}`
* @param {function} options.scrollToHandler A function that lets you override the default scrollTo behavior and
* define a custom action to do the scrolling, and possibly other logic.
* @param {function} options.showOn A function that, when it returns `true`, will show the step.
* If it returns false, the step will be skipped.
* @param {string} options.text The text in the body of the step. It can be one of three types:
* ```
* - HTML string
* - `HTMLElement` object
* - `Function` to be executed when the step is built. It must return one the two options above.
* ```
* @param {string} options.title The step's title. It becomes an `h3` at the top of the step. It can be one of two types:
* ```
* - HTML string
* - `Function` to be executed when the step is built. It must return HTML string.
* ```
* @param {object} options.when You can define `show`, `hide`, etc events inside `when`. For example:
* ```js
* when: {
* show: function() {
* window.scrollTo(0, 0);
* }
* }
* ```
* @return {Step} The newly created Step instance
*/
constructor(tour, options = {}) {
super(tour, options);
this.tour = tour;
this.classPrefix = this.tour.options
? normalizePrefix(this.tour.options.classPrefix)
: '';
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 {null|{}|{element, to}}
* @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._updateStepTargetOnHide();
this.trigger('destroy');
}
/**
* Returns the tour for the step
* @return {Tour} 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;
}
this._updateStepTargetOnHide();
this.trigger('hide');
}
/**
* Resolves attachTo options.
* @returns {{}|{element, on}}
* @private
*/
_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 {boolean} True if the step is open and visible
*/
isOpen() {
return Boolean(this.el && !this.el.hidden);
}
/**
* Wraps `_show` and ensures `beforeShowPromise` resolves before calling show
* @return {*|Promise}
*/
show() {
if (isFunction(this.options.beforeShowPromise)) {
return Promise.resolve(this.options.beforeShowPromise()).then(() =>
this._show()
);
}
return Promise.resolve(this._show());
}
/**
* Updates the options of the step.
*
* @param {Object} options The options for the step
*/
updateStepOptions(options) {
Object.assign(this.options, options);
if (this.shepherdElementComponent) {
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 {Element} The DOM element for the step tooltip
* @private
*/
_createTooltipContent() {
const descriptionId = `${this.id}-description`;
const labelId = `${this.id}-label`;
this.shepherdElementComponent = new ShepherdElement({
target: this.tour.options.stepsContainer || document.body,
props: {
classPrefix: this.classPrefix,
descriptionId,
labelId,
step: this,
styles: this.styles
}
});
return this.shepherdElementComponent.getElement();
}
/**
* If a custom scrollToHandler is defined, call that, otherwise do the generic
* scrollIntoView call.
*
* @param {boolean|Object} 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) {
const { element } = this._getResolvedAttachToOptions();
if (isFunction(this.options.scrollToHandler)) {
this.options.scrollToHandler(element);
} else if (
isElement(element) &&
typeof element.scrollIntoView === 'function'
) {
element.scrollIntoView(scrollToOptions);
}
}
/**
* _getClassOptions gets all possible classes for the step
* @param {Object} stepOptions The step specific options
* @returns {String} unique string from array of classes
* @private
*/
_getClassOptions(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 {Object} options The options for the step
* @private
*/
_setOptions(options = {}) {
let tourOptions =
this.tour && this.tour.options && this.tour.options.defaultStepOptions;
tourOptions = merge({}, 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) => {
this.on(event, when[event], this);
});
}
}
/**
* 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);
}
// 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() {
this.trigger('before-show');
// Force resolve to make sure the options are updated on subsequent shows.
this._resolveAttachToOptions();
this._setupElements();
if (!this.tour.modal) {
this.tour._setupModal();
}
this.tour.modal.setupForStep(this);
this._styleTargetElementForStep(this);
this.el.hidden = false;
// start scrolling to target before showing the step
if (this.options.scrollTo) {
setTimeout(() => {
this._scrollTo(this.options.scrollTo);
});
}
this.el.hidden = false;
const content = this.shepherdElementComponent.getElement();
const target = this.target || document.body;
target.classList.add(`${this.classPrefix}shepherd-enabled`);
target.classList.add(`${this.classPrefix}shepherd-target`);
content.classList.add('shepherd-enabled');
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 The step object that attaches to the element
* @private
*/
_styleTargetElementForStep(step) {
const targetElement = step.target;
if (!targetElement) {
return;
}
if (step.options.highlightClass) {
targetElement.classList.add(step.options.highlightClass);
}
targetElement.classList.remove('shepherd-target-click-disabled');
if (step.options.canClickTarget === false) {
targetElement.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;
if (this.options.highlightClass) {
target.classList.remove(this.options.highlightClass);
}
target.classList.remove(
'shepherd-target-click-disabled',
`${this.classPrefix}shepherd-enabled`,
`${this.classPrefix}shepherd-target`
);
}
}