UNPKG

@selfcommunity/react-ui

Version:

React UI Components to integrate a Community created with SelfCommunity Platform.

356 lines (355 loc) • 12.3 kB
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;