@rently-team/shepherd.js
Version:
Guide your users through a tour of your app.
341 lines (288 loc) • 8.97 kB
text/typescript
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,
offset,
hide,
size
} 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.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`
});
}
// Will hide the popup when the target is not visible
// and will make it visbile when the target appears again
if (middlewareData.hide) {
Object.assign(step.el.style, {
visibility: middlewareData.hide.referenceHidden ? 'hidden' : 'visible'
});
}
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({fallbackAxisSideDirection: 'start'}));
}
if (!attachToOptions.strict)
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 : 22
})
);
}
options.middleware.push(offset(step.options.offset || 0));
options.middleware.push(hide({ strategy: 'referenceHidden' }));
options.middleware.push(size())
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;
}
export function positionOverlay(step: Step): ComputePositionConfig | void {
const overlay = step._overlay?.element as HTMLElement;
const target = step._resolvedAttachTo?.element as HTMLElement;
const paddingX = step.options.overlay?.paddingX || 0;
const paddingY = step.options.overlay?.paddingY || 0;
if (!step._overlay) return;
step._overlay?.cleanup?.();
const options: ComputePositionConfig = {
placement: 'top-start',
middleware: [hide({ strategy: 'referenceHidden' })]
};
const cleanup = autoUpdate(target, overlay, () => {
if (!target || !overlay) {
step._overlay?.cleanup?.();
return;
}
computePosition(target, overlay, options).then(({ middlewareData }) => {
const targetRect = target.getBoundingClientRect();
const scrollContainer = getScrollableAncestor(target);
const containerRect = scrollContainer?.getBoundingClientRect() ?? {
top: 0,
left: 0,
right: window.innerWidth,
bottom: window.innerHeight,
};
const isHidden = middlewareData.hide?.referenceHidden || false;
if (isHidden) {
overlay.style.visibility = 'hidden';
return;
}
const visibleLeft = Math.max(targetRect.left - paddingX, containerRect.left);
const visibleTop = Math.max(targetRect.top - paddingY, containerRect.top);
const visibleRight = Math.min(targetRect.right + paddingX, containerRect.right);
const visibleBottom = Math.min(targetRect.bottom + paddingY, containerRect.bottom);
const visibleWidth = Math.max(0, visibleRight - visibleLeft);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
Object.assign(overlay.style, {
position: 'absolute',
left: `${visibleLeft + window.scrollX}px`,
top: `${visibleTop + window.scrollY}px`,
width: `${visibleWidth}px`,
height: `${visibleHeight}px`,
visibility: 'visible',
padding: `${paddingY}px ${paddingX}px`,
boxSizing: 'border-box',
});
});
});
step._overlay.cleanup = cleanup;
return options;
}
const getScrollableAncestor = (el: HTMLElement): HTMLElement | null => {
let parent = el.parentElement;
while (parent) {
const style = getComputedStyle(parent);
const overflowY = style.overflowY;
const overflowX = style.overflowX;
const isScrollableStyle = /(auto|scroll|overlay)/.test(overflowY + overflowX);
const isActuallyOverflowing =
parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth;
if (isScrollableStyle && isActuallyOverflowing) {
return parent;
}
parent = parent.parentElement;
}
return null;
};