From 7b9c584b5b2cc7d535f821c6d50bfa823e5d3cae Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 10 Feb 2025 11:12:32 -0800 Subject: [PATCH 1/4] 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 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..579b5f36266 --- /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 From c3c0dad378d76748104fa99e79a09f44ac94e8d3 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 10 Feb 2025 11:12:53 -0800 Subject: [PATCH 2/4] add auto_scroll --- reflex/components/core/auto_scroll.pyi | 103 +++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 reflex/components/core/auto_scroll.pyi 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 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 From 7841e27d322557c534f29d0e3fa39b6f15aa8562 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 10 Feb 2025 11:15:27 -0800 Subject: [PATCH 3/4] add auto_scroll to global --- reflex/__init__.py | 1 + reflex/__init__.pyi | 1 + 2 files changed, 2 insertions(+) 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 From de183bf42c45a51ff46f9cb354580fda900cd83d Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Wed, 12 Feb 2025 11:29:28 -0800 Subject: [PATCH 4/4] use random id for maximum safety --- reflex/components/core/auto_scroll.py | 55 ++++++++++++++------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/reflex/components/core/auto_scroll.py b/reflex/components/core/auto_scroll.py index 579b5f36266..d24bd9a1370 100644 --- a/reflex/components/core/auto_scroll.py +++ b/reflex/components/core/auto_scroll.py @@ -5,7 +5,7 @@ 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 +from reflex.vars.base import Var, get_unique_variable_name class AutoScroll(Div): @@ -25,9 +25,8 @@ def create(cls, *children, **props): 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) + 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. @@ -43,15 +42,16 @@ def add_hooks(self) -> list[str | Var]: 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);", - """ -const checkIfNearBottom = () => { - if (!containerRef.current) return; + f""" +const checkIfNearBottom = () => {{ + if (!{ref_name}.current) return; - const container = containerRef.current; + const container = {ref_name}.current; const nearBottomThreshold = 50; // pixels from bottom to trigger auto-scroll const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; @@ -60,34 +60,35 @@ def add_hooks(self) -> list[str | Var]: // Track if container had a scrollbar hadScrollbar.current = container.scrollHeight > container.clientHeight; -}; +}}; """, - """ -const scrollToBottomIfNeeded = () => { - if (!containerRef.current) return; + f""" +const scrollToBottomIfNeeded = () => {{ + if (!{ref_name}.current) return; - const container = containerRef.current; + 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)) { + if (wasNearBottom.current || (!hadScrollbar.current && hasScrollbarNow)) {{ container.scrollTop = container.scrollHeight; - } + }} // Update scrollbar state for next check - hadScrollbar.current = hasScrollbarNow;}; + hadScrollbar.current = hasScrollbarNow; +}}; """, - """ - useEffect(() => { - const container = containerRef.current; + f""" +useEffect(() => {{ + const container = {ref_name}.current; if (!container) return; // Create ResizeObserver to detect height changes - const resizeObserver = new ResizeObserver(() => { - scrollToBottomIfNeeded(); - }); + const resizeObserver = new ResizeObserver(() => {{ + scrollToBottomIfNeeded(); + }}); // Track scroll position before height changes container.addEventListener('scroll', checkIfNearBottom); @@ -98,11 +99,11 @@ def add_hooks(self) -> list[str | Var]: // Observe container for size changes resizeObserver.observe(container); - return () => { - container.removeEventListener('scroll', checkIfNearBottom); - resizeObserver.disconnect(); - }; - }); + return () => {{ + container.removeEventListener('scroll', checkIfNearBottom); + resizeObserver.disconnect(); + }}; +}}); """, ]