Bläddra i källkod

Introduce QSlideItem element (#4358)

This PR introduces a Slide Item element based on Quasar's QSlideItem
https://quasar.dev/vue-components/slide-item

@falkoschindler and @rodja I would be keen to hear your feedback on how
I handled the `LeftSlide`, `RightSlide`, etc relationship with
`SlideItem`, I feel there may be a better way to add the association of
their slot to the parent element.

I'm also interested to hear your feedback on whether an `on_change`
callback is really needed if each slot already has an `on_slide`
callback.

I was undecided whether creating `LeftSlide`, `RightSlide`, etc classes
were truly necessary, a user could just use `SlideSide` with the `side`
parameter, but noted the style of `Drawer`, `LeftDrawer`, `RightDrawer`
so aligned to this.

---

Feature request: #4282

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
Simon Robinson 2 månader sedan
förälder
incheckning
49a7fe302e

+ 132 - 0
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')

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

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

+ 57 - 0
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')

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

+ 2 - 0
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)
 
 

+ 63 - 0
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)