@atlaskit/onboarding
Version:
An onboarding spotlight introduces new features to users through focused messages or multi-step tours.
152 lines (149 loc) • 5.23 kB
JavaScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { bind } from 'bind-event-listener';
import __noop from '@atlaskit/ds-lib/noop';
import { fg } from '@atlaskit/platform-feature-flags/fg';
// The minimum interval between position updates in milliseconds
const POSITION_UPDATE_INTERVAL = 200;
const getElementRect = element => {
const {
height,
left,
top,
width
} = element.getBoundingClientRect();
return {
height,
left,
top,
width
};
};
const useResizeAwareElementBox = (element, updateMethod) => {
const [box, setBox] = useState({
width: 0,
height: 0,
left: 0,
top: 0
});
useLayoutEffect(() => {
if (updateMethod === 'resizeListener') {
if (fg('scroll-lock-replacement')) {
// use setTimeout 0 to defer the state update to avoid content shifting when pages have scrollbars
// more details are https://www.loom.com/share/96a5d7c2afd74146a3c005bf20a8c69e?sid=968b00c1-e5ab-4ea0-9fe4-e534fe7088e4
setTimeout(() => {
setBox(getElementRect(element));
}, 0);
} else {
setBox(getElementRect(element));
}
}
}, [element, updateMethod]);
useEffect(() => {
const onResize = () => {
requestAnimationFrame(() => {
setBox(getElementRect(element));
});
};
if (updateMethod === 'resizeListener') {
return bind(window, {
type: 'resize',
listener: onResize
});
}
return __noop;
}, [element, updateMethod]);
return box;
};
const usePollingElementBox = (element, updateMethod) => {
// These are intentionally tracked as number primitives rather than as a shared `box` object.
// Since the requestAnimationFrame code below updates this often, we want to avoid re-renders
// when the values are the same. React uses `Object.is` to figure out if the state changed after a setState.
// If we represent this as a shared `box` object, this will re-render even if the two objects have identical contents.
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [left, setLeft] = useState(0);
const [top, setTop] = useState(0);
useLayoutEffect(() => {
if (updateMethod === 'polling') {
if (fg('scroll-lock-replacement')) {
// use setTimeout 0 to defer the state update to avoid content shifting when pages have scrollbars
// more details are https://www.loom.com/share/96a5d7c2afd74146a3c005bf20a8c69e?sid=968b00c1-e5ab-4ea0-9fe4-e534fe7088e4
setTimeout(() => {
const newBox = getElementRect(element);
setWidth(newBox.width);
setHeight(newBox.height);
setLeft(newBox.left);
setTop(newBox.top);
}, 0);
} else {
const newBox = getElementRect(element);
setWidth(newBox.width);
setHeight(newBox.height);
setLeft(newBox.left);
setTop(newBox.top);
}
}
}, [element, updateMethod]);
// Souce: https://css-tricks.com/using-requestanimationframe-with-react-hooks/
// Use useRef for mutable variables that we want to persist
// without triggering a re-render on their change
const requestRef = useRef();
const previousUpdateTimeRef = useRef();
const animate = useCallback(time => {
if (previousUpdateTimeRef.current !== undefined) {
const timeSinceLastUpdate = time - previousUpdateTimeRef.current;
if (timeSinceLastUpdate > POSITION_UPDATE_INTERVAL) {
const newBox = getElementRect(element);
setWidth(newBox.width);
setHeight(newBox.height);
setLeft(newBox.left);
setTop(newBox.top);
previousUpdateTimeRef.current = time;
}
} else {
// Initialize previousUpdateTimeRef
previousUpdateTimeRef.current = time;
}
requestRef.current = requestAnimationFrame(animate);
}, [element]);
useEffect(() => {
if (updateMethod === 'polling') {
requestRef.current = requestAnimationFrame(animate);
}
return () => {
if (requestRef.current !== undefined) {
cancelAnimationFrame(requestRef.current);
}
};
// This useEffect should only run on mount and when `element` or `updateMethod` changes.
}, [animate, element, updateMethod]);
const box = useMemo(() => ({
width,
height,
left,
top
}), [width, height, left, top]);
return box;
};
/**
* Will listen to the document resizing to see if an element has moved positions.
* Not using ResizeObserver because of IE11 support.
* @param element HTMLElement to watch when resizing.
*/
const useElementBox = (element, resizeUpdateMethod) => {
const updateMethod = resizeUpdateMethod || 'resizeListener';
const boxViaResizeListener = useResizeAwareElementBox(element, updateMethod);
const boxViaPolling = usePollingElementBox(element, updateMethod);
return updateMethod === 'resizeListener' ? boxViaResizeListener : boxViaPolling;
};
/**
* __Element box__
*
* Allows consumption of `userElementBox` hook through render props.
*
* @internal
*/
export const ElementBox = props => {
const box = useElementBox(props.element, props.resizeUpdateMethod);
return props.children(box);
};