From 7b9c584b5b2cc7d535f821c6d50bfa823e5d3cae Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 10 Feb 2025 11:12:32 -0800 Subject: [PATCH] add auto_scroll --- reflex/components/core/__init__.py | 1 + reflex/components/core/__init__.pyi | 1 + reflex/components/core/auto_scroll.py | 110 ++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 reflex/components/core/auto_scroll.py diff --git a/reflex/components/core/__init__.py b/reflex/components/core/__init__.py index fbe0bdc84..d1f822e67 100644 --- a/reflex/components/core/__init__.py +++ b/reflex/components/core/__init__.py @@ -48,6 +48,7 @@ _SUBMOD_ATTRS: dict[str, list[str]] = { "get_upload_url", "selected_files", ], + "auto_scroll": ["auto_scroll"], } __getattr__, __dir__, __all__ = lazy_loader.attach( diff --git a/reflex/components/core/__init__.pyi b/reflex/components/core/__init__.pyi index ea9275334..e94b4982e 100644 --- a/reflex/components/core/__init__.pyi +++ b/reflex/components/core/__init__.pyi @@ -4,6 +4,7 @@ # ------------------------------------------------------ from . import layout as layout +from .auto_scroll import auto_scroll as auto_scroll from .banner import ConnectionBanner as ConnectionBanner from .banner import ConnectionModal as ConnectionModal from .banner import ConnectionPulser as ConnectionPulser diff --git a/reflex/components/core/auto_scroll.py b/reflex/components/core/auto_scroll.py new file mode 100644 index 000000000..579b5f362 --- /dev/null +++ b/reflex/components/core/auto_scroll.py @@ -0,0 +1,110 @@ +"""A component that automatically scrolls to the bottom when new content is added.""" + +from __future__ import annotations + +from reflex.components.el.elements.typography import Div +from reflex.constants.compiler import MemoizationDisposition, MemoizationMode +from reflex.utils.imports import ImportDict +from reflex.vars.base import Var + + +class AutoScroll(Div): + """A div that automatically scrolls to the bottom when new content is added.""" + + _memoization_mode = MemoizationMode(disposition=MemoizationDisposition.ALWAYS) + + @classmethod + def create(cls, *children, **props): + """Create an AutoScroll component. + + Args: + *children: The children of the component. + **props: The props of the component. + + Returns: + An AutoScroll component. + """ + props.setdefault("overflow", "auto") + custom_attrs = props.pop("custom_attrs", {}) + custom_attrs["ref"] = Var("containerRef") + return super().create(*children, **props, custom_attrs=custom_attrs) + + def add_imports(self) -> ImportDict | list[ImportDict]: + """Add imports required for the component. + + Returns: + The imports required for the component. + """ + return {"react": ["useEffect", "useRef"]} + + def add_hooks(self) -> list[str | Var]: + """Add hooks required for the component. + + Returns: + The hooks required for the component. + """ + return [ + "const containerRef = useRef(null);", + "const wasNearBottom = useRef(false);", + "const hadScrollbar = useRef(false);", + """ +const checkIfNearBottom = () => { + if (!containerRef.current) return; + + const container = containerRef.current; + const nearBottomThreshold = 50; // pixels from bottom to trigger auto-scroll + + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; + + wasNearBottom.current = distanceFromBottom <= nearBottomThreshold; + + // Track if container had a scrollbar + hadScrollbar.current = container.scrollHeight > container.clientHeight; +}; +""", + """ +const scrollToBottomIfNeeded = () => { + if (!containerRef.current) return; + + const container = containerRef.current; + const hasScrollbarNow = container.scrollHeight > container.clientHeight; + + // Scroll if: + // 1. User was near bottom, OR + // 2. Container didn't have scrollbar before but does now + if (wasNearBottom.current || (!hadScrollbar.current && hasScrollbarNow)) { + container.scrollTop = container.scrollHeight; + } + + // Update scrollbar state for next check + hadScrollbar.current = hasScrollbarNow;}; +""", + """ + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Create ResizeObserver to detect height changes + const resizeObserver = new ResizeObserver(() => { + scrollToBottomIfNeeded(); + }); + + // Track scroll position before height changes + container.addEventListener('scroll', checkIfNearBottom); + + // Initial check + checkIfNearBottom(); + + // Observe container for size changes + resizeObserver.observe(container); + + return () => { + container.removeEventListener('scroll', checkIfNearBottom); + resizeObserver.disconnect(); + }; + }); +""", + ] + + +auto_scroll = AutoScroll.create