renommage de lib, rendre buildable

This commit is contained in:
Tykayn 2025-08-08 10:57:00 +02:00 committed by tykayn
parent 1706c64713
commit a89007a81b
9896 changed files with 478996 additions and 496 deletions

View file

@ -0,0 +1,67 @@
<script>
import { isFunction } from '../utils/type-check.ts';
export let config, step;
let action, classes, disabled, label, secondary, text;
$: {
action = config.action ? config.action.bind(step.tour) : null;
classes = config.classes;
disabled = config.disabled ? getConfigOption(config.disabled) : false;
label = config.label ? getConfigOption(config.label) : null;
secondary = config.secondary;
text = config.text ? getConfigOption(config.text) : null;
}
function getConfigOption(option) {
if (isFunction(option)) {
return (option = option.call(step));
}
return option;
}
</script>
<button
aria-label={label ? label : null}
class={`${classes || ''} shepherd-button ${
secondary ? 'shepherd-button-secondary' : ''
}`}
{disabled}
on:click={action}
tabindex="0"
type="button"
>
{@html text}
</button>
<style global>
.shepherd-button {
background: rgb(50, 136, 230);
border: 0;
border-radius: 3px;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
margin-right: 0.5rem;
padding: 0.5rem 1.5rem;
transition: all 0.5s ease;
}
.shepherd-button:not(:disabled):hover {
background: rgb(25, 111, 204);
color: rgba(255, 255, 255, 0.75);
}
.shepherd-button.shepherd-button-secondary {
background: rgb(241, 242, 243);
color: rgba(0, 0, 0, 0.75);
}
.shepherd-button.shepherd-button-secondary:not(:disabled):hover {
background: rgb(214, 217, 219);
color: rgba(0, 0, 0, 0.75);
}
.shepherd-button:disabled {
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,46 @@
<script>
export let cancelIcon, step;
/**
* Add a click listener to the cancel link that cancels the tour
*/
const handleCancelClick = (e) => {
e.preventDefault();
step.cancel();
};
</script>
<button
aria-label={cancelIcon.label ? cancelIcon.label : 'Close Tour'}
class="shepherd-cancel-icon"
on:click={handleCancelClick}
type="button"
>
<span aria-hidden="true">&times;</span>
</button>
<style global>
.shepherd-cancel-icon {
background: transparent;
border: none;
color: rgba(128, 128, 128, 0.75);
font-size: 2em;
cursor: pointer;
font-weight: normal;
margin: 0;
padding: 0;
transition: color 0.5s ease;
}
.shepherd-cancel-icon:hover {
color: rgba(0, 0, 0, 0.75);
}
.shepherd-has-title .shepherd-content .shepherd-cancel-icon {
color: rgba(128, 128, 128, 0.75);
}
.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover {
color: rgba(0, 0, 0, 0.75);
}
</style>

View file

@ -0,0 +1,30 @@
<script>
import ShepherdFooter from './shepherd-footer.svelte';
import ShepherdHeader from './shepherd-header.svelte';
import ShepherdText from './shepherd-text.svelte';
import { isUndefined } from '../utils/type-check.ts';
export let descriptionId, labelId, step;
</script>
<div class="shepherd-content">
{#if !isUndefined(step.options.title) || (step.options.cancelIcon && step.options.cancelIcon.enabled)}
<ShepherdHeader {labelId} {step} />
{/if}
{#if !isUndefined(step.options.text)}
<ShepherdText {descriptionId} {step} />
{/if}
{#if Array.isArray(step.options.buttons) && step.options.buttons.length}
<ShepherdFooter {step} />
{/if}
</div>
<style global>
.shepherd-content {
border-radius: 5px;
outline: none;
padding: 0;
}
</style>

View file

@ -0,0 +1,286 @@
<script>
import { onDestroy, onMount, afterUpdate } from 'svelte';
import ShepherdContent from './shepherd-content.svelte';
import { isUndefined, isString } from '../utils/type-check.ts';
const KEY_TAB = 9;
const KEY_ESC = 27;
const LEFT_ARROW = 37;
const RIGHT_ARROW = 39;
export let attachToElement,
attachTofocusableDialogElements,
classPrefix,
element,
descriptionId,
// Focusable attachTo elements
focusableAttachToElements,
firstFocusableAttachToElement,
lastFocusableAttachToElement,
// Focusable dialog elements
firstFocusableDialogElement,
focusableDialogElements,
lastFocusableDialogElement,
labelId,
step,
dataStepId;
let hasCancelIcon, hasTitle, classes;
$: {
hasCancelIcon =
step.options &&
step.options.cancelIcon &&
step.options.cancelIcon.enabled;
hasTitle = step.options && step.options.title;
}
export const getElement = () => element;
onMount(() => {
// Get all elements that are focusable
dataStepId = { [`data-${classPrefix}shepherd-step-id`]: step.id };
focusableDialogElements = [
...element.querySelectorAll(
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex="0"]'
)
];
firstFocusableDialogElement = focusableDialogElements[0];
lastFocusableDialogElement =
focusableDialogElements[focusableDialogElements.length - 1];
const attachTo = step._getResolvedAttachToOptions();
if (attachTo?.element) {
attachToElement = attachTo.element;
attachToElement.tabIndex = 0;
focusableAttachToElements = [
attachToElement,
...attachToElement.querySelectorAll(
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex="0"]'
)
];
firstFocusableAttachToElement = focusableAttachToElements[0];
lastFocusableAttachToElement =
focusableAttachToElements[focusableAttachToElements.length - 1];
// Add keydown listener to attachTo element
attachToElement.addEventListener('keydown', handleKeyDown);
}
});
onDestroy(() => {
attachToElement?.removeEventListener('keydown', handleKeyDown);
});
afterUpdate(() => {
if (classes !== step.options.classes) {
updateDynamicClasses();
}
});
function updateDynamicClasses() {
removeClasses(classes);
classes = step.options.classes;
addClasses(classes);
}
function removeClasses(classes) {
if (isString(classes)) {
const oldClasses = getClassesArray(classes);
if (oldClasses.length) {
element.classList.remove(...oldClasses);
}
}
}
function addClasses(classes) {
if (isString(classes)) {
const newClasses = getClassesArray(classes);
if (newClasses.length) {
element.classList.add(...newClasses);
}
}
}
function getClassesArray(classes) {
return classes.split(' ').filter((className) => !!className.length);
}
/**
* Setup keydown events to allow closing the modal with ESC
*
* Borrowed from this great post! https://bitsofco.de/accessible-modal-dialog/
*
* @private
*/
const handleKeyDown = (e) => {
const { tour } = step;
switch (e.keyCode) {
case KEY_TAB:
if (
(!focusableAttachToElements ||
focusableAttachToElements.length === 0) &&
focusableDialogElements.length === 0
) {
e.preventDefault();
break;
}
// Backward tab
if (e.shiftKey) {
// If at the beginning of elements in the dialog, go to last element in attachTo
// If attachToElement is undefined, circle around to the last element in the dialog.
if (
document.activeElement === firstFocusableDialogElement ||
document.activeElement.classList.contains('shepherd-element')
) {
e.preventDefault();
(
lastFocusableAttachToElement ?? lastFocusableDialogElement
).focus();
}
// If at the beginning of elements in attachTo
else if (document.activeElement === firstFocusableAttachToElement) {
e.preventDefault();
lastFocusableDialogElement.focus();
}
} else {
if (document.activeElement === lastFocusableDialogElement) {
e.preventDefault();
(
firstFocusableAttachToElement ?? firstFocusableDialogElement
).focus();
}
// If at the end of elements in attachTo
else if (document.activeElement === lastFocusableAttachToElement) {
e.preventDefault();
firstFocusableDialogElement.focus();
}
}
break;
case KEY_ESC:
if (tour.options.exitOnEsc) {
e.preventDefault();
e.stopPropagation();
step.cancel();
}
break;
case LEFT_ARROW:
if (tour.options.keyboardNavigation) {
e.preventDefault();
e.stopPropagation();
tour.back();
}
break;
case RIGHT_ARROW:
if (tour.options.keyboardNavigation) {
e.preventDefault();
e.stopPropagation();
tour.next();
}
break;
default:
break;
}
};
</script>
<dialog
aria-describedby={!isUndefined(step.options.text) ? descriptionId : null}
aria-labelledby={step.options.title ? labelId : null}
bind:this={element}
class:shepherd-has-cancel-icon={hasCancelIcon}
class:shepherd-has-title={hasTitle}
class:shepherd-element={true}
{...dataStepId}
on:keydown={handleKeyDown}
open="true"
>
{#if step.options.arrow && step.options.attachTo && step.options.attachTo.element && step.options.attachTo.on}
<div class="shepherd-arrow" data-popper-arrow></div>
{/if}
<ShepherdContent {descriptionId} {labelId} {step} />
</dialog>
<style global>
.shepherd-element {
background: #fff;
border: none;
border-radius: 5px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
margin: 0;
max-width: 400px;
opacity: 0;
outline: none;
padding: 0;
transition:
opacity 0.3s,
visibility 0.3s;
visibility: hidden;
width: 100%;
z-index: 9999;
}
.shepherd-enabled.shepherd-element {
opacity: 1;
visibility: visible;
}
.shepherd-element[data-popper-reference-hidden]:not(.shepherd-centered) {
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.shepherd-element,
.shepherd-element *,
.shepherd-element *:after,
.shepherd-element *:before {
box-sizing: border-box;
}
.shepherd-arrow,
.shepherd-arrow::before {
position: absolute;
width: 16px;
height: 16px;
z-index: -1;
}
.shepherd-arrow:before {
content: '';
transform: rotate(45deg);
background: #fff;
}
.shepherd-element[data-popper-placement^='top'] > .shepherd-arrow {
bottom: -8px;
}
.shepherd-element[data-popper-placement^='bottom'] > .shepherd-arrow {
top: -8px;
}
.shepherd-element[data-popper-placement^='left'] > .shepherd-arrow {
right: -8px;
}
.shepherd-element[data-popper-placement^='right'] > .shepherd-arrow {
left: -8px;
}
.shepherd-element.shepherd-centered > .shepherd-arrow {
opacity: 0;
}
/**
* Arrow on top of tooltip centered horizontally, with title color
*/
.shepherd-element.shepherd-has-title[data-popper-placement^='bottom']
> .shepherd-arrow::before {
background-color: #e6e6e6;
}
.shepherd-target-click-disabled.shepherd-enabled.shepherd-target,
.shepherd-target-click-disabled.shepherd-enabled.shepherd-target * {
pointer-events: none;
}
</style>

View file

@ -0,0 +1,29 @@
<script>
import ShepherdButton from './shepherd-button.svelte';
export let step;
$: buttons = step.options.buttons;
</script>
<footer class="shepherd-footer">
{#if buttons}
{#each buttons as config}
<ShepherdButton {config} {step} />
{/each}
{/if}
</footer>
<style global>
.shepherd-footer {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
display: flex;
justify-content: flex-end;
padding: 0 0.75rem 0.75rem;
}
.shepherd-footer .shepherd-button:last-child {
margin-right: 0;
}
</style>

View file

@ -0,0 +1,39 @@
<script>
import ShepherdCancelIcon from './shepherd-cancel-icon.svelte';
import ShepherdTitle from './shepherd-title.svelte';
export let labelId, step;
let title, cancelIcon;
$: {
title = step.options.title;
cancelIcon = step.options.cancelIcon;
}
</script>
<header class="shepherd-header">
{#if title}
<ShepherdTitle {labelId} {title} />
{/if}
{#if cancelIcon && cancelIcon.enabled}
<ShepherdCancelIcon {cancelIcon} {step} />
{/if}
</header>
<style global>
.shepherd-header {
align-items: center;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
display: flex;
justify-content: flex-end;
line-height: 2em;
padding: 0.75rem 0.75rem 0;
}
.shepherd-has-title .shepherd-content .shepherd-header {
background: #e6e6e6;
padding: 1em;
}
</style>

View file

@ -0,0 +1,324 @@
<script>
import { makeOverlayPath } from '../utils/overlay-path.ts';
export let element, openingProperties;
let modalIsVisible = false;
let rafId = undefined;
let pathDefinition;
$: pathDefinition = makeOverlayPath(openingProperties);
closeModalOpening();
export const getElement = () => element;
export function closeModalOpening() {
openingProperties = [
{
width: 0,
height: 0,
x: 0,
y: 0,
r: 0
}
];
}
/**
* Hide the modal overlay
*/
export function hide() {
modalIsVisible = false;
// Ensure we cleanup all event listeners when we hide the modal
_cleanupStepEventListeners();
}
/**
* Uses the bounds of the element we want the opening overtop of to set the dimensions of the opening and position it
* @param {Number} modalOverlayOpeningPadding An amount of padding to add around the modal overlay opening
* @param {Number | { topLeft: Number, bottomLeft: Number, bottomRight: Number, topRight: Number }} modalOverlayOpeningRadius An amount of border radius to add around the modal overlay opening
* @param {Number} modalOverlayOpeningXOffset An amount to offset the modal overlay opening in the x-direction
* @param {Number} modalOverlayOpeningYOffset An amount to offset the modal overlay opening in the y-direction
* @param {HTMLElement} scrollParent The scrollable parent of the target element
* @param {HTMLElement} targetElement The element the opening will expose
*/
export function positionModal(
modalOverlayOpeningPadding = 0,
modalOverlayOpeningRadius = 0,
modalOverlayOpeningXOffset = 0,
modalOverlayOpeningYOffset = 0,
scrollParent,
targetElement,
extraHighlights
) {
if (targetElement) {
const elementsToHighlight = [targetElement, ...(extraHighlights || [])];
openingProperties = [];
for (const element of elementsToHighlight) {
if (!element) continue;
// Skip duplicate elements
if (
elementsToHighlight.indexOf(element) !==
elementsToHighlight.lastIndexOf(element)
) {
continue;
}
const { y, height } = _getVisibleHeight(element, scrollParent);
const { x, width, left } = element.getBoundingClientRect();
// Check if the element is contained by another element
const isContained = elementsToHighlight.some((otherElement) => {
if (otherElement === element) return false;
const otherRect = otherElement.getBoundingClientRect();
return (
x >= otherRect.left &&
x + width <= otherRect.right &&
y >= otherRect.top &&
y + height <= otherRect.bottom
);
});
if (isContained) continue;
// getBoundingClientRect is not consistent. Some browsers use x and y, while others use left and top
openingProperties.push({
width: width + modalOverlayOpeningPadding * 2,
height: height + modalOverlayOpeningPadding * 2,
x:
(x || left) +
modalOverlayOpeningXOffset -
modalOverlayOpeningPadding,
y: y + modalOverlayOpeningYOffset - modalOverlayOpeningPadding,
r: modalOverlayOpeningRadius
});
}
} else {
closeModalOpening();
}
}
/**
* If modal is enabled, setup the svg mask opening and modal overlay for the step
* @param {Step} step The step instance
*/
export function setupForStep(step) {
// Ensure we move listeners from the previous step, before we setup new ones
_cleanupStepEventListeners();
if (step.tour.options.useModalOverlay) {
_styleForStep(step);
show();
} else {
hide();
}
}
/**
* Show the modal overlay
*/
export function show() {
modalIsVisible = true;
}
const _preventModalBodyTouch = (e) => {
e.preventDefault();
};
const _preventModalOverlayTouch = (e) => {
e.stopPropagation();
};
/**
* Add touchmove event listener
* @private
*/
function _addStepEventListeners() {
// Prevents window from moving on touch.
window.addEventListener('touchmove', _preventModalBodyTouch, {
passive: false
});
}
/**
* Cancel the requestAnimationFrame loop and remove touchmove event listeners
* @private
*/
function _cleanupStepEventListeners() {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = undefined;
}
window.removeEventListener('touchmove', _preventModalBodyTouch, {
passive: false
});
}
/**
* Style the modal for the step
* @param {Step} step The step to style the opening for
* @private
*/
function _styleForStep(step) {
const {
modalOverlayOpeningPadding,
modalOverlayOpeningRadius,
modalOverlayOpeningXOffset = 0,
modalOverlayOpeningYOffset = 0
} = step.options;
const iframeOffset = _getIframeOffset(step.target);
const scrollParent = _getScrollParent(step.target);
// Setup recursive function to call requestAnimationFrame to update the modal opening position
const rafLoop = () => {
rafId = undefined;
positionModal(
modalOverlayOpeningPadding,
modalOverlayOpeningRadius,
modalOverlayOpeningXOffset + iframeOffset.left,
modalOverlayOpeningYOffset + iframeOffset.top,
scrollParent,
step.target,
step._resolvedExtraHighlightElements
);
rafId = requestAnimationFrame(rafLoop);
};
rafLoop();
_addStepEventListeners();
}
/**
* Find the closest scrollable parent element
* @param {HTMLElement} element The target element
* @returns {HTMLElement}
* @private
*/
function _getScrollParent(element) {
if (!element) {
return null;
}
const isHtmlElement = element instanceof HTMLElement;
const overflowY =
isHtmlElement && window.getComputedStyle(element).overflowY;
const isScrollable = overflowY !== 'hidden' && overflowY !== 'visible';
if (isScrollable && element.scrollHeight >= element.clientHeight) {
return element;
}
return _getScrollParent(element.parentElement);
}
/**
* Get the top and left offset required to position the modal overlay cutout
* when the target element is within an iframe
* @param {HTMLElement} element The target element
* @private
*/
function _getIframeOffset(element) {
let offset = {
top: 0,
left: 0
};
if (!element) {
return offset;
}
let targetWindow = element.ownerDocument.defaultView;
while (targetWindow !== window.top) {
const targetIframe = targetWindow?.frameElement;
if (targetIframe) {
const targetIframeRect = targetIframe.getBoundingClientRect();
offset.top += targetIframeRect.top + (targetIframeRect.scrollTop ?? 0);
offset.left +=
targetIframeRect.left + (targetIframeRect.scrollLeft ?? 0);
}
targetWindow = targetWindow.parent;
}
return offset;
}
/**
* Get the visible height of the target element relative to its scrollParent.
* If there is no scroll parent, the height of the element is returned.
*
* @param {HTMLElement} element The target element
* @param {HTMLElement} [scrollParent] The scrollable parent element
* @returns {{y: number, height: number}}
* @private
*/
function _getVisibleHeight(element, scrollParent) {
const elementRect = element.getBoundingClientRect();
let top = elementRect.y || elementRect.top;
let bottom = elementRect.bottom || top + elementRect.height;
if (scrollParent) {
const scrollRect = scrollParent.getBoundingClientRect();
const scrollTop = scrollRect.y || scrollRect.top;
const scrollBottom = scrollRect.bottom || scrollTop + scrollRect.height;
top = Math.max(top, scrollTop);
bottom = Math.min(bottom, scrollBottom);
}
const height = Math.max(bottom - top, 0); // Default to 0 if height is negative
return { y: top, height };
}
</script>
<svg
bind:this={element}
class={`${
modalIsVisible ? 'shepherd-modal-is-visible' : ''
} shepherd-modal-overlay-container`}
on:touchmove={_preventModalOverlayTouch}
>
<path d={pathDefinition} />
</svg>
<style global>
.shepherd-modal-overlay-container {
height: 0;
left: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
position: fixed;
top: 0;
transition:
all 0.3s ease-out,
height 0ms 0.3s,
opacity 0.3s 0ms;
width: 100vw;
z-index: 9997;
}
.shepherd-modal-overlay-container.shepherd-modal-is-visible {
height: 100vh;
opacity: 0.5;
transition:
all 0.3s ease-out,
height 0s 0s,
opacity 0.3s 0s;
transform: translateZ(0);
}
.shepherd-modal-overlay-container.shepherd-modal-is-visible path {
pointer-events: all;
}
</style>

View file

@ -0,0 +1,39 @@
<script>
import { afterUpdate } from 'svelte';
import { isHTMLElement, isFunction } from '../utils/type-check.ts';
export let descriptionId, element, step;
afterUpdate(() => {
let { text } = step.options;
if (isFunction(text)) {
text = text.call(step);
}
if (isHTMLElement(text)) {
element.appendChild(text);
} else {
element.innerHTML = text;
}
});
</script>
<div bind:this={element} class="shepherd-text" id={descriptionId}></div>
<style global>
.shepherd-text {
color: rgba(0, 0, 0, 0.75);
font-size: 1rem;
line-height: 1.3em;
padding: 0.75em;
}
.shepherd-text p {
margin-top: 0;
}
.shepherd-text p:last-child {
margin-bottom: 0;
}
</style>

View file

@ -0,0 +1,29 @@
<script>
import { afterUpdate } from 'svelte';
import { isFunction } from '../utils/type-check.ts';
export let labelId, element, title;
afterUpdate(() => {
if (isFunction(title)) {
title = title();
}
element.innerHTML = title;
});
</script>
<!-- svelte-ignore a11y-missing-content -->
<h3 bind:this={element} id={labelId} class="shepherd-title"></h3>
<style global>
.shepherd-title {
color: rgba(0, 0, 0, 0.75);
display: flex;
font-size: 1rem;
font-weight: normal;
flex: 1 0 auto;
margin: 0;
padding: 0;
}
</style>

View file

@ -0,0 +1,95 @@
import { isUndefined } from './utils/type-check.ts';
export type Bindings = {
[key: string]: Array<{ handler: () => void; ctx?: unknown; once?: boolean }>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyHandler = (...args: any[]) => void;
export class Evented {
declare bindings: Bindings;
/**
* Adds an event listener for the given event string.
*
* @param {string} event
* @param {Function} handler
* @param ctx
* @param {boolean} once
* @returns
*/
on(event: string, handler: AnyHandler, ctx?: unknown, once = false) {
if (isUndefined(this.bindings)) {
this.bindings = {};
}
if (isUndefined(this.bindings[event])) {
this.bindings[event] = [];
}
this.bindings[event]?.push({ handler, ctx, once });
return this;
}
/**
* Adds an event listener that only fires once for the given event string.
*
* @param {string} event
* @param {Function} handler
* @param ctx
* @returns
*/
once(event: string, handler: AnyHandler, ctx?: unknown) {
return this.on(event, handler, ctx, true);
}
/**
* Removes an event listener for the given event string.
*
* @param {string} event
* @param {Function} handler
* @returns
*/
off(event: string, handler?: AnyHandler) {
if (isUndefined(this.bindings) || isUndefined(this.bindings[event])) {
return this;
}
if (isUndefined(handler)) {
delete this.bindings[event];
} else {
this.bindings[event]?.forEach((binding, index) => {
if (binding.handler === handler) {
this.bindings[event]?.splice(index, 1);
}
});
}
return this;
}
/**
* Triggers an event listener for the given event string.
*
* @param {string} event
* @returns
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
trigger(event: string, ...args: any[]) {
if (!isUndefined(this.bindings) && this.bindings[event]) {
this.bindings[event]?.forEach((binding, index) => {
const { ctx, handler, once } = binding;
const context = ctx || this;
handler.apply(context, args as []);
if (once) {
this.bindings[event]?.splice(index, 1);
}
});
}
return this;
}
}

View file

@ -0,0 +1,15 @@
import { Shepherd, Tour } from './tour.ts';
import { StepNoOp, TourNoOp } from './utils/general.ts';
import { Step } from './step.ts';
const isServerSide = typeof window === 'undefined';
Shepherd.Step = (isServerSide ? StepNoOp : Step) as unknown as typeof Step;
Shepherd.Tour = (isServerSide ? TourNoOp : Tour) as unknown as typeof Tour;
export { ShepherdBase } from './tour.ts';
export default Shepherd;
// Reexport types so they can be more easily used.
export type * from './evented.ts';
export type * from './step.ts';
export type * from './tour.ts';

View file

@ -0,0 +1,726 @@
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
} 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 } 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 dont 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` doesnt 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;
/**
* You can define `show`, `hide`, etc events inside `when`. For example:
* ```js
* when: {
* show: function() {
* window.scrollTo(0, 0);
* }
* }
* ```
*/
when?: StepOptionsWhen;
}
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;
}
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;
}
/**
* 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;
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._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;
}
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);
}
/**
* Wraps `_show` and ensures `beforeShowPromise` resolves before calling show
*/
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 {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);
});
}
}
/**
* 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._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`);
});
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;
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`
);
});
}
}

View file

@ -0,0 +1,476 @@
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;
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 };

View file

@ -0,0 +1,19 @@
/**
* Binds all the methods on a JS Class to the `this` context of the class.
* Adapted from https://github.com/sindresorhus/auto-bind
* @param self The `this` context of the class
* @return The `this` context of the class
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function autoBind(self: any) {
const keys = Object.getOwnPropertyNames(self.constructor.prototype);
for (let i = 0; i < keys.length; i++) {
const key = keys[i] as keyof typeof self;
const val = self[key];
if (key !== 'constructor' && typeof val === 'function') {
self[key] = val.bind(self);
}
}
return self;
}

View file

@ -0,0 +1,64 @@
import type { Step } from '../step.ts';
import { isUndefined } from './type-check.ts';
/**
* Sets up the handler to determine if we should advance the tour
* @param step The step instance
* @param selector
* @private
*/
function _setupAdvanceOnHandler(step: Step, selector?: string) {
return (event: Event) => {
if (step.isOpen()) {
const targetIsEl = step.el && event.currentTarget === step.el;
const targetIsSelector =
!isUndefined(selector) &&
(event.currentTarget as HTMLElement).matches(selector);
if (targetIsSelector || targetIsEl) {
step.tour.next();
}
}
};
}
/**
* Bind the event handler for advanceOn
* @param step The step instance
*/
export function bindAdvance(step: Step) {
// An empty selector matches the step element
const { event, selector } = step.options.advanceOn || {};
if (event) {
const handler = _setupAdvanceOnHandler(step, selector);
// TODO: this should also bind/unbind on show/hide
let el: Element | null = null;
if (!isUndefined(selector)) {
el = document.querySelector(selector);
if (!el) {
return console.error(
`No element was found for the selector supplied to advanceOn: ${selector}`
);
}
}
if (el) {
el.addEventListener(event, handler);
step.on('destroy', () => {
return (el as HTMLElement).removeEventListener(event, handler);
});
} else {
document.body.addEventListener(event, handler, true);
step.on('destroy', () => {
return document.body.removeEventListener(event, handler, true);
});
}
} else {
return console.error(
'advanceOn was defined, but no event name was passed.'
);
}
}

View file

@ -0,0 +1,32 @@
import type { Tour } from '../tour.ts';
import { isHTMLElement } from './type-check.ts';
/**
* Cleanup the steps and set pointerEvents back to 'auto'
* @param tour The tour object
*/
export function cleanupSteps(tour: Tour) {
if (tour) {
const { steps } = tour;
steps.forEach((step) => {
if (
step.options &&
step.options.canClickTarget === false &&
step.options.attachTo
) {
if (isHTMLElement(step.target)) {
step.target.classList.remove('shepherd-target-click-disabled');
}
if (step._resolvedExtraHighlightElements) {
step._resolvedExtraHighlightElements.forEach((element) => {
if (isHTMLElement(element)) {
element.classList.remove('shepherd-target-click-disabled');
}
});
}
}
});
}
}

View file

@ -0,0 +1,240 @@
import { deepmerge } from 'deepmerge-ts';
import { shouldCenterStep } from './general.ts';
import {
autoUpdate,
arrow,
computePosition,
flip,
autoPlacement,
limitShift,
shift,
type ComputePositionConfig,
type MiddlewareData,
type Placement,
type Alignment
} from '@floating-ui/dom';
import type { Step, StepOptions, StepOptionsAttachTo } from '../step.ts';
import { isHTMLElement } from './type-check.ts';
/**
* Determines options for the tooltip and initializes event listeners.
*
* @param step The step instance
*/
export function setupTooltip(step: Step): ComputePositionConfig {
if (step.cleanup) {
step.cleanup();
}
const attachToOptions = step._getResolvedAttachToOptions();
let target = attachToOptions.element as HTMLElement;
const floatingUIOptions = getFloatingUIOptions(attachToOptions, step);
const shouldCenter = shouldCenterStep(attachToOptions);
if (shouldCenter) {
target = document.body;
// @ts-expect-error TODO: fix this type error when we type Svelte
const content = step.shepherdElementComponent.getElement();
content.classList.add('shepherd-centered');
}
step.cleanup = autoUpdate(target, step.el as HTMLElement, () => {
// The element might have already been removed by the end of the tour.
if (!step.el) {
step.cleanup?.();
return;
}
setPosition(target, step, floatingUIOptions, shouldCenter);
});
step.target = attachToOptions.element as HTMLElement;
return floatingUIOptions;
}
/**
* Merge tooltip options handling nested keys.
*
* @param tourOptions - The default tour options.
* @param options - Step specific options.
*
* @return {floatingUIOptions: FloatingUIOptions}
*/
export function mergeTooltipConfig(
tourOptions: StepOptions,
options: StepOptions
): { floatingUIOptions: ComputePositionConfig } {
return {
floatingUIOptions: deepmerge(
tourOptions.floatingUIOptions || {},
options.floatingUIOptions || {}
)
};
}
/**
* Cleanup function called when the step is closed/destroyed.
*
* @param step
*/
export function destroyTooltip(step: Step) {
if (step.cleanup) {
step.cleanup();
}
step.cleanup = null;
}
function setPosition(
target: HTMLElement,
step: Step,
floatingUIOptions: ComputePositionConfig,
shouldCenter: boolean
) {
return (
computePosition(target, step.el as HTMLElement, floatingUIOptions)
.then(floatingUIposition(step, shouldCenter))
// Wait before forcing focus.
.then(
(step: Step) =>
new Promise<Step>((resolve) => {
setTimeout(() => resolve(step), 300);
})
)
// Replaces focusAfterRender modifier.
.then((step: Step) => {
if (step?.el) {
step.el.tabIndex = 0;
step.el.focus({ preventScroll: true });
}
})
);
}
function floatingUIposition(step: Step, shouldCenter: boolean) {
return ({
x,
y,
placement,
middlewareData
}: {
x: number;
y: number;
placement: Placement;
middlewareData: MiddlewareData;
}) => {
if (!step.el) {
return step;
}
if (shouldCenter) {
Object.assign(step.el.style, {
position: 'fixed',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)'
});
} else {
Object.assign(step.el.style, {
position: 'absolute',
left: `${x}px`,
top: `${y}px`
});
}
step.el.dataset['popperPlacement'] = placement;
placeArrow(step.el, middlewareData);
return step;
};
}
function placeArrow(el: HTMLElement, middlewareData: MiddlewareData) {
const arrowEl = el.querySelector('.shepherd-arrow');
if (isHTMLElement(arrowEl) && middlewareData.arrow) {
const { x: arrowX, y: arrowY } = middlewareData.arrow;
Object.assign(arrowEl.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : ''
});
}
}
/**
* Gets the `Floating UI` options from a set of base `attachTo` options
* @param attachToOptions
* @param step The step instance
* @private
*/
export function getFloatingUIOptions(
attachToOptions: StepOptionsAttachTo,
step: Step
): ComputePositionConfig {
const options: ComputePositionConfig = {
strategy: 'absolute'
};
options.middleware = [];
const arrowEl = addArrow(step);
const shouldCenter = shouldCenterStep(attachToOptions);
const hasAutoPlacement = attachToOptions.on?.includes('auto');
const hasEdgeAlignment =
attachToOptions?.on?.includes('-start') ||
attachToOptions?.on?.includes('-end');
if (!shouldCenter) {
if (hasAutoPlacement) {
options.middleware.push(
autoPlacement({
crossAxis: true,
alignment: hasEdgeAlignment
? (attachToOptions?.on?.split('-').pop() as Alignment)
: null
})
);
} else {
options.middleware.push(flip());
}
options.middleware.push(
// Replicate PopperJS default behavior.
shift({
limiter: limitShift(),
crossAxis: true
})
);
if (arrowEl) {
const arrowOptions =
typeof step.options.arrow === 'object'
? step.options.arrow
: { padding: 4 };
options.middleware.push(
arrow({
element: arrowEl,
padding: hasEdgeAlignment ? arrowOptions.padding : 0
})
);
}
if (!hasAutoPlacement) options.placement = attachToOptions.on as Placement;
}
return deepmerge(options, step.options.floatingUIOptions || {});
}
function addArrow(step: Step) {
if (step.options.arrow && step.el) {
return step.el.querySelector('.shepherd-arrow');
}
return false;
}

View file

@ -0,0 +1,103 @@
import { type Tour, type TourOptions } from '../tour.ts';
import {
type StepOptionsAttachTo,
type Step,
type StepOptions
} from '../step.ts';
import { isFunction, isString } from './type-check.ts';
export class StepNoOp {
constructor(_options: StepOptions) {}
}
export class TourNoOp {
constructor(_tour: Tour, _options: TourOptions) {}
}
/**
* Ensure class prefix ends in `-`
* @param prefix - The prefix to prepend to the class names generated by nano-css
* @return The prefix ending in `-`
*/
export function normalizePrefix(prefix?: string) {
if (!isString(prefix) || prefix === '') {
return '';
}
return prefix.charAt(prefix.length - 1) !== '-' ? `${prefix}-` : prefix;
}
/**
* Resolves attachTo options, converting element option value to a qualified HTMLElement.
* @param step - The step instance
* @returns {{}|{element, on}}
* `element` is a qualified HTML Element
* `on` is a string position value
*/
export function parseAttachTo(step: Step) {
const options = step.options.attachTo || {};
const returnOpts = Object.assign({}, options);
if (isFunction(returnOpts.element)) {
// Bind the callback to step so that it has access to the object, to enable running additional logic
returnOpts.element = returnOpts.element.call(step);
}
if (isString(returnOpts.element)) {
// Can't override the element in user opts reference because we can't
// guarantee that the element will exist in the future.
try {
returnOpts.element = document.querySelector(
returnOpts.element
) as HTMLElement;
} catch (e) {
// TODO
}
if (!returnOpts.element) {
console.error(
`The element for this Shepherd step was not found ${options.element}`
);
}
}
return returnOpts;
}
/*
* Resolves the step's `extraHighlights` option, converting any locator values to HTMLElements.
*/
export function parseExtraHighlights(step: Step): HTMLElement[] {
if (step.options.extraHighlights) {
return step.options.extraHighlights.flatMap((highlight) => {
return Array.from(document.querySelectorAll(highlight)) as HTMLElement[];
});
}
return [];
}
/**
* Checks if the step should be centered or not. Does not trigger attachTo.element evaluation, making it a pure
* alternative for the deprecated step.isCentered() method.
*/
export function shouldCenterStep(resolvedAttachToOptions: StepOptionsAttachTo) {
if (
resolvedAttachToOptions === undefined ||
resolvedAttachToOptions === null
) {
return true;
}
return !resolvedAttachToOptions.element || !resolvedAttachToOptions.on;
}
/**
* Create a unique id for steps, tours, modals, etc
*/
export function uuid() {
let d = Date.now();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c == 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}

View file

@ -0,0 +1,59 @@
interface OverlayPathParams {
height: number;
r?:
| number
| {
bottomLeft: number;
bottomRight: number;
topLeft: number;
topRight: number;
};
x?: number;
y?: number;
width: number;
}
/**
* Generates the svg path data for a rounded rectangle overlay
* @param dimension - Dimensions of rectangle.
* @param dimension.width - Width.
* @param dimension.height - Height.
* @param dimension.x - Offset from top left corner in x axis. default 0.
* @param dimension.y - Offset from top left corner in y axis. default 0.
* @param dimension.r - Corner Radius. Keep this smaller than half of width or height.
* @returns Rounded rectangle overlay path data.
*/
export function makeOverlayPath(overlayPaths: OverlayPathParams[]) {
let openings = '';
const { innerWidth: w, innerHeight: h } = window;
overlayPaths.forEach((overlayPath) => {
const { width, height, x = 0, y = 0, r = 0 } = overlayPath;
const {
topLeft = 0,
topRight = 0,
bottomRight = 0,
bottomLeft = 0
} = typeof r === 'number'
? { topLeft: r, topRight: r, bottomRight: r, bottomLeft: r }
: r;
openings += `M${x + topLeft},${y}\
a${topLeft},${topLeft},0,0,0-${topLeft},${topLeft}\
V${height + y - bottomLeft}\
a${bottomLeft},${bottomLeft},0,0,0,${bottomLeft},${bottomLeft}\
H${width + x - bottomRight}\
a${bottomRight},${bottomRight},0,0,0,${bottomRight}-${bottomRight}\
V${y + topRight}\
a${topRight},${topRight},0,0,0-${topRight}-${topRight}\
Z`;
});
return `M${w},${h}\
H0\
V0\
H${w}\
V${h}\
Z\
${openings}`.replace(/\s/g, '');
}

View file

@ -0,0 +1,40 @@
/**
* Checks if `value` is classified as an `Element`.
* @param value The param to check if it is an Element
*/
export function isElement<T>(value: T | Element): value is Element {
return value instanceof Element;
}
/**
* Checks if `value` is classified as an `HTMLElement`.
* @param value The param to check if it is an HTMLElement
*/
export function isHTMLElement<T>(value: T | HTMLElement): value is HTMLElement {
return value instanceof HTMLElement;
}
/**
* Checks if `value` is classified as a `Function` object.
* @param value The param to check if it is a function
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export function isFunction<T>(value: T | Function): value is Function {
return typeof value === 'function';
}
/**
* Checks if `value` is classified as a `String` object.
* @param value The param to check if it is a string
*/
export function isString<T>(value: T | string): value is string {
return typeof value === 'string';
}
/**
* Checks if `value` is undefined.
* @param value The param to check if it is undefined
*/
export function isUndefined<T>(value: T | undefined): value is undefined {
return value === undefined;
}