From ae33c5d75e480c4407c2fdcf4a5d1cc43c57d7f0 Mon Sep 17 00:00:00 2001 From: Mihai Popescu Date: Sun, 27 Apr 2025 21:21:50 +0300 Subject: [PATCH 1/3] fix: first stab at fixing #8 --- demo/Demo.tsx | 109 +++++++++++--- demo/index.tsx | 4 +- src/StickToBottom.tsx | 88 +++++++----- src/useStickToBottom.ts | 309 +++++++++++++++++++++++++++++----------- 4 files changed, 369 insertions(+), 141 deletions(-) diff --git a/demo/Demo.tsx b/demo/Demo.tsx index ee8d828..dc64dd6 100644 --- a/demo/Demo.tsx +++ b/demo/Demo.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { StickToBottom, useStickToBottomContext } from '../src/StickToBottom'; import { useFakeMessages } from './useFakeMessages'; -function ScrollToBottom() { +// ScrollToBottom button for element-scroll mode +function ElementScrollToBottom() { const { isAtBottom, scrollToBottom } = useStickToBottomContext(); - return ( !isAtBottom && ( + + + ); +} + export function Demo() { const [speed, setSpeed] = useState(0.2); + const [mode, setMode] = useState<'element' | 'document'>('element'); return (
- setSpeed(+e.target.value)} - min={0} - max={1} - step={0.01} - > + {/* Header with navigation */} +
+ + +
-
- - + {/* Sticky range selector */} +
+ setSpeed(+e.target.value)} + min={0} + max={1} + step={0.01} + />
+ + {/* Conditionally render demos */} + {mode === 'element' && ( +
+ + +
+ )} + {mode === 'document' && ( +
+ + + +
+ )}
); } diff --git a/demo/index.tsx b/demo/index.tsx index 7fac5cb..9ec3920 100644 --- a/demo/index.tsx +++ b/demo/index.tsx @@ -1,7 +1,7 @@ import './index.css'; import { createRoot } from 'react-dom/client'; -import { Demo } from './Demo.js'; +import { Demo } from './Demo.js'; // Import the Demo component const container = document.getElementById('root')!; const root = createRoot(container); -root.render(); +root.render(); // Render the Demo component diff --git a/src/StickToBottom.tsx b/src/StickToBottom.tsx index 43fca8b..4ca0a4d 100644 --- a/src/StickToBottom.tsx +++ b/src/StickToBottom.tsx @@ -35,6 +35,7 @@ export interface StickToBottomContext { get targetScrollTop(): GetTargetScrollTop | null; set targetScrollTop(targetScrollTop: GetTargetScrollTop | null); state: StickToBottomState; + scrollMode: "element" | "document"; // Add scrollMode to context } const StickToBottomContext = createContext(null); @@ -45,6 +46,7 @@ export interface StickToBottomProps contextRef?: React.Ref; instance?: ReturnType; children: ((context: StickToBottomContext) => ReactNode) | ReactNode; + scrollMode?: "element" | "document"; // Add scrollMode prop } const useIsomorphicLayoutEffect = @@ -59,6 +61,7 @@ export function StickToBottom({ damping, stiffness, targetScrollTop: currentTargetScrollTop, + scrollMode = "element", // Destructure scrollMode prop with default contextRef, ...props }: StickToBottomProps) { @@ -79,6 +82,7 @@ export function StickToBottom({ resize, initial, targetScrollTop, + scrollMode, // Pass scrollMode to the hook }); const { @@ -89,46 +93,54 @@ export function StickToBottom({ isAtBottom, escapedFromLock, state, + // Destructure scrollMode from hook result (though we already have it from props) + // Might be useful if using a passed-in instance + scrollMode: instanceScrollMode, } = instance ?? defaultInstance; - const context = useMemo( - () => ({ - scrollToBottom, - stopScroll, - scrollRef, - isAtBottom, - escapedFromLock, - contentRef, - state, - get targetScrollTop() { - return customTargetScrollTop.current; - }, - set targetScrollTop(targetScrollTop: GetTargetScrollTop | null) { - customTargetScrollTop.current = targetScrollTop; - }, - }), - [ - scrollToBottom, - isAtBottom, - contentRef, - scrollRef, - stopScroll, - escapedFromLock, - state, - ], - ); + // Use the scrollMode passed via props primarily, fallback to instance if provided externally + const effectiveScrollMode = instance ? instanceScrollMode : scrollMode; + +const context = useMemo( +() => ({ +scrollToBottom, +stopScroll, +scrollRef, +isAtBottom, +escapedFromLock, +contentRef, +state, +scrollMode: effectiveScrollMode, // Add scrollMode to context value +get targetScrollTop() { +return customTargetScrollTop.current; +}, +set targetScrollTop(targetScrollTop: GetTargetScrollTop | null) { +customTargetScrollTop.current = targetScrollTop; +}, +}), +[ +scrollToBottom, +isAtBottom, +contentRef, +scrollRef, +stopScroll, +escapedFromLock, +state, +effectiveScrollMode, // Add effectiveScrollMode to dependency array +], +); useImperativeHandle(contextRef, () => context, [context]); + // Conditionally apply overflow style only in element mode useIsomorphicLayoutEffect(() => { - if (!scrollRef.current) { - return; - } - - if (getComputedStyle(scrollRef.current).overflow === "visible") { - scrollRef.current.style.overflow = "auto"; + if (effectiveScrollMode === "element" && scrollRef.current) { + if (getComputedStyle(scrollRef.current).overflow === "visible") { + scrollRef.current.style.overflow = "auto"; + } } - }, []); + // Add effectiveScrollMode to dependency array + }, [effectiveScrollMode]); return ( @@ -148,6 +160,16 @@ export namespace StickToBottom { export function Content({ children, ...props }: ContentProps) { const context = useStickToBottomContext(); + // In 'document' mode, don't render the outer scroll div + if (context.scrollMode === "document") { + return ( +
+ {typeof children === "function" ? children(context) : children} +
+ ); + } + + // Default 'element' mode rendering return (
{ mouseDown = false; }); +// Helper to get the document's scroll element +const getDocumentScrollElement = (): HTMLElement => { + // Use documentElement first, fallback to body (needed for some browsers like Safari) + if ( + document.documentElement.scrollHeight > document.documentElement.clientHeight || + document.documentElement.scrollTop > 0 + ) { + return document.documentElement; + } + return document.body; +}; + export const useStickToBottom = (options: StickToBottomOptions = {}) => { + const { scrollMode = "element" } = options; // Default to 'element' const [escapedFromLock, updateEscapedFromLock] = useState(false); const [isAtBottom, updateIsAtBottom] = useState(options.initial !== false); const [isNearBottom, setIsNearBottom] = useState(false); @@ -181,47 +202,103 @@ export const useStickToBottom = (options: StickToBottomOptions = {}) => { updateEscapedFromLock(escapedFromLock); }, []); + const getScrollElement = useCallback((): HTMLElement | null => { + if (scrollMode === "document") { + return getDocumentScrollElement(); + } + return scrollRef.current; + }, [scrollMode]); + + const getScrollTop = useCallback((): number => { + if (scrollMode === "document") { + // Handle potential undefined during SSR or initial render + return typeof window !== "undefined" ? window.scrollY : 0; + } + return scrollRef.current?.scrollTop ?? 0; + }, [scrollMode]); + + const setScrollTop = useCallback( + (scrollTop: number) => { + if (scrollMode === "document") { + if (typeof window !== "undefined") { + window.scrollTo({ top: scrollTop, behavior: "instant" }); + // Directly setting might be needed if scrollTo doesn't trigger ignoreScrollToTop correctly + const scrollElement = getDocumentScrollElement(); + if (scrollElement) { + state.ignoreScrollToTop = scrollElement.scrollTop; + } + } + } else if (scrollRef.current) { + scrollRef.current.scrollTop = scrollTop; + state.ignoreScrollToTop = scrollRef.current.scrollTop; + } + }, + // state dependency removed as it causes infinite loops, ignoreScrollToTop is set directly + // State dependency removed again, as it caused 'used before declaration' error. + // The setter only *writes* to state.ignoreScrollToTop, doesn't read it first. + [scrollMode], + ); + + // Add explicit return type + const getScrollDimensions = useCallback((): { + scrollHeight: number; + clientHeight: number; + } => { + if (scrollMode === "document") { + const scrollElement = getDocumentScrollElement(); + return { + scrollHeight: scrollElement?.scrollHeight ?? 0, + clientHeight: window.innerHeight ?? 0, + }; + } + return { + scrollHeight: scrollRef.current?.scrollHeight ?? 0, + clientHeight: scrollRef.current?.clientHeight ?? 0, + }; + }, [scrollMode]); + // biome-ignore lint/correctness/useExhaustiveDependencies: not needed const state = useMemo(() => { let lastCalculation: | { targetScrollTop: number; calculatedScrollTop: number } | undefined; - return { - escapedFromLock, - isAtBottom, - resizeDifference: 0, - accumulated: 0, - velocity: 0, - listeners: new Set(), - - get scrollTop() { - return scrollRef.current?.scrollTop ?? 0; +return { +escapedFromLock, +isAtBottom, +resizeDifference: 0, +accumulated: 0, +velocity: 0, + +get scrollTop() { + return getScrollTop(); }, set scrollTop(scrollTop: number) { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollTop; - state.ignoreScrollToTop = scrollRef.current.scrollTop; - } + setScrollTop(scrollTop); }, get targetScrollTop() { - if (!scrollRef.current || !contentRef.current) { + const { scrollHeight, clientHeight } = getScrollDimensions(); + // Ensure contentRef exists if needed for calculation, though less critical in document mode + if (scrollMode === "element" && !contentRef.current) { return 0; } - - return ( - scrollRef.current.scrollHeight - 1 - scrollRef.current.clientHeight - ); + return Math.max(0, scrollHeight - 1 - clientHeight); }, get calculatedTargetScrollTop() { - if (!scrollRef.current || !contentRef.current) { + const { targetScrollTop } = this; + const scrollElement = getScrollElement(); // Get current scroll element + + // Ensure contentRef exists if needed for calculation + if (scrollMode === "element" && !contentRef.current) { + return 0; + } + // Ensure scrollElement exists for document mode calculation context + if (scrollMode === "document" && !scrollElement) { return 0; } - const { targetScrollTop } = this; - - if (!options.targetScrollTop) { + if (!optionsRef.current.targetScrollTop) { return targetScrollTop; } @@ -229,12 +306,15 @@ export const useStickToBottom = (options: StickToBottomOptions = {}) => { return lastCalculation.calculatedScrollTop; } + // Provide potentially null scrollElement to targetScrollTop callback + const contextElements: ScrollElements = { + scrollElement: scrollElement!, // Assert non-null based on checks above + contentElement: contentRef.current!, // Assert non-null for element mode, might be null otherwise but callback should handle + }; + const calculatedScrollTop = Math.max( Math.min( - options.targetScrollTop(targetScrollTop, { - scrollElement: scrollRef.current, - contentElement: contentRef.current, - }), + optionsRef.current.targetScrollTop(targetScrollTop, contextElements), targetScrollTop, ), 0, @@ -403,12 +483,13 @@ export const useStickToBottom = (options: StickToBottomOptions = {}) => { const { scrollTop, ignoreScrollToTop } = state; let { lastScrollTop = scrollTop } = state; - state.lastScrollTop = scrollTop; - state.ignoreScrollToTop = undefined; + const currentScrollTop = getScrollTop(); // Use helper + state.lastScrollTop = currentScrollTop; + state.ignoreScrollToTop = undefined; - if (ignoreScrollToTop && ignoreScrollToTop > scrollTop) { - /** - * When the user scrolls up while the animation plays, the `scrollTop` may + if (ignoreScrollToTop && ignoreScrollToTop > currentScrollTop) { + /** + * When the user scrolls up while the animation plays, the `scrollTop` may * not come in separate events; if this happens, to make sure `isScrollingUp` * is correct, set the lastScrollTop to the ignored event. */ @@ -422,27 +503,31 @@ export const useStickToBottom = (options: StickToBottomOptions = {}) => { * so in order to ignore resize events correctly we use a * timeout. * - * @see https://github.com/WICG/resize-observer/issues/25#issuecomment-248757228 + * @see https://github.com/WICG/resize-observer/issues/25#issuecomment-248757228 + */ + setTimeout(() => { + const currentScrollTop = getScrollTop(); // Get fresh value inside timeout + /** + * When theres a resize difference ignore the resize event. */ - setTimeout(() => { - /** - * When theres a resize difference ignore the resize event. - */ - if (state.resizeDifference || scrollTop === ignoreScrollToTop) { - return; - } + // Check against currentScrollTop inside timeout + if (state.resizeDifference || currentScrollTop === ignoreScrollToTop) { + return; + } - if (isSelecting()) { + if (isSelecting()) { setEscapedFromLock(true); setIsAtBottom(false); - return; - } + return; + } - const isScrollingDown = scrollTop > lastScrollTop; - const isScrollingUp = scrollTop < lastScrollTop; + // Use currentScrollTop for comparisons + const isScrollingDown = currentScrollTop > lastScrollTop; + const isScrollingUp = currentScrollTop < lastScrollTop; - if (state.animation?.ignoreEscapes) { - state.scrollTop = lastScrollTop; + if (state.animation?.ignoreEscapes) { + // Setting scrollTop might need adjustment based on mode + setScrollTop(lastScrollTop); return; } @@ -464,41 +549,90 @@ export const useStickToBottom = (options: StickToBottomOptions = {}) => { ); const handleWheel = useCallback( - ({ target, deltaY }: WheelEvent) => { - let element = target as HTMLElement; - - while (!["scroll", "auto"].includes(getComputedStyle(element).overflow)) { - if (!element.parentElement) { - return; + (event: WheelEvent) => { + // In document mode, always check deltaY from the event. + // In element mode, check if the target is within the scrollRef. + if (scrollMode === "document") { + // Access deltaY from the event object + if (event.deltaY < 0 && !state.animation?.ignoreEscapes) { + const { scrollHeight, clientHeight } = getScrollDimensions(); + if (scrollHeight > clientHeight) { + // Only escape if document is actually scrollable + setEscapedFromLock(true); + setIsAtBottom(false); + } } + } else { + // Original element mode logic + const { target, deltaY } = event; + let element = target as HTMLElement | null; + + // Traverse up to find the scroll container or body/html + while ( + element && + element !== document.body && + element !== document.documentElement + ) { + if (element === scrollRef.current) { + // We are scrolling within the designated scroll element + const { scrollHeight, clientHeight } = getScrollDimensions(); + if ( + deltaY < 0 && + scrollHeight > clientHeight && + !state.animation?.ignoreEscapes + ) { + setEscapedFromLock(true); + setIsAtBottom(false); + } + return; // Stop traversal once scrollRef is found + } + element = element.parentElement; + } + } + }, + [ + scrollMode, + state, + setEscapedFromLock, + setIsAtBottom, + getScrollDimensions, + ], // Added dependencies + ); + + // Effect for managing window listeners in document mode + useEffect(() => { + if (scrollMode === "document") { + window.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("wheel", handleWheel, { passive: true }); - element = element.parentElement; + // Initial check in case content loads before effect runs + setIsNearBottom(state.isNearBottom); + if (!state.escapedFromLock && state.isNearBottom) { + setIsAtBottom(true); } - /** - * The browser may cancel the scrolling from the mouse wheel - * if we update it from the animation in meantime. - * To prevent this, always escape when the wheel is scrolled up. - */ - if ( - element === scrollRef.current && - deltaY < 0 && - scrollRef.current.scrollHeight > scrollRef.current.clientHeight && - !state.animation?.ignoreEscapes - ) { - setEscapedFromLock(true); - setIsAtBottom(false); + + return () => { + window.removeEventListener("scroll", handleScroll); + window.removeEventListener("wheel", handleWheel); + }; + } + // Intentionally not returning anything for 'element' mode + // as listeners are handled by scrollRef callback + }, [scrollMode, handleScroll, handleWheel, state, setIsAtBottom]); // Added dependencies + + const scrollRef = useRefCallback( + (scroll) => { + // Only attach/detach listeners if in element mode + if (scrollMode === "element") { + scrollRef.current?.removeEventListener("scroll", handleScroll); + scrollRef.current?.removeEventListener("wheel", handleWheel); + scroll?.addEventListener("scroll", handleScroll, { passive: true }); + scroll?.addEventListener("wheel", handleWheel, { passive: true }); } }, - [setEscapedFromLock, setIsAtBottom, state], - ); - - const scrollRef = useRefCallback((scroll) => { - scrollRef.current?.removeEventListener("scroll", handleScroll); - scrollRef.current?.removeEventListener("wheel", handleWheel); - scroll?.addEventListener("scroll", handleScroll, { passive: true }); - scroll?.addEventListener("wheel", handleWheel, { passive: true }); - }, []); + [scrollMode, handleScroll, handleWheel], + ); // Added scrollMode dependency const contentRef = useRefCallback((content) => { state.resizeObserver?.disconnect(); @@ -509,19 +643,24 @@ export const useStickToBottom = (options: StickToBottomOptions = {}) => { let previousHeight: number | undefined; - state.resizeObserver = new ResizeObserver(([entry]) => { - const { height } = entry.contentRect; - const difference = height - (previousHeight ?? height); + state.resizeObserver = new ResizeObserver(([entry]) => { + // Ensure contentRef is still valid before proceeding + if (!contentRef.current) return; + + const { height } = entry.contentRect; + const difference = height - (previousHeight ?? height); state.resizeDifference = difference; /** * Sometimes the browser can overscroll past the target, * so check for this and adjust appropriately. - */ - if (state.scrollTop > state.targetScrollTop) { - state.scrollTop = state.targetScrollTop; - } + */ + const currentScrollTop = getScrollTop(); + const currentTargetScrollTop = state.targetScrollTop; // Use getter + if (currentScrollTop > currentTargetScrollTop) { + setScrollTop(currentTargetScrollTop); // Use setter + } setIsNearBottom(state.isNearBottom); @@ -579,13 +718,15 @@ export const useStickToBottom = (options: StickToBottomOptions = {}) => { return { contentRef, - scrollRef, + scrollRef, // Still return scrollRef, might be needed for other purposes or context scrollToBottom, stopScroll, isAtBottom: isAtBottom || isNearBottom, isNearBottom, escapedFromLock, state, + // Expose scrollMode if needed by consumers, though StickToBottom component handles it + scrollMode, }; }; From d13536f4dcac03ea56a0aebccdb10b3f690c8200 Mon Sep 17 00:00:00 2001 From: Mihai Popescu Date: Sun, 27 Apr 2025 21:27:26 +0300 Subject: [PATCH 2/3] fix: formatting --- demo/Demo.tsx | 20 +-- demo/index.tsx | 4 +- src/StickToBottom.tsx | 336 +++++++++++++++++++++--------------------- 3 files changed, 174 insertions(+), 186 deletions(-) diff --git a/demo/Demo.tsx b/demo/Demo.tsx index dc64dd6..0ead528 100644 --- a/demo/Demo.tsx +++ b/demo/Demo.tsx @@ -2,21 +2,9 @@ import { useState, type ReactNode } from 'react'; import { StickToBottom, useStickToBottomContext } from '../src/StickToBottom'; import { useFakeMessages } from './useFakeMessages'; -// ScrollToBottom button for element-scroll mode -function ElementScrollToBottom() { - const { isAtBottom, scrollToBottom } = useStickToBottomContext(); - return ( - !isAtBottom && ( -
- +
-
+ ); } diff --git a/src/StickToBottom.tsx b/src/StickToBottom.tsx index 469f76d..b0fad9b 100644 --- a/src/StickToBottom.tsx +++ b/src/StickToBottom.tsx @@ -5,197 +5,197 @@ import * as React from "react"; import { - type ReactNode, - createContext, - useContext, - useEffect, - useImperativeHandle, - useLayoutEffect, - useMemo, - useRef, + type ReactNode, + createContext, + useContext, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, } from "react"; import { - type GetTargetScrollTop, - type ScrollToBottom, - type StickToBottomOptions, - type StickToBottomState, - type StopScroll, - useStickToBottom, + type GetTargetScrollTop, + type ScrollToBottom, + type StickToBottomOptions, + type StickToBottomState, + type StopScroll, + useStickToBottom, } from "./useStickToBottom.js"; export interface StickToBottomContext { - contentRef: React.MutableRefObject & - React.RefCallback; - scrollRef: React.MutableRefObject & - React.RefCallback; - scrollToBottom: ScrollToBottom; - stopScroll: StopScroll; - isAtBottom: boolean; - escapedFromLock: boolean; - get targetScrollTop(): GetTargetScrollTop | null; - set targetScrollTop(targetScrollTop: GetTargetScrollTop | null); - state: StickToBottomState; - scrollMode: "element" | "document"; // Add scrollMode to context + contentRef: React.MutableRefObject & + React.RefCallback; + scrollRef: React.MutableRefObject & + React.RefCallback; + scrollToBottom: ScrollToBottom; + stopScroll: StopScroll; + isAtBottom: boolean; + escapedFromLock: boolean; + get targetScrollTop(): GetTargetScrollTop | null; + set targetScrollTop(targetScrollTop: GetTargetScrollTop | null); + state: StickToBottomState; + scrollMode: "element" | "document"; // Add scrollMode to context } const StickToBottomContext = createContext(null); export interface StickToBottomProps - extends Omit, "children">, - StickToBottomOptions { - contextRef?: React.Ref; - instance?: ReturnType; - children: ((context: StickToBottomContext) => ReactNode) | ReactNode; - scrollMode?: "element" | "document"; // Add scrollMode prop + extends Omit, "children">, + StickToBottomOptions { + contextRef?: React.Ref; + instance?: ReturnType; + children: ((context: StickToBottomContext) => ReactNode) | ReactNode; + scrollMode?: "element" | "document"; // Add scrollMode prop } const useIsomorphicLayoutEffect = - typeof window !== "undefined" ? useLayoutEffect : useEffect; + typeof window !== "undefined" ? useLayoutEffect : useEffect; export function StickToBottom({ - instance, - children, - resize, - initial, - mass, - damping, - stiffness, - targetScrollTop: currentTargetScrollTop, - scrollMode = "element", // Destructure scrollMode prop with default - contextRef, - ...props + instance, + children, + resize, + initial, + mass, + damping, + stiffness, + targetScrollTop: currentTargetScrollTop, + scrollMode = "element", // Destructure scrollMode prop with default + contextRef, + ...props }: StickToBottomProps) { - const customTargetScrollTop = useRef(null); - - const targetScrollTop = React.useCallback( - (target, elements) => { - const get = context?.targetScrollTop ?? currentTargetScrollTop; - return get?.(target, elements) ?? target; - }, - [currentTargetScrollTop] - ); - - const defaultInstance = useStickToBottom({ - mass, - damping, - stiffness, - resize, - initial, - targetScrollTop, - scrollMode, // Pass scrollMode to the hook - }); - - const { - scrollRef, - contentRef, - scrollToBottom, - stopScroll, - isAtBottom, - escapedFromLock, - state, - // Destructure scrollMode from hook result (though we already have it from props) - // Might be useful if using a passed-in instance - scrollMode: instanceScrollMode, - } = instance ?? defaultInstance; - - // Use the scrollMode passed via props primarily, fallback to instance if provided externally - const effectiveScrollMode = instance ? instanceScrollMode : scrollMode; - - const context = useMemo( - () => ({ - scrollToBottom, - stopScroll, - scrollRef, - isAtBottom, - escapedFromLock, - contentRef, - state, - scrollMode: effectiveScrollMode, // Add scrollMode to context value - get targetScrollTop() { - return customTargetScrollTop.current; - }, - set targetScrollTop(targetScrollTop: GetTargetScrollTop | null) { - customTargetScrollTop.current = targetScrollTop; - }, - }), - [ - scrollToBottom, - isAtBottom, - contentRef, - scrollRef, - stopScroll, - escapedFromLock, - state, - effectiveScrollMode, // Add effectiveScrollMode to dependency array - ] - ); - - useImperativeHandle(contextRef, () => context, [context]); - - // Conditionally apply overflow style only in element mode - useIsomorphicLayoutEffect(() => { - if (effectiveScrollMode === "element" && scrollRef.current) { - if (getComputedStyle(scrollRef.current).overflow === "visible") { - scrollRef.current.style.overflow = "auto"; - } - } - // Add effectiveScrollMode to dependency array - }, [effectiveScrollMode]); - - return ( - -
- {typeof children === "function" ? children(context) : children} -
-
- ); + const customTargetScrollTop = useRef(null); + + const targetScrollTop = React.useCallback( + (target, elements) => { + const get = context?.targetScrollTop ?? currentTargetScrollTop; + return get?.(target, elements) ?? target; + }, + [currentTargetScrollTop], + ); + + const defaultInstance = useStickToBottom({ + mass, + damping, + stiffness, + resize, + initial, + targetScrollTop, + scrollMode, // Pass scrollMode to the hook + }); + + const { + scrollRef, + contentRef, + scrollToBottom, + stopScroll, + isAtBottom, + escapedFromLock, + state, + // Destructure scrollMode from hook result (though we already have it from props) + // Might be useful if using a passed-in instance + scrollMode: instanceScrollMode, + } = instance ?? defaultInstance; + + // Use the scrollMode passed via props primarily, fallback to instance if provided externally + const effectiveScrollMode = instance ? instanceScrollMode : scrollMode; + + const context = useMemo( + () => ({ + scrollToBottom, + stopScroll, + scrollRef, + isAtBottom, + escapedFromLock, + contentRef, + state, + scrollMode: effectiveScrollMode, // Add scrollMode to context value + get targetScrollTop() { + return customTargetScrollTop.current; + }, + set targetScrollTop(targetScrollTop: GetTargetScrollTop | null) { + customTargetScrollTop.current = targetScrollTop; + }, + }), + [ + scrollToBottom, + isAtBottom, + contentRef, + scrollRef, + stopScroll, + escapedFromLock, + state, + effectiveScrollMode, // Add effectiveScrollMode to dependency array + ], + ); + + useImperativeHandle(contextRef, () => context, [context]); + + // Conditionally apply overflow style only in element mode + useIsomorphicLayoutEffect(() => { + if (effectiveScrollMode === "element" && scrollRef.current) { + if (getComputedStyle(scrollRef.current).overflow === "visible") { + scrollRef.current.style.overflow = "auto"; + } + } + // Add effectiveScrollMode to dependency array + }, [effectiveScrollMode]); + + return ( + +
+ {typeof children === "function" ? children(context) : children} +
+
+ ); } export namespace StickToBottom { - export interface ContentProps - extends Omit, "children"> { - children: ((context: StickToBottomContext) => ReactNode) | ReactNode; - } - - export function Content({ children, ...props }: ContentProps) { - const context = useStickToBottomContext(); - - // In 'document' mode, don't render the outer scroll div - if (context.scrollMode === "document") { - return ( -
- {typeof children === "function" ? children(context) : children} -
- ); - } - - // Default 'element' mode rendering - return ( -
-
- {typeof children === "function" ? children(context) : children} -
-
- ); - } + export interface ContentProps + extends Omit, "children"> { + children: ((context: StickToBottomContext) => ReactNode) | ReactNode; + } + + export function Content({ children, ...props }: ContentProps) { + const context = useStickToBottomContext(); + + // In 'document' mode, don't render the outer scroll div + if (context.scrollMode === "document") { + return ( +
+ {typeof children === "function" ? children(context) : children} +
+ ); + } + + // Default 'element' mode rendering + return ( +
+
+ {typeof children === "function" ? children(context) : children} +
+
+ ); + } } /** * Use this hook inside a component to gain access to whether the component is at the bottom of the scrollable area. */ export function useStickToBottomContext() { - const context = useContext(StickToBottomContext); - if (!context) { - throw new Error( - "use-stick-to-bottom component context must be used within a StickToBottom component" - ); - } - - return context; + const context = useContext(StickToBottomContext); + if (!context) { + throw new Error( + "use-stick-to-bottom component context must be used within a StickToBottom component", + ); + } + + return context; } diff --git a/src/useStickToBottom.ts b/src/useStickToBottom.ts index 54d64b4..e107bc4 100644 --- a/src/useStickToBottom.ts +++ b/src/useStickToBottom.ts @@ -158,7 +158,8 @@ globalThis.document?.addEventListener("click", () => { const getDocumentScrollElement = (): HTMLElement => { // Use documentElement first, fallback to body (needed for some browsers like Safari) if ( - document.documentElement.scrollHeight > document.documentElement.clientHeight || + document.documentElement.scrollHeight > + document.documentElement.clientHeight || document.documentElement.scrollTop > 0 ) { return document.documentElement; @@ -192,6 +193,14 @@ export const useStickToBottom = (options: StickToBottomOptions = {}) => { ); }, []); + const scrollStateRef = useRef<{ + lastScrollTop: number; + ignoreScrollToTop?: number; + }>({ + lastScrollTop: 0, + ignoreScrollToTop: undefined, + }); + const setIsAtBottom = useCallback((isAtBottom: boolean) => { state.isAtBottom = isAtBottom; updateIsAtBottom(isAtBottom); @@ -263,14 +272,14 @@ export const useStickToBottom = (options: StickToBottomOptions = {}) => { | { targetScrollTop: number; calculatedScrollTop: number } | undefined; -return { -escapedFromLock, -isAtBottom, -resizeDifference: 0, -accumulated: 0, -velocity: 0, + return { + escapedFromLock, + isAtBottom, + resizeDifference: 0, + accumulated: 0, + velocity: 0, -get scrollTop() { + get scrollTop() { return getScrollTop(); }, set scrollTop(scrollTop: number) { @@ -314,7 +323,10 @@ get scrollTop() { const calculatedScrollTop = Math.max( Math.min( - optionsRef.current.targetScrollTop(targetScrollTop, contextElements), + optionsRef.current.targetScrollTop( + targetScrollTop, + contextElements, + ), targetScrollTop, ), 0, @@ -480,54 +492,35 @@ get scrollTop() { return; } - const { scrollTop, ignoreScrollToTop } = state; - let { lastScrollTop = scrollTop } = state; + const currentScrollTop = getScrollTop(); + // Use scrollStateRef instead of state for scroll position tracking + let { lastScrollTop, ignoreScrollToTop } = scrollStateRef.current; + scrollStateRef.current.lastScrollTop = currentScrollTop; + scrollStateRef.current.ignoreScrollToTop = undefined; - const currentScrollTop = getScrollTop(); // Use helper - state.lastScrollTop = currentScrollTop; - state.ignoreScrollToTop = undefined; - - if (ignoreScrollToTop && ignoreScrollToTop > currentScrollTop) { - /** - * When the user scrolls up while the animation plays, the `scrollTop` may - * not come in separate events; if this happens, to make sure `isScrollingUp` - * is correct, set the lastScrollTop to the ignored event. - */ + if (ignoreScrollToTop && ignoreScrollToTop > currentScrollTop) { lastScrollTop = ignoreScrollToTop; } setIsNearBottom(state.isNearBottom); - /** - * Scroll events may come before a ResizeObserver event, - * so in order to ignore resize events correctly we use a - * timeout. - * - * @see https://github.com/WICG/resize-observer/issues/25#issuecomment-248757228 - */ - setTimeout(() => { - const currentScrollTop = getScrollTop(); // Get fresh value inside timeout - /** - * When theres a resize difference ignore the resize event. - */ - // Check against currentScrollTop inside timeout - if (state.resizeDifference || currentScrollTop === ignoreScrollToTop) { - return; - } + setTimeout(() => { + const currentScrollTop = getScrollTop(); + if (state.resizeDifference || currentScrollTop === ignoreScrollToTop) { + return; + } - if (isSelecting()) { + if (isSelecting()) { setEscapedFromLock(true); setIsAtBottom(false); - return; - } + return; + } - // Use currentScrollTop for comparisons - const isScrollingDown = currentScrollTop > lastScrollTop; - const isScrollingUp = currentScrollTop < lastScrollTop; + const isScrollingDown = currentScrollTop > lastScrollTop; + const isScrollingUp = currentScrollTop < lastScrollTop; - if (state.animation?.ignoreEscapes) { - // Setting scrollTop might need adjustment based on mode - setScrollTop(lastScrollTop); + if (state.animation?.ignoreEscapes) { + setScrollTop(lastScrollTop); return; } @@ -545,7 +538,14 @@ get scrollTop() { } }, 1); }, - [setEscapedFromLock, setIsAtBottom, isSelecting, state], + [ + setEscapedFromLock, + setIsAtBottom, + isSelecting, + state, + getScrollTop, + setScrollTop, + ], ); const handleWheel = useCallback( @@ -590,13 +590,7 @@ get scrollTop() { } } }, - [ - scrollMode, - state, - setEscapedFromLock, - setIsAtBottom, - getScrollDimensions, - ], // Added dependencies + [scrollMode, state, setEscapedFromLock, setIsAtBottom, getScrollDimensions], // Added dependencies ); // Effect for managing window listeners in document mode @@ -611,7 +605,6 @@ get scrollTop() { setIsAtBottom(true); } - return () => { window.removeEventListener("scroll", handleScroll); window.removeEventListener("wheel", handleWheel); @@ -643,24 +636,24 @@ get scrollTop() { let previousHeight: number | undefined; - state.resizeObserver = new ResizeObserver(([entry]) => { - // Ensure contentRef is still valid before proceeding - if (!contentRef.current) return; + state.resizeObserver = new ResizeObserver(([entry]) => { + // Ensure contentRef is still valid before proceeding + if (!contentRef.current) return; - const { height } = entry.contentRect; - const difference = height - (previousHeight ?? height); + const { height } = entry.contentRect; + const difference = height - (previousHeight ?? height); state.resizeDifference = difference; /** * Sometimes the browser can overscroll past the target, * so check for this and adjust appropriately. - */ - const currentScrollTop = getScrollTop(); - const currentTargetScrollTop = state.targetScrollTop; // Use getter - if (currentScrollTop > currentTargetScrollTop) { - setScrollTop(currentTargetScrollTop); // Use setter - } + */ + const currentScrollTop = getScrollTop(); + const currentTargetScrollTop = state.targetScrollTop; // Use getter + if (currentScrollTop > currentTargetScrollTop) { + setScrollTop(currentTargetScrollTop); // Use setter + } setIsNearBottom(state.isNearBottom);