react-joyride
Version:
Create guided tours for your apps
278 lines (221 loc) • 6.36 kB
text/typescript
import scroll from 'scroll';
import scrollParent from 'scrollparent';
export function canUseDOM() {
return !!(typeof window !== 'undefined' && window.document?.createElement);
}
/**
* Find the bounding client rect
*/
export function getClientRect(element: HTMLElement | null) {
if (!element) {
return null;
}
return element.getBoundingClientRect();
}
/**
* Helper function to get the browser-normalized "document height"
*/
export function getDocumentHeight(median = false): number {
const { body, documentElement } = document;
if (!body || !documentElement) {
return 0;
}
if (median) {
const heights = [
body.scrollHeight,
body.offsetHeight,
documentElement.clientHeight,
documentElement.scrollHeight,
documentElement.offsetHeight,
].sort((a, b) => a - b);
const middle = Math.floor(heights.length / 2);
if (heights.length % 2 === 0) {
return (heights[middle - 1] + heights[middle]) / 2;
}
return heights[middle];
}
return Math.max(
body.scrollHeight,
body.offsetHeight,
documentElement.clientHeight,
documentElement.scrollHeight,
documentElement.offsetHeight,
);
}
/**
* Find and return the target DOM element based on a step's 'target'.
*/
export function getElement(element: string | HTMLElement): HTMLElement | null {
if (typeof element === 'string') {
try {
return document.querySelector(element);
} catch (error: any) {
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.error(error);
}
return null;
}
}
return element;
}
/**
* Get computed style property
*/
export function getStyleComputedProperty(el: HTMLElement): CSSStyleDeclaration | null {
if (!el || el.nodeType !== 1) {
return null;
}
return getComputedStyle(el);
}
/**
* Get scroll parent with fix
*/
export function getScrollParent(
element: HTMLElement | null,
skipFix: boolean,
forListener?: boolean,
) {
if (!element) {
return scrollDocument();
}
const parent = scrollParent(element) as HTMLElement;
if (parent) {
if (parent.isSameNode(scrollDocument())) {
if (forListener) {
return document;
}
return scrollDocument();
}
const hasScrolling = parent.scrollHeight > parent.offsetHeight;
if (!hasScrolling && !skipFix) {
parent.style.overflow = 'initial';
return scrollDocument();
}
}
return parent;
}
/**
* Check if the element has custom scroll parent
*/
export function hasCustomScrollParent(element: HTMLElement | null, skipFix: boolean): boolean {
if (!element) {
return false;
}
const parent = getScrollParent(element, skipFix);
return parent ? !parent.isSameNode(scrollDocument()) : false;
}
/**
* Check if the element has custom offset parent
*/
export function hasCustomOffsetParent(element: HTMLElement): boolean {
return element.offsetParent !== document.body;
}
/**
* Check if an element has fixed/sticky position
*/
export function hasPosition(el: HTMLElement | Node | null, type: string = 'fixed'): boolean {
if (!el || !(el instanceof HTMLElement)) {
return false;
}
const { nodeName } = el;
const styles = getStyleComputedProperty(el);
if (nodeName === 'BODY' || nodeName === 'HTML') {
return false;
}
if (styles && styles.position === type) {
return true;
}
if (!el.parentNode) {
return false;
}
return hasPosition(el.parentNode, type);
}
/**
* Check if the element is visible
*/
export function isElementVisible(element: HTMLElement): element is HTMLElement {
if (!element) {
return false;
}
let parentElement: HTMLElement | null = element;
while (parentElement) {
if (parentElement === document.body) {
break;
}
if (parentElement instanceof HTMLElement) {
const { display, visibility } = getComputedStyle(parentElement);
if (display === 'none' || visibility === 'hidden') {
return false;
}
}
parentElement = parentElement.parentElement ?? null;
}
return true;
}
/**
* Find and return the target DOM element based on a step's 'target'.
*/
export function getElementPosition(
element: HTMLElement | null,
offset: number,
skipFix: boolean,
): number {
const elementRect = getClientRect(element);
const parent = getScrollParent(element, skipFix);
const hasScrollParent = hasCustomScrollParent(element, skipFix);
const isFixedTarget = hasPosition(element);
let parentTop = 0;
let top = elementRect?.top ?? 0;
if (hasScrollParent && isFixedTarget) {
const offsetTop = element?.offsetTop ?? 0;
const parentScrollTop = (parent as HTMLElement)?.scrollTop ?? 0;
top = offsetTop - parentScrollTop;
} else if (parent instanceof HTMLElement) {
parentTop = parent.scrollTop;
if (!hasScrollParent && !hasPosition(element)) {
top += parentTop;
}
if (!parent.isSameNode(scrollDocument())) {
top += scrollDocument().scrollTop;
}
}
return Math.floor(top - offset);
}
/**
* Get the scrollTop position
*/
export function getScrollTo(element: HTMLElement | null, offset: number, skipFix: boolean): number {
if (!element) {
return 0;
}
const { offsetTop = 0, scrollTop = 0 } = scrollParent(element) ?? {};
let top = element.getBoundingClientRect().top + scrollTop;
if (!!offsetTop && (hasCustomScrollParent(element, skipFix) || hasCustomOffsetParent(element))) {
top -= offsetTop;
}
const output = Math.floor(top - offset);
return output < 0 ? 0 : output;
}
export function scrollDocument(): Element | HTMLElement {
return document.scrollingElement ?? document.documentElement;
}
/**
* Scroll to position
*/
export function scrollTo(
value: number,
options: { duration?: number; element: Element | HTMLElement },
): Promise<void> {
const { duration, element } = options;
return new Promise((resolve, reject) => {
const { scrollTop } = element;
const limit = value > scrollTop ? value - scrollTop : scrollTop - value;
scroll.top(element as HTMLElement, value, { duration: limit < 100 ? 50 : duration }, error => {
if (error && error.message !== 'Element already at target scroll position') {
return reject(error);
}
return resolve();
});
});
}