@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
118 lines (117 loc) • 4.21 kB
JavaScript
"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