瀏覽代碼

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 月之前
父節點
當前提交
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:
 if TYPE_CHECKING:
     from .client import Client
     from .client import Client
     from .element import Element
     from .element import Element
+    from .elements.slide_item import SlideSide
     from .observables import ObservableCollection
     from .observables import ObservableCollection
 
 
 
 
@@ -56,6 +57,11 @@ class ClickEventArguments(UiEventArguments):
     pass
     pass
 
 
 
 
+@dataclass(**KWONLY_SLOTS)
+class SlideEventArguments(UiEventArguments):
+    side: SlideSide
+
+
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
 class EChartPointClickEventArguments(UiEventArguments):
 class EChartPointClickEventArguments(UiEventArguments):
     component_type: str
     component_type: str

+ 2 - 0
nicegui/ui.py

@@ -98,6 +98,7 @@ __all__ = [
     'select',
     'select',
     'separator',
     'separator',
     'skeleton',
     'skeleton',
+    'slide_item',
     'slider',
     'slider',
     'space',
     'space',
     'spinner',
     'spinner',
@@ -202,6 +203,7 @@ from .elements.scroll_area import ScrollArea as scroll_area
 from .elements.select import Select as select
 from .elements.select import Select as select
 from .elements.separator import Separator as separator
 from .elements.separator import Separator as separator
 from .elements.skeleton import Skeleton as skeleton
 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.slider import Slider as slider
 from .elements.space import Space as space
 from .elements.space import Space as space
 from .elements.spinner import Spinner as spinner
 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]
     Label [text=Hidden, visible=False]
 '''.strip()
 '''.strip()
 
 
+
 async def test_typing_to_disabled_element(user: User) -> None:
 async def test_typing_to_disabled_element(user: User) -> None:
     initial_value = 'Hello first'
     initial_value = 'Hello first'
     given_new_input = 'Hello second'
     given_new_input = 'Hello second'

+ 2 - 0
website/documentation/content/section_page_layout.py

@@ -12,6 +12,7 @@ from . import (
     grid_documentation,
     grid_documentation,
     list_documentation,
     list_documentation,
     menu_documentation,
     menu_documentation,
+    slide_item_documentation,
     notification_documentation,
     notification_documentation,
     notify_documentation,
     notify_documentation,
     pagination_documentation,
     pagination_documentation,
@@ -56,6 +57,7 @@ doc.intro(column_documentation)
 doc.intro(row_documentation)
 doc.intro(row_documentation)
 doc.intro(grid_documentation)
 doc.intro(grid_documentation)
 doc.intro(list_documentation)
 doc.intro(list_documentation)
+doc.intro(slide_item_documentation)
 doc.intro(fullscreen_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)