@selfcommunity/react-ui
Version:
React UI Components to integrate a Community created with SelfCommunity Platform.
356 lines (355 loc) • 12.3 kB
JavaScript
import { jsx as _jsx } from "react/jsx-runtime";
/**
* Sticky Boxes with sensible behaviour if the content is bigger than the viewport.
*
* Use as a Component:
*
* import StickyBox from "shared/StickyBox";
*
* const Page = () => (
* <div className="row">
* <StickyBox offsetTop={20} offsetBottom={20}>
* <div>Sidebar</div>
* </StickyBox>
* <div>Content</div>
* </div>
* );
*
* Or via the useStickyBox hook
* import {useStickyBox} from "shared/StickyBox";
*
* const Page = () => {
* const stickyRef = useStickyBox({offsetTop: 20, offsetBottom: 20})
* <div className="row">
* <aside ref={stickyRef}>
* <div>Sidebar</div>
* </aside>
* <div>Content</div>
* </div>
* };
*
*/
import { useEffect, useRef, useState } from 'react';
import ResizeObserver from 'resize-observer-polyfill';
/**
* getScrollParent
* @param node
*/
const getScrollParent = (node) => {
let parent = node;
while ((parent = parent.parentElement)) {
const overflowYVal = getComputedStyle(parent, null).getPropertyValue('overflow-y');
if (parent === document.body)
return window;
if (overflowYVal === 'auto' || overflowYVal === 'scroll')
return parent;
}
return window;
};
/**
* offsetTill
* @param node
* @param target
*/
const offsetTill = (node, target) => {
let current = node;
let offset = 0;
// If target is not an offsetParent itself, subtract its offsetTop and set correct target
if (target.firstChild && target.firstChild.offsetParent !== target) {
offset += node.offsetTop - target.offsetTop;
target = node.offsetParent;
offset += -node.offsetTop;
}
do {
offset += current.offsetTop;
current = current.offsetParent;
} while (current && current !== target);
return offset;
};
/**
* getParentNode
* @param node
*/
const getParentNode = (node) => {
let currentParent = node.parentNode;
while (currentParent) {
const style = getComputedStyle(currentParent, null);
if (style.getPropertyValue('display') !== 'contents')
break;
currentParent = currentParent.parentNode;
}
return currentParent || window;
};
/**
* Check CSS 'sticky' compatibility
*/
let stickyProp = null;
if (typeof CSS !== 'undefined' && CSS.supports) {
if (CSS.supports('position', 'sticky'))
stickyProp = 'sticky';
else if (CSS.supports('position', '-webkit-sticky'))
stickyProp = '-webkit-sticky';
}
/**
* Inspired by https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md#feature-detection
*/
let passiveArg = false;
try {
let opts = Object.defineProperty({}, 'passive', {
// eslint-disable-next-line getter-return
get() {
passiveArg = { passive: true };
}
});
window.addEventListener('testPassive', null, opts);
window.removeEventListener('testPassive', null, opts);
// eslint-disable-next-line no-empty
}
catch (e) { }
/**
* registerNode
* @param node
* @param offsetTop
* @param offsetBottom
* @param bottom
*/
const registerNode = (node, { offsetTop, offsetBottom, bottom }) => {
const scrollPane = getScrollParent(node);
let latestScrollY = scrollPane === window ? window.scrollY : scrollPane.scrollTop;
const unsubs = [];
let mode, offset, nodeHeight, naturalTop, parentHeight, scrollPaneOffset, viewPortHeight;
const getCurrentOffset = () => {
if (mode === 'relative')
return offset;
if (mode === 'stickyTop') {
return Math.max(0, scrollPaneOffset + latestScrollY - naturalTop + offsetTop);
}
if (mode === 'stickyBottom') {
return Math.max(0, scrollPaneOffset + latestScrollY + viewPortHeight - (naturalTop + nodeHeight + offsetBottom));
}
};
const changeToStickyBottomIfBoxTooLow = (scrollY) => {
if (scrollY + scrollPaneOffset + viewPortHeight >= naturalTop + nodeHeight + offset + offsetBottom) {
changeMode('stickyBottom');
}
};
const changeMode = (newMode) => {
mode = newMode;
if (newMode === 'relative') {
node.style.position = 'relative';
if (bottom) {
const nextBottom = Math.max(0, parentHeight - nodeHeight - offset);
node.style.bottom = `${nextBottom}px`;
}
else {
node.style.top = `${offset}px`;
}
}
else {
node.style.position = stickyProp;
if (newMode === 'stickyBottom') {
if (bottom) {
node.style.bottom = `${offsetBottom}px`;
}
else {
node.style.top = `${viewPortHeight - nodeHeight - offsetBottom}px`;
}
}
else {
// stickyTop
if (bottom) {
node.style.bottom = `${viewPortHeight - nodeHeight - offsetBottom}px`;
}
else {
node.style.top = `${offsetTop}px`;
}
}
}
offset = getCurrentOffset();
};
const initial = () => {
if (bottom) {
if (mode !== 'stickyBottom')
changeMode('stickyBottom');
}
else {
if (mode !== 'stickyTop')
changeMode('stickyTop');
}
};
const addListener = (element, event, handler, passive) => {
element.addEventListener(event, handler, passive);
unsubs.push(() => element.removeEventListener(event, handler));
};
const handleScroll = () => {
const scrollY = scrollPane === window ? window.scrollY : scrollPane.scrollTop;
if (scrollY === latestScrollY)
return;
if (nodeHeight + offsetTop + offsetBottom <= viewPortHeight) {
// Just make it sticky if node smaller than viewport
initial();
latestScrollY = scrollY;
return;
}
const scrollDelta = scrollY - latestScrollY;
offset = getCurrentOffset();
if (scrollDelta > 0) {
// scroll down
if (mode === 'stickyTop') {
if (scrollY + scrollPaneOffset + offsetTop > naturalTop) {
if (scrollY + scrollPaneOffset + viewPortHeight <= naturalTop + nodeHeight + offset + offsetBottom) {
changeMode('relative');
}
else {
changeMode('stickyBottom');
}
}
}
else if (mode === 'relative') {
changeToStickyBottomIfBoxTooLow(scrollY);
}
}
else {
// scroll up
if (mode === 'stickyBottom') {
if (scrollPaneOffset + scrollY + viewPortHeight < naturalTop + parentHeight + offsetBottom) {
if (scrollPaneOffset + scrollY + offsetTop >= naturalTop + offset) {
changeMode('relative');
}
else {
changeMode('stickyTop');
}
}
}
else if (mode === 'relative') {
if (scrollPaneOffset + scrollY + offsetTop < naturalTop + offset) {
changeMode('stickyTop');
}
}
}
latestScrollY = scrollY;
};
const handleWindowResize = () => {
viewPortHeight = window.innerHeight;
scrollPaneOffset = 0;
handleScroll();
};
const handleScrollPaneResize = () => {
viewPortHeight = scrollPane.offsetHeight;
if (process.env.NODE_ENV !== 'production' && viewPortHeight === 0) {
console.warn(`react-sticky-box's scroll pane has a height of 0. This seems odd. Please check this node:`, scrollPane);
}
// Only applicable if scrollPane is an offsetParent
if (scrollPane.firstChild.offsetParent === scrollPane) {
scrollPaneOffset = scrollPane.getBoundingClientRect().top;
}
else {
scrollPaneOffset = 0;
}
handleScroll();
};
const handleParentNodeResize = () => {
const parentNode = getParentNode(node);
const computedParentStyle = getComputedStyle(parentNode, null);
const parentPaddingTop = parseInt(computedParentStyle.getPropertyValue('padding-top'), 10);
const parentPaddingBottom = parseInt(computedParentStyle.getPropertyValue('padding-bottom'), 10);
const verticalParentPadding = parentPaddingTop + parentPaddingBottom;
naturalTop = offsetTill(parentNode, scrollPane) + parentPaddingTop + scrollPaneOffset;
const oldParentHeight = parentHeight;
parentHeight = parentNode.getBoundingClientRect().height - verticalParentPadding;
if (mode === 'relative') {
if (bottom) {
changeMode('relative');
}
else {
// If parent height decreased...
if (oldParentHeight > parentHeight) {
changeToStickyBottomIfBoxTooLow(latestScrollY);
}
}
}
if (oldParentHeight !== parentHeight && mode === 'relative') {
latestScrollY = Number.POSITIVE_INFINITY;
handleScroll();
}
};
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
const handleNodeResize = ({ initial: initialArg } = {}) => {
const prevHeight = nodeHeight;
nodeHeight = node.getBoundingClientRect().height;
if (!initialArg && prevHeight !== nodeHeight) {
if (nodeHeight + offsetTop + offsetBottom <= viewPortHeight) {
// Just make it sticky if node smaller than viewport
mode = undefined;
initial();
return;
}
else {
const diff = prevHeight - nodeHeight;
const lowestPossible = parentHeight - nodeHeight;
const nextOffset = Math.min(lowestPossible, getCurrentOffset() + (bottom ? diff : 0));
offset = Math.max(0, nextOffset);
if (!bottom || mode !== 'stickyBottom')
changeMode('relative');
}
}
};
const addResizeObserver = (n, handler) => {
const ro = new ResizeObserver(handler);
ro.observe(n);
unsubs.push(() => ro.disconnect());
};
addListener(scrollPane, 'scroll', handleScroll, passiveArg);
addListener(scrollPane, 'mousewheel', handleScroll, passiveArg);
if (scrollPane === window) {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
addListener(window, 'resize', handleWindowResize);
handleWindowResize();
}
else {
addResizeObserver(scrollPane, handleScrollPaneResize);
handleScrollPaneResize();
}
addResizeObserver(getParentNode(node), handleParentNodeResize);
handleParentNodeResize();
addResizeObserver(node, handleNodeResize);
handleNodeResize({ initial: true });
initial();
return () => unsubs.forEach((fn) => fn());
};
/**
* useStickyBox
* @param offsetTop
* @param offsetBottom
* @param bottom
*/
export const useStickyBox = ({ offsetTop = 0, offsetBottom = 0, bottom = false } = {}) => {
const [node, setNode] = useState(null);
const argRef = useRef({ offsetTop, offsetBottom, bottom });
useEffect(() => {
argRef.current = { offsetTop, offsetBottom, bottom };
});
useEffect(() => {
if (!node)
return;
return registerNode(node, argRef.current);
}, [node]);
return setNode;
};
/**
* StickyBox component
* @param offsetTop
* @param offsetBottom
* @param bottom
* @param children
* @param className
* @param style
* @constructor
*/
const StickyBox = ({ offsetTop, offsetBottom, bottom, children, className, style }) => {
const ref = useStickyBox({ offsetTop, offsetBottom, bottom });
return (_jsx("div", Object.assign({ className: className, style: style, ref: ref }, { children: children })));
};
export default StickyBox;