UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

118 lines (117 loc) • 4.21 kB
"use client"; import { c } from "@assistant-ui/tap/react-shim/compiler-runtime"; import { useEffect, useRef } from "@assistant-ui/tap/react-shim"; // src/primitives/reasoning/useScrollLock.ts const findScrollableAncestor = (element) => { let current = element; while (current) { const { overflowY } = getComputedStyle(current); if (overflowY === "scroll" || overflowY === "auto") return current; current = current.parentElement; } return null; }; const scrollLocks = /* @__PURE__ */ new WeakMap(); const acquireScrollLock = (scrollContainer) => { const existing = scrollLocks.get(scrollContainer); if (existing) { existing.count++; return existing; } const scrollPosition = scrollContainer.scrollTop; const scrollbarWidth = scrollContainer.style.scrollbarWidth; const computed = getComputedStyle(scrollContainer); const paddingSide = computed.direction === "rtl" ? "paddingLeft" : "paddingRight"; const previousPadding = scrollContainer.style[paddingSide]; const elementScrollbarSize = scrollContainer.offsetWidth - scrollContainer.clientWidth - parseFloat(computed.borderLeftWidth) - parseFloat(computed.borderRightWidth); const ownerDocument = scrollContainer.ownerDocument; const scrollbarSize = (scrollContainer === ownerDocument.documentElement || scrollContainer === ownerDocument.body) && elementScrollbarSize <= 0 ? (ownerDocument.defaultView?.innerWidth ?? 0) - ownerDocument.documentElement.clientWidth : elementScrollbarSize; scrollContainer.style.scrollbarWidth = "none"; if (scrollbarSize > 0) scrollContainer.style[paddingSide] = `${parseFloat(computed[paddingSide]) + scrollbarSize}px`; const restoreStyles = () => { scrollContainer.style.scrollbarWidth = scrollbarWidth; scrollContainer.style[paddingSide] = previousPadding; }; const resetPosition = () => scrollContainer.scrollTop = scrollPosition; scrollContainer.addEventListener("scroll", resetPosition); const lock = { count: 1, restore: () => { scrollContainer.removeEventListener("scroll", resetPosition); restoreStyles(); } }; scrollLocks.set(scrollContainer, lock); return lock; }; /** * Locks scroll position during collapsible/height animations and hides scrollbar. * * This utility prevents page jumps when content height changes during animations, * providing a smooth user experience. It finds the nearest scrollable ancestor and * temporarily locks its scroll position while the animation completes. * * - Prevents forced reflows: no layout reads, mutations scoped to scrollable parent only * - Reactive: only intercepts scroll events when browser actually adjusts * - Cleans up automatically after animation duration * * @param animatedElementRef - Ref to the animated element * @param animationDuration - Lock duration in milliseconds * @returns Function to activate the scroll lock * * @example * ```tsx * const collapsibleRef = useRef<HTMLDivElement>(null); * const lockScroll = useScrollLock(collapsibleRef, 200); * * const handleCollapse = () => { * lockScroll(); // Lock scroll before collapsing * setIsOpen(false); * }; * ``` */ const useScrollLock = (animatedElementRef, animationDuration) => { const $ = c(5); const cleanupRef = useRef(null); let t0; let t1; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { t0 = () => () => { cleanupRef.current?.(); }; t1 = []; $[0] = t0; $[1] = t1; } else { t0 = $[0]; t1 = $[1]; } useEffect(t0, t1); let t2; if ($[2] !== animatedElementRef || $[3] !== animationDuration) { t2 = () => { cleanupRef.current?.(); const scrollContainer = findScrollableAncestor(animatedElementRef.current); if (!scrollContainer) { cleanupRef.current = null; return; } const lock = acquireScrollLock(scrollContainer); const timeoutId = setTimeout(() => cleanupRef.current?.(), animationDuration); cleanupRef.current = () => { clearTimeout(timeoutId); if ((lock.count = lock.count - 1) === 0) { lock.restore(); scrollLocks.delete(scrollContainer); } cleanupRef.current = null; }; }; $[2] = animatedElementRef; $[3] = animationDuration; $[4] = t2; } else t2 = $[4]; return t2; }; export { useScrollLock }; //# sourceMappingURL=useScrollLock.js.map