Repository: zauberzeug/nicegui
Pull request #4411 (2025-03-03 12:26:34)

flag for file existence checks on add_static_file
- Added flag `strict` to `add_static_file` params that determines whether to check if file exists or not.
- default value should be FALSE because:
1) python stdlibs like [pathlib](https://docs.python.org/3/library/pathlib.html) are default false, because paths are not checked for existence until accessed
2) existing code is very unlikely to use `add_static_file` raising an error to determine whether a file exists or not
3) raising error on file existence is undocumented behavior, making it even less likely for existing code to rely on
- changed error raised from general ValueError to more appropriate FileNotFoundError as used by stdlib



Patch:
diff --git a/examples/authentication/main.py b/examples/authentication/main.py
index 81f46ba8..5a7dcf36 100755
--- a/examples/authentication/main.py
+++ b/examples/authentication/main.py
@@ -28,8 +28,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
     async def dispatch(self, request: Request, call_next):
         if not app.storage.user.get('authenticated', False):
             if not request.url.path.startswith('/_nicegui') and request.url.path not in unrestricted_page_routes:
-                app.storage.user['referrer_path'] = request.url.path  # remember where the user wanted to go
-                return RedirectResponse('/login')
+                return RedirectResponse(f'/login?redirect_to={request.url.path}')
         return await call_next(request)
 
 
@@ -53,11 +52,11 @@ def test_page() -> None:
 
 
 @ui.page('/login')
-def login() -> Optional[RedirectResponse]:
+def login(redirect_to: str = '/') -> Optional[RedirectResponse]:
     def try_login() -> None:  # local function to avoid passing username and password as arguments
         if passwords.get(username.value) == password.value:
             app.storage.user.update({'username': username.value, 'authenticated': True})
-            ui.navigate.to(app.storage.user.get('referrer_path', '/'))  # go back to where the user wanted to go
+            ui.navigate.to(redirect_to)  # go back to where the user wanted to go
         else:
             ui.notify('Wrong username or password', color='negative')
 
diff --git a/nicegui/app/app.py b/nicegui/app/app.py
index cc7fc7d9..dcf3bf8a 100644
--- a/nicegui/app/app.py
+++ b/nicegui/app/app.py
@@ -175,6 +175,7 @@ class App(FastAPI):
                         local_file: Union[str, Path],
                         url_path: Optional[str] = None,
                         single_use: bool = False,
+                        strict: bool = True,
                         max_cache_age: int = 3600) -> str:
         """Add a single static file.
 
@@ -184,9 +185,13 @@ class App(FastAPI):
         To make a whole folder of files accessible, use `add_static_files()` instead.
         For media files which should be streamed, you can use `add_media_files()` or `add_media_file()` instead.
 
+        Deprecation warning:
+        Non-existing files will raise a ``FileNotFoundError`` rather than a ``ValueError`` in version 3.0 if ``strict`` is ``True``.
+
         :param local_file: local file to serve as static content
         :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
         :param single_use: whether to remove the route after the file has been downloaded once (default: False)
+        :param strict: whether to raise a ``ValueError`` if the file does not exist (default: False, *added in version 2.12.0*)
         :param max_cache_age: value for max-age set in Cache-Control header (*added in version 2.8.0*)
         :return: encoded URL which can be used to access the file
         """
@@ -194,8 +199,8 @@ class App(FastAPI):
             raise ValueError('''Value of max_cache_age must be a positive integer or 0.''')
 
         file = Path(local_file).resolve()
-        if not file.is_file():
-            raise ValueError(f'File not found: {file}')
+        if strict and not file.is_file():
+            raise ValueError(f'File not found: {file}')  # DEPRECATED: will raise a ``FileNotFoundError`` in version 3.0
         path = f'/_nicegui/auto/static/{helpers.hash_file_path(file)}/{file.name}' if url_path is None else url_path
 
         @self.get(path)
@@ -231,7 +236,7 @@ class App(FastAPI):
                        local_file: Union[str, Path],
                        url_path: Optional[str] = None,
                        single_use: bool = False,
-                       ) -> str:
+                       strict: bool = True) -> str:
         """Add a single media file.
 
         Allows a local file to be streamed.
@@ -240,14 +245,18 @@ class App(FastAPI):
         To make a whole folder of media files accessible via streaming, use `add_media_files()` instead.
         For small static files, you can use `add_static_files()` or `add_static_file()` instead.
 
+        Deprecation warning:
+        Non-existing files will raise a ``FileNotFoundError`` rather than a ``ValueError`` in version 3.0 if ``strict`` is ``True``.
+
         :param local_file: local file to serve as media content
         :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
         :param single_use: whether to remove the route after the media file has been downloaded once (default: False)
+        :param strict: whether to raise a ``ValueError`` if the file does not exist (default: False, *added in version 2.12.0*)
         :return: encoded URL which can be used to access the file
         """
         file = Path(local_file).resolve()
-        if not file.is_file():
-            raise ValueError(f'File not found: {local_file}')
+        if strict and not file.is_file():
+            raise ValueError(f'File not found: {file}')  # DEPRECATED: will raise a ``FileNotFoundError`` in version 3.0
         path = f'/_nicegui/auto/media/{helpers.hash_file_path(file)}/{file.name}' if url_path is None else url_path
 
         @self.get(path)
diff --git a/nicegui/elements/slide_item.py b/nicegui/elements/slide_item.py
new file mode 100644
index 00000000..ce5cb851
--- /dev/null
+++ b/nicegui/elements/slide_item.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+from typing import Literal, Optional
+
+from typing_extensions import Self
+
+from ..events import Handler, SlideEventArguments, handle_event
+from ..slot import Slot
+from .item import Item
+from .label import Label
+from .mixins.disableable_element import DisableableElement
+
+SlideSide = Literal['left', 'right', 'top', 'bottom']
+
+
+class SlideItem(DisableableElement):
+
+    def __init__(self, text: str = '', *, on_slide: Optional[Handler[SlideEventArguments]] = None) -> None:
+        """Slide Item
+
+        This element is based on Quasar's `QSlideItem <https://quasar.dev/vue-components/slide-item/>`_ component.
+
+        If the ``text`` parameter is provided, a nested ``ui.item`` element will be created with the given text.
+        If you want to customize how the text is displayed, you can place custom elements inside the slide item.
+
+        To fill slots for individual slide actions, use the ``left``, ``right``, ``top``, or ``bottom`` methods or
+        the ``action`` method with a side argument ("left", "right", "top", or "bottom").
+
+        Once a slide action has occurred, the slide item can be reset back to its initial state using the ``reset`` method.
+
+        *Added in version 2.12.0*
+
+        :param text: text to be displayed (default: "")
+        :param on_slide: callback which is invoked when any slide action is activated
+        """
+        super().__init__(tag='q-slide-item')
+
+        if text:
+            with self:
+                Item(text)
+
+        if on_slide:
+            self.on_slide(None, on_slide)
+
+    def action(self,
+               side: SlideSide,
+               text: str = '', *,
+               on_slide: Optional[Handler[SlideEventArguments]] = None,
+               color: Optional[str] = 'primary',
+               ) -> Slot:
+        """Add a slide action to a specified side.
+
+        :param side: side of the slide item where the slide should be added ("left", "right", "top", "bottom")
+        :param text: text to be displayed (default: "")
+        :param on_slide: callback which is invoked when the slide action is activated
+        :param color: the color of the slide background (either a Quasar, Tailwind, or CSS color or ``None``, default: "primary")
+        """
+        if color:
+            self._props[f'{side}-color'] = color
+
+        if on_slide:
+            self.on_slide(side, on_slide)
+
+        slot = self.add_slot(side)
+        if text:
+            with slot:
+                Label(text)
+
+        return slot
+
+    def left(self,
+             text: str = '', *,
+             on_slide: Optional[Handler[SlideEventArguments]] = None,
+             color: Optional[str] = 'primary',
+             ) -> Slot:
+        """Add a slide action to the left side.
+
+        :param text: text to be displayed (default: "")
+        :param on_slide: callback which is invoked when the slide action is activated
+        :param color: the color of the slide background (either a Quasar, Tailwind, or CSS color or ``None``, default: "primary")
+        """
+        return self.action('left', text=text, on_slide=on_slide, color=color)
+
+    def right(self,
+              text: str = '', *,
+              on_slide: Optional[Handler[SlideEventArguments]] = None,
+              color: Optional[str] = 'primary',
+              ) -> Slot:
+        """Add a slide action to the right side.
+
+        :param text: text to be displayed (default: "")
+        :param on_slide: callback which is invoked when the slide action is activated
+        :param color: the color of the slide background (either a Quasar, Tailwind, or CSS color or ``None``, default: "primary")
+        """
+        return self.action('right', text=text, on_slide=on_slide, color=color)
+
+    def top(self,
+            text: str = '', *,
+            on_slide: Optional[Handler[SlideEventArguments]] = None,
+            color: Optional[str] = 'primary',
+            ) -> Slot:
+        """Add a slide action to the top side.
+
+        :param text: text to be displayed (default: "")
+        :param on_slide: callback which is invoked when the slide action is activated
+        :param color: the color of the slide background (either a Quasar, Tailwind, or CSS color or ``None``, default: "primary")
+        """
+        return self.action('top', text=text, on_slide=on_slide, color=color)
+
+    def bottom(self,
+               text: str = '', *,
+               on_slide: Optional[Handler[SlideEventArguments]] = None,
+               color: Optional[str] = 'primary',
+               ) -> Slot:
+        """Add a slide action to the bottom side.
+
+        :param text: text to be displayed (default: "")
+        :param on_slide: callback which is invoked when the slide action is activated
+        :param color: the color of the slide background (either a Quasar, Tailwind, or CSS color or ``None``, default: "primary")
+        """
+        return self.action('bottom', text=text, on_slide=on_slide, color=color)
+
+    def on_slide(self, side: SlideSide | None, handler: Handler[SlideEventArguments]) -> Self:
+        """Add a callback to be invoked when the slide action is activated."""
+        self.on(side or 'action', lambda e: handle_event(handler, SlideEventArguments(sender=self,
+                                                                                      client=self.client,
+                                                                                      side=e.args.get('side', side))))
+        return self
+
+    def reset(self) -> None:
+        """Reset the slide item to its initial state."""
+        self.run_method('reset')
diff --git a/nicegui/events.py b/nicegui/events.py
index fd210005..d8e58d37 100644
--- a/nicegui/events.py
+++ b/nicegui/events.py
@@ -27,6 +27,7 @@ from .slot import Slot
 if TYPE_CHECKING:
     from .client import Client
     from .element import Element
+    from .elements.slide_item import SlideSide
     from .observables import ObservableCollection
 
 
@@ -56,6 +57,11 @@ class ClickEventArguments(UiEventArguments):
     pass
 
 
+@dataclass(**KWONLY_SLOTS)
+class SlideEventArguments(UiEventArguments):
+    side: SlideSide
+
+
 @dataclass(**KWONLY_SLOTS)
 class EChartPointClickEventArguments(UiEventArguments):
     component_type: str
diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py
index 9b2845f1..b869c687 100644
--- a/nicegui/page_layout.py
+++ b/nicegui/page_layout.py
@@ -83,7 +83,7 @@ class Header(ValueElement, default_classes='nicegui-header'):
         self.value = False
 
 
-class Drawer(Element, default_classes='nicegui-drawer'):
+class Drawer(ValueElement, default_classes='nicegui-drawer'):
 
     def __init__(self,
                  side: DrawerSides, *,
@@ -102,6 +102,10 @@ class Drawer(Element, default_classes='nicegui-drawer'):
         Note: Depending on the side, the drawer is automatically placed above or below the main page container in the DOM to improve accessibility.
         To change the order, use the `move` method.
 
+        A value of ``None`` will automatically open or close the drawer depending on the current layout width (breakpoint: >=1024 px).
+        On the auto-index page, the value will remain ``None`` until the drawer is opened, closed or toggled.
+        On other pages, the value will be requested from the client when the websocket connection is established.
+
         :param side: side of the page where the drawer should be placed (`left` or `right`)
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
@@ -112,11 +116,8 @@ class Drawer(Element, default_classes='nicegui-drawer'):
         """
         _check_current_slot(self)
         with context.client.layout:
-            super().__init__('q-drawer')
-        if value is None:
-            self._props['show-if-above'] = True
-        else:
-            self._props['model-value'] = value
+            super().__init__(tag='q-drawer', value=value, on_value_change=None)
+        self._props['show-if-above'] = value is None
         self._props['side'] = side
         self._props['bordered'] = bordered
         self._props['elevated'] = elevated
@@ -129,17 +130,30 @@ class Drawer(Element, default_classes='nicegui-drawer'):
         page_container_index = self.client.layout.default_slot.children.index(self.client.page_container)
         self.move(target_index=page_container_index if side == 'left' else page_container_index + 1)
 
+        if value is None and not self.client.is_auto_index_client:
+            async def _request_value() -> None:
+                js_code = f'!getHtmlElement({self.id}).parentElement.classList.contains("q-layout--prevent-focus")'
+                self.value = await context.client.run_javascript(js_code)
+            self.client.on_connect(_request_value)
+
     def toggle(self) -> None:
         """Toggle the drawer"""
-        self.run_method('toggle')
+        if self.value is None:
+            self.run_method('toggle')
+        else:
+            self.value = not self.value
 
     def show(self) -> None:
         """Show the drawer"""
-        self.run_method('show')
+        self.value = True
 
     def hide(self) -> None:
         """Hide the drawer"""
-        self.run_method('hide')
+        self.value = False
+
+    def _handle_value_change(self, value: bool) -> None:
+        super()._handle_value_change(value)
+        self._props['show-if-above'] = value is None
 
 
 class LeftDrawer(Drawer):
@@ -160,6 +174,10 @@ class LeftDrawer(Drawer):
         Note: The left drawer is automatically placed above the main page container in the DOM to improve accessibility.
         To change the order, use the `move` method.
 
+        A value of ``None`` will automatically open or close the drawer depending on the current layout width (breakpoint: >=1024 px).
+        On the auto-index page, the value will remain ``None`` until the drawer is opened, closed or toggled.
+        On other pages, the value will be requested from the client when the websocket connection is established.
+
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the drawer should have a border (default: `False`)
@@ -194,6 +212,10 @@ class RightDrawer(Drawer):
         Note: The right drawer is automatically placed below the main page container in the DOM to improve accessibility.
         To change the order, use the `move` method.
 
+        A value of ``None`` will automatically open or close the drawer depending on the current layout width (breakpoint: >=1024 px).
+        On the auto-index page, the value will remain ``None`` until the drawer is opened, closed or toggled.
+        On other pages, the value will be requested from the client when the websocket connection is established.
+
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the drawer should have a border (default: `False`)
diff --git a/nicegui/ui.py b/nicegui/ui.py
index ad2713c3..62f79c0a 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -98,6 +98,7 @@ __all__ = [
     'select',
     'separator',
     'skeleton',
+    'slide_item',
     'slider',
     'space',
     'spinner',
@@ -202,6 +203,7 @@ from .elements.scroll_area import ScrollArea as scroll_area
 from .elements.select import Select as select
 from .elements.separator import Separator as separator
 from .elements.skeleton import Skeleton as skeleton
+from .elements.slide_item import SlideItem as slide_item
 from .elements.slider import Slider as slider
 from .elements.space import Space as space
 from .elements.spinner import Spinner as spinner
diff --git a/website/documentation/content/overview.py b/website/documentation/content/overview.py
index c8d48774..b390b435 100644
--- a/website/documentation/content/overview.py
+++ b/website/documentation/content/overview.py
@@ -251,6 +251,7 @@ def map_of_nicegui():
             - [`ui.plotly`](/documentation/plotly)
             - [`ui.pyplot`](/documentation/pyplot)
             - [`ui.radio`](/documentation/radio)
+            - [`ui.rating`](/documentation/rating)
             - [`ui.range`](/documentation/range)
             - [`ui.restructured_text`](/documentation/restructured_text)
             - [`ui.row`](/documentation/row)
@@ -259,6 +260,7 @@ def map_of_nicegui():
             - [`ui.select`](/documentation/select)
             - [`ui.separator`](/documentation/separator)
             - [`ui.skeleton`](/documentation/skeleton)
+            - [`ui.slide_item`](/documentation/slide_item)
             - [`ui.slider`](/documentation/slider)
             - [`ui.space`](/documentation/space)
             - [`ui.spinner`](/documentation/spinner)
diff --git a/website/documentation/content/section_page_layout.py b/website/documentation/content/section_page_layout.py
index aa130dca..fc467d97 100644
--- a/website/documentation/content/section_page_layout.py
+++ b/website/documentation/content/section_page_layout.py
@@ -12,6 +12,7 @@ from . import (
     grid_documentation,
     list_documentation,
     menu_documentation,
+    slide_item_documentation,
     notification_documentation,
     notify_documentation,
     pagination_documentation,
@@ -56,6 +57,7 @@ doc.intro(column_documentation)
 doc.intro(row_documentation)
 doc.intro(grid_documentation)
 doc.intro(list_documentation)
+doc.intro(slide_item_documentation)
 doc.intro(fullscreen_documentation)
 
 
diff --git a/website/documentation/content/slide_item_documentation.py b/website/documentation/content/slide_item_documentation.py
new file mode 100644
index 00000000..3372badd
--- /dev/null
+++ b/website/documentation/content/slide_item_documentation.py
@@ -0,0 +1,63 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.slide_item)
+def main_demo() -> None:
+    with ui.list().props('bordered separator'):
+        with ui.slide_item('Slide me left or right') as slide_item_1:
+            slide_item_1.left('Left', color='green')
+            slide_item_1.right('Right', color='red')
+        with ui.slide_item('Slide me up or down') as slide_item_2:
+            slide_item_2.top('Top', color='blue')
+            slide_item_2.bottom('Bottom', color='purple')
+
+
+@doc.demo('More complex layout', '''
+    You can fill the slide item and its action slots with custom UI elements.
+''')
+def complex_demo():
+    with ui.list().props('bordered'):
+        with ui.slide_item() as slide_item:
+            with ui.item():
+                with ui.item_section().props('avatar'):
+                    ui.icon('person')
+                with ui.item_section():
+                    ui.item_label('Alice A. Anderson')
+                    ui.item_label('CEO').props('caption')
+            with slide_item.left(on_slide=lambda: ui.notify('Calling...')):
+                with ui.item(on_click=slide_item.reset):
+                    with ui.item_section().props('avatar'):
+                        ui.icon('phone')
+                    ui.item_section('Call')
+            with slide_item.right(on_slide=lambda: ui.notify('Texting...')):
+                with ui.item(on_click=slide_item.reset):
+                    ui.item_section('Text')
+                    with ui.item_section().props('avatar'):
+                        ui.icon('message')
+
+
+@doc.demo('Slide handlers', '''
+    An event handler can be triggered when a specific side is selected.
+''')
+def slide_callbacks():
+    with ui.list().props('bordered'):
+        with ui.slide_item('Slide me', on_slide=lambda e: ui.notify(f'Slide: {e.side}')) as slide_item:
+            slide_item.left('A', on_slide=lambda e: ui.notify(f'A ({e.side})'))
+            slide_item.right('B', on_slide=lambda e: ui.notify(f'B ({e.side})'))
+
+
+@doc.demo('Resetting the slide item', '''
+    After a slide action has occurred, the slide item can be reset back to its initial state using the ``reset`` method.
+''')
+def slide_reset():
+    with ui.list().props('bordered'):
+        with ui.slide_item() as slide_item:
+            ui.item('Slide me')
+            with slide_item.left(color='blue'):
+                ui.item('Left')
+            with slide_item.right(color='purple'):
+                ui.item('Right')
+
+    ui.button('Reset', on_click=slide_item.reset)


Test patch:
diff --git a/tests/test_slide_item.py b/tests/test_slide_item.py
new file mode 100644
index 00000000..61bc26d4
--- /dev/null
+++ b/tests/test_slide_item.py
@@ -0,0 +1,57 @@
+from selenium.webdriver.common.action_chains import ActionChains
+
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+def test_slide_item(screen: Screen):
+    label = ui.label('None')
+    with ui.slide_item('slide item', on_slide=lambda e: label.set_text(f'Event: {e.side}')) as slide_item:
+        slide_item.left()
+
+    screen.open('/')
+    screen.should_contain('slide item')
+    screen.should_contain('None')
+
+    ActionChains(screen.selenium) \
+        .move_to_element_with_offset(screen.find_element(slide_item), -20, 0) \
+        .click_and_hold() \
+        .pause(0.5) \
+        .move_by_offset(60, 0) \
+        .pause(0.5) \
+        .release() \
+        .perform()
+    screen.should_contain('Event: left')
+
+
+def test_slide_side(screen: Screen):
+    label = ui.label('None')
+    with ui.slide_item('slide item') as slide_item:
+        slide_item.left(on_slide=lambda e: label.set_text(f'Event: {e.side}'))
+        slide_item.right(on_slide=lambda e: label.set_text(f'Event: {e.side}'))
+
+    screen.open('/')
+    screen.should_contain('None')
+
+    ActionChains(screen.selenium) \
+        .move_to_element_with_offset(screen.find_element(slide_item), -20, 0) \
+        .click_and_hold() \
+        .pause(0.5) \
+        .move_by_offset(60, 0) \
+        .pause(0.5) \
+        .release() \
+        .perform()
+    screen.should_contain('Event: left')
+
+    slide_item.reset()
+    screen.should_contain('slide item')
+
+    ActionChains(screen.selenium) \
+        .move_to_element_with_offset(screen.find_element(slide_item), 20, 0) \
+        .click_and_hold() \
+        .pause(0.5) \
+        .move_by_offset(-60, 0) \
+        .pause(0.5) \
+        .release() \
+        .perform()
+    screen.should_contain('Event: right')
diff --git a/tests/test_user_simulation.py b/tests/test_user_simulation.py
index 9f7340b9..5750b5a5 100644
--- a/tests/test_user_simulation.py
+++ b/tests/test_user_simulation.py
@@ -466,6 +466,7 @@ q-layout
     Label [text=Hidden, visible=False]
 '''.strip()
 
+
 async def test_typing_to_disabled_element(user: User) -> None:
     initial_value = 'Hello first'
     given_new_input = 'Hello second'