diff --git a/reflex/__init__.py b/reflex/__init__.py index 3209b505e45..7ee6f7e73c8 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -248,6 +248,7 @@ "selected_files", "upload", ], + "components.core.auto_scroll": ["auto_scroll"], } COMPONENTS_BASE_MAPPING: dict = { diff --git a/reflex/__init__.pyi b/reflex/__init__.pyi index 5c80269adba..1de63db6e36 100644 --- a/reflex/__init__.pyi +++ b/reflex/__init__.pyi @@ -34,6 +34,7 @@ from .components.component import Component as Component from .components.component import ComponentNamespace as ComponentNamespace from .components.component import NoSSRComponent as NoSSRComponent from .components.component import memo as memo +from .components.core.auto_scroll import auto_scroll as auto_scroll from .components.core.banner import connection_banner as connection_banner from .components.core.banner import connection_modal as connection_modal from .components.core.breakpoints import breakpoints as breakpoints diff --git a/reflex/components/core/__init__.py b/reflex/components/core/__init__.py index fbe0bdc8473..d1f822e67f7 100644 --- a/reflex/components/core/__init__.py +++ b/reflex/components/core/__init__.py @@ -48,6 +48,7 @@ "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 ea927533433..e94b4982e67 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 00000000000..d24bd9a1370 --- /dev/null +++ b/reflex/components/core/auto_scroll.py @@ -0,0 +1,111 @@ +"""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, get_unique_variable_name + + +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") + props.setdefault("id", get_unique_variable_name()) + return super().create(*children, **props) + + 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. + """ + ref_name = self.get_ref() + return [ + "const containerRef = useRef(null);", + "const wasNearBottom = useRef(false);", + "const hadScrollbar = useRef(false);", + f""" +const checkIfNearBottom = () => {{ + if (!{ref_name}.current) return; + + const container = {ref_name}.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; +}}; +""", + f""" +const scrollToBottomIfNeeded = () => {{ + if (!{ref_name}.current) return; + + const container = {ref_name}.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; +}}; +""", + f""" +useEffect(() => {{ + const container = {ref_name}.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 diff --git a/reflex/components/core/auto_scroll.pyi b/reflex/components/core/auto_scroll.pyi new file mode 100644 index 00000000000..690b11e5777 --- /dev/null +++ b/reflex/components/core/auto_scroll.pyi @@ -0,0 +1,103 @@ +"""Stub file for reflex/components/core/auto_scroll.py""" + +# ------------------- DO NOT EDIT ---------------------- +# This file was generated by `reflex/utils/pyi_generator.py`! +# ------------------------------------------------------ +from typing import Any, Dict, Optional, Union, overload + +from reflex.components.el.elements.typography import Div +from reflex.event import EventType +from reflex.style import Style +from reflex.utils.imports import ImportDict +from reflex.vars.base import Var + +class AutoScroll(Div): + @overload + @classmethod + def create( # type: ignore + cls, + *children, + access_key: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + auto_capitalize: Optional[ + Union[Var[Union[bool, int, str]], bool, int, str] + ] = None, + content_editable: Optional[ + Union[Var[Union[bool, int, str]], bool, int, str] + ] = None, + context_menu: Optional[ + Union[Var[Union[bool, int, str]], bool, int, str] + ] = None, + dir: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + draggable: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + enter_key_hint: Optional[ + Union[Var[Union[bool, int, str]], bool, int, str] + ] = None, + hidden: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + input_mode: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + item_prop: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + lang: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + role: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + slot: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + spell_check: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + tab_index: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + title: Optional[Union[Var[Union[bool, int, str]], bool, int, str]] = None, + style: Optional[Style] = None, + key: Optional[Any] = None, + id: Optional[Any] = None, + class_name: Optional[Any] = None, + autofocus: Optional[bool] = None, + custom_attrs: Optional[Dict[str, Union[Var, Any]]] = None, + on_blur: Optional[EventType[()]] = None, + on_click: Optional[EventType[()]] = None, + on_context_menu: Optional[EventType[()]] = None, + on_double_click: Optional[EventType[()]] = None, + on_focus: Optional[EventType[()]] = None, + on_mount: Optional[EventType[()]] = None, + on_mouse_down: Optional[EventType[()]] = None, + on_mouse_enter: Optional[EventType[()]] = None, + on_mouse_leave: Optional[EventType[()]] = None, + on_mouse_move: Optional[EventType[()]] = None, + on_mouse_out: Optional[EventType[()]] = None, + on_mouse_over: Optional[EventType[()]] = None, + on_mouse_up: Optional[EventType[()]] = None, + on_scroll: Optional[EventType[()]] = None, + on_unmount: Optional[EventType[()]] = None, + **props, + ) -> "AutoScroll": + """Create an AutoScroll component. + + Args: + *children: The children of the component. + access_key: Provides a hint for generating a keyboard shortcut for the current element. + auto_capitalize: Controls whether and how text input is automatically capitalized as it is entered/edited by the user. + content_editable: Indicates whether the element's content is editable. + context_menu: Defines the ID of a