Repository: zauberzeug/nicegui
Pull request #2990 (2024-04-30 10:32:41)

Documentation: Fix a broken link on the ui.page documentation


Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 89de52cf..18a52e3b 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -10,6 +10,7 @@
     "--disable=C0301", // Line too long (exceeds character limit)
     "--disable=C0302", // Too many lines in module
     "--disable=R0801", // Similar lines in files
+    "--disable=R0901", // Too many ancestors
     "--disable=R0902", // Too many instance attributes
     "--disable=R0903", // Too few public methods
     "--disable=R0904", // Too many public methods
diff --git a/nicegui/client.py b/nicegui/client.py
index 0eea0c08..a552423f 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -94,7 +94,7 @@ class Client:
     @property
     def has_socket_connection(self) -> bool:
         """Return True if the client is connected, False otherwise."""
-        return self.environ is not None
+        return self.tab_id is not None
 
     @property
     def head_html(self) -> str:
diff --git a/nicegui/elements/chip.py b/nicegui/elements/chip.py
new file mode 100644
index 00000000..0c40ed01
--- /dev/null
+++ b/nicegui/elements/chip.py
@@ -0,0 +1,61 @@
+from typing import Any, Callable, Optional
+
+from typing_extensions import Self
+
+from ..events import ClickEventArguments, handle_event
+from .mixins.color_elements import BackgroundColorElement, TextColorElement
+from .mixins.disableable_element import DisableableElement
+from .mixins.selectable_element import SelectableElement
+from .mixins.text_element import TextElement
+from .mixins.value_element import ValueElement
+
+
+class Chip(ValueElement, TextElement, BackgroundColorElement, TextColorElement, DisableableElement, SelectableElement):
+    TEXT_COLOR_PROP = 'text-color'
+
+    def __init__(self,
+                 text: str = '',
+                 *,
+                 icon: Optional[str] = None,
+                 color: Optional[str] = 'primary',
+                 text_color: Optional[str] = None,
+                 on_click: Optional[Callable[..., Any]] = None,
+                 selectable: bool = False,
+                 selected: bool = False,
+                 on_selection_change: Optional[Callable[..., Any]] = None,
+                 removable: bool = False,
+                 on_value_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
+        """Chip
+
+        A chip element wrapping Quasar's `QChip <https://quasar.dev/vue-components/chip>`_ component.
+        It can be clickable, selectable and removable.
+
+        :param text: the initial value of the text field (default: "")
+        :param icon: the name of an icon to be displayed on the chip (default: `None`)
+        :param color: the color name for component (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
+        :param text_color: text color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
+        :param on_click: callback which is invoked when chip is clicked. Makes the chip clickable if set
+        :param selectable: whether the chip is selectable (default: `False`)
+        :param selected: whether the chip is selected (default: `False`)
+        :param on_selection_change: callback which is invoked when the chip's selection state is changed
+        :param removable: whether the chip is removable. Shows a small "x" button if True (default: `False`)
+        :param on_value_change: callback which is invoked when the chip is removed or unremoved
+        """
+        super().__init__(tag='q-chip', value=True, on_value_change=on_value_change,
+                         text=text, text_color=text_color, background_color=color,
+                         selectable=selectable, selected=selected, on_selection_change=on_selection_change)
+        if icon:
+            self._props['icon'] = icon
+
+        self._props['removable'] = removable
+
+        if on_click:
+            self.on_click(on_click)
+
+    def on_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the chip is clicked."""
+        self._props['clickable'] = True
+        self.update()
+        self.on('click', lambda _: handle_event(callback, ClickEventArguments(sender=self, client=self.client)), [])
+        return self
diff --git a/nicegui/elements/mixins/selectable_element.py b/nicegui/elements/mixins/selectable_element.py
new file mode 100644
index 00000000..e5f01f14
--- /dev/null
+++ b/nicegui/elements/mixins/selectable_element.py
@@ -0,0 +1,109 @@
+from typing import Any, Callable, List, Optional, cast
+
+from typing_extensions import Self
+
+from ...binding import BindableProperty, bind, bind_from, bind_to
+from ...element import Element
+from ...events import ValueChangeEventArguments, handle_event
+
+
+class SelectableElement(Element):
+    selected = BindableProperty(
+        on_change=lambda sender, selected: cast(Self, sender)._handle_selection_change(selected))  # pylint: disable=protected-access
+
+    def __init__(self, *,
+                 selectable: bool,
+                 selected: bool,
+                 on_selection_change: Optional[Callable[..., Any]],
+                 **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        if not selectable:
+            return
+
+        self._props['selectable'] = selectable
+
+        self.selected = selected
+        self._props['selected'] = selected
+        self.set_selected(selected)
+        self.on('update:selected', lambda e: self.set_selected(e.args))
+
+        self._selection_change_handlers: List[Callable[..., Any]] = []
+        if on_selection_change:
+            self.on_selection_change(on_selection_change)
+
+    def on_selection_change(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the selection state changes."""
+        self._selection_change_handlers.append(callback)
+        return self
+
+    def bind_selected_to(self,
+                         target_object: Any,
+                         target_name: str = 'selected',
+                         forward: Callable[..., Any] = lambda x: x,
+                         ) -> Self:
+        """Bind the selection state of this element to the target object's target_name property.
+
+        The binding works one way only, from this element to the target.
+        The update happens immediately and whenever a value changes.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        """
+        bind_to(self, 'selected', target_object, target_name, forward)
+        return self
+
+    def bind_selected_from(self,
+                           target_object: Any,
+                           target_name: str = 'selected',
+                           backward: Callable[..., Any] = lambda x: x,
+                           ) -> Self:
+        """Bind the selection state of this element from the target object's target_name property.
+
+        The binding works one way only, from the target to this element.
+        The update happens immediately and whenever a value changes.
+
+        :param target_object: The object to bind from.
+        :param target_name: The name of the property to bind from.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind_from(self, 'selected', target_object, target_name, backward)
+        return self
+
+    def bind_selected(self,
+                      target_object: Any,
+                      target_name: str = 'selected', *,
+                      forward: Callable[..., Any] = lambda x: x,
+                      backward: Callable[..., Any] = lambda x: x,
+                      ) -> Self:
+        """Bind the selection state of this element to the target object's target_name property.
+
+        The binding works both ways, from this element to the target and from the target to this element.
+        The update happens immediately and whenever a value changes.
+        The backward binding takes precedence for the initial synchronization.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind(self, 'selected', target_object, target_name, forward=forward, backward=backward)
+        return self
+
+    def set_selected(self, selected: bool) -> None:
+        """Set the selection state of this element.
+
+        :param selected: The new selection state.
+        """
+        self.selected = selected
+
+    def _handle_selection_change(self, selected: bool) -> None:
+        """Called when the selection state of this element changes.
+
+        :param selected: The new selection state.
+        """
+        self._props['selected'] = selected
+        self.update()
+        args = ValueChangeEventArguments(sender=self, client=self.client, value=self._props['selected'])
+        for handler in self._selection_change_handlers:
+            handle_event(handler, args)
diff --git a/nicegui/ui.py b/nicegui/ui.py
index 76adb0dc..267aafaa 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -14,6 +14,7 @@ __all__ = [
     'chart',
     'chat_message',
     'checkbox',
+    'chip',
     'clipboard',
     'code',
     'color_input',
@@ -139,6 +140,7 @@ from .elements.carousel import CarouselSlide as carousel_slide
 from .elements.chart import chart
 from .elements.chat_message import ChatMessage as chat_message
 from .elements.checkbox import Checkbox as checkbox
+from .elements.chip import Chip as chip
 from .elements.code import Code as code
 from .elements.color_input import ColorInput as color_input
 from .elements.color_picker import ColorPicker as color_picker
diff --git a/website/documentation/content/chip_documentation.py b/website/documentation/content/chip_documentation.py
new file mode 100644
index 00000000..c9ce4396
--- /dev/null
+++ b/website/documentation/content/chip_documentation.py
@@ -0,0 +1,39 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.chip)
+def main_demo() -> None:
+    with ui.row().classes('gap-1'):
+        ui.chip('Click me', icon='ads_click', on_click=lambda: ui.notify('Clicked'))
+        ui.chip('Selectable', selectable=True, icon='bookmark', color='orange')
+        ui.chip('Removable', removable=True, icon='label', color='indigo-3')
+        ui.chip('Styled', icon='star', color='green').props('outline square')
+        ui.chip('Disabled', icon='block', color='red').set_enabled(False)
+
+
+@doc.demo('Dynamic chip elements as labels/tags', '''
+    This demo shows how to implement a dynamic list of chips as labels or tags.
+    You can add new chips by typing a label and pressing Enter or pressing the plus button.
+    Removed chips still exist, but their value is set to `False`.
+''')
+def labels():
+    def add_chip():
+        with chips:
+            ui.chip(label_input.value, icon='label', color='silver', removable=True)
+        label_input.value = ''
+
+    label_input = ui.input('Add label').on('keydown.enter', add_chip)
+    with label_input.add_slot('append'):
+        ui.button(icon='add', on_click=add_chip).props('round dense flat')
+
+    with ui.row().classes('gap-0') as chips:
+        ui.chip('Label 1', icon='label', color='silver', removable=True)
+
+    ui.button('Restore removed chips', icon='unarchive',
+              on_click=lambda: [chip.set_value(True) for chip in chips]) \
+        .props('flat')
+
+
+doc.reference(ui.chip)
diff --git a/website/documentation/content/page_documentation.py b/website/documentation/content/page_documentation.py
index c7b74c4d..5a39bedd 100644
--- a/website/documentation/content/page_documentation.py
+++ b/website/documentation/content/page_documentation.py
@@ -60,8 +60,8 @@ def wait_for_connected_demo():
     This is especially useful if you want to reuse the same prefix for multiple pages.
     The router and its pages can be neatly tugged away in a separate module (e.g. file) and
     the router is simply imported and included in the main app.
-    See our [modularization example](https://github.com/zauberzeug/nicegui/blob/main/examples/modularization/example_c.py)
-    for a multi-file app structure.
+    See our [modularization example](https://github.com/zauberzeug/nicegui/blob/main/examples/modularization/api_router_example.py)
+    for a multi-file app structure using an API router.
 ''', tab='/sub-path')
 def api_router_demo():
     # from nicegui import APIRouter, app
diff --git a/website/documentation/content/section_controls.py b/website/documentation/content/section_controls.py
index 6851fcc6..31488eb9 100644
--- a/website/documentation/content/section_controls.py
+++ b/website/documentation/content/section_controls.py
@@ -4,6 +4,7 @@ from . import (
     button_dropdown_documentation,
     button_group_documentation,
     checkbox_documentation,
+    chip_documentation,
     color_input_documentation,
     color_picker_documentation,
     date_documentation,
@@ -29,6 +30,7 @@ doc.intro(button_documentation)
 doc.intro(button_group_documentation)
 doc.intro(button_dropdown_documentation)
 doc.intro(badge_documentation)
+doc.intro(chip_documentation)
 doc.intro(toggle_documentation)
 doc.intro(radio_documentation)
 doc.intro(select_documentation)


Test patch:
diff --git a/tests/test_chip.py b/tests/test_chip.py
new file mode 100644
index 00000000..bb098801
--- /dev/null
+++ b/tests/test_chip.py
@@ -0,0 +1,27 @@
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+def test_removable_chip(screen: Screen):
+    chip = ui.chip('Chip', removable=True)
+
+    screen.open('/')
+    screen.should_contain('Chip')
+
+    chip.set_value(False)
+    screen.wait(0.5)
+    screen.should_not_contain('Chip')
+
+
+def test_selectable_chip(screen: Screen):
+    chip = ui.chip('Chip', selectable=True)
+    ui.label().bind_text_from(chip, 'selected', lambda s: f'Selected: {s}')
+
+    screen.open('/')
+    screen.should_contain('Selected: False')
+
+    screen.click('Chip')
+    screen.should_contain('Selected: True')
+
+    screen.click('Chip')
+    screen.should_contain('Selected: False')