ソースを参照

Add auto scroll (#4790)

* add auto_scroll

* add auto_scroll

* add auto_scroll to global

* use random id for maximum safety
Khaleel Al-Adhami 3 ヶ月 前
コミット
3129ddab47

+ 1 - 0
reflex/__init__.py

@@ -248,6 +248,7 @@ COMPONENTS_CORE_MAPPING: dict = {
         "selected_files",
         "upload",
     ],
+    "components.core.auto_scroll": ["auto_scroll"],
 }
 
 COMPONENTS_BASE_MAPPING: dict = {

+ 1 - 0
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

+ 1 - 0
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(

+ 1 - 0
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

+ 111 - 0
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

+ 103 - 0
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 <menu> element which will serve as the element's context menu.
+            dir: Defines the text direction. Allowed values are ltr (Left-To-Right) or rtl (Right-To-Left)
+            draggable: Defines whether the element can be dragged.
+            enter_key_hint: Hints what media types the media element is able to play.
+            hidden: Defines whether the element is hidden.
+            input_mode: Defines the type of the element.
+            item_prop: Defines the name of the element for metadata purposes.
+            lang: Defines the language used in the element.
+            role: Defines the role of the element.
+            slot: Assigns a slot in a shadow DOM shadow tree to an element.
+            spell_check: Defines whether the element may be checked for spelling errors.
+            tab_index: Defines the position of the current element in the tabbing order.
+            title: Defines a tooltip for the element.
+            style: The style of the component.
+            key: A unique key for the component.
+            id: The id for the component.
+            class_name: The class name for the component.
+            autofocus: Whether the component should take the focus once the page is loaded
+            custom_attrs: custom attribute
+            **props: The props of the component.
+
+        Returns:
+            An AutoScroll component.
+        """
+        ...
+
+    def add_imports(self) -> ImportDict | list[ImportDict]: ...
+    def add_hooks(self) -> list[str | Var]: ...
+
+auto_scroll = AutoScroll.create