Repository: zauberzeug/nicegui
Pull request #4529 (2025-03-25 14:26:25)

refactor: Introduces `LabelElement`
This PR introduces the `LabelElement `and applies it to the inheritance of the following components to provide a series of methods about label:

- ui.input
- ui.select
- ui.color_input
- ui.number
- ui.tab
- ui.text_area
- ui.upload

evnchn: It's great, but I'm thinking one step ahead. 

We may want to allow post-creation editing of other things, such as placeholder. But then, this would require us to add 2 blocks of code per property. 

Then we might as well push this forward: https://github.com/zauberzeug/nicegui/pull/4464, unifying all such update maneuvers to the `.configure` method as discussed 
weinibuliu: Thanks @evnchn .

#4464 is progressing a bit slowly, and the community seems not very interested in it.

But even if #4464 is officially put into development, I don't think it will conflict with this PR. #4464 hopes to provide developers with a convenient way to set certain values ​​again, but it does not mean that it will be the only way. 
weinibuliu: Thanks @falkoschindler 

I was originally planning to do this, but I wasn't sure which elements needs the `label()`.

I'll do my best to improve this PR.
weinibuliu: Hi @falkoschindler  I have pushed a new commit.

The improvement of `ui.knob` is not yet completed. I not sure how to edit it.
I think we should not make any changes to `ui.knob`. The `label` property of the `ui.knob` is bound to its `value`, and I am worried that providing label-related methods will cause some bugs.

At the same time, I wrote a test.
```python
from nicegui import ui

# Test `set_label()`
with ui.card(), ui.row():
    ui.button("Set Label", on_click=lambda: set_label()).props("no-caps")
    a0 = ui.input(label="Test Label 3")
    b0 = ui.select(["A Test Option"], label="Test Label 2")
    c0 = ui.color_input("Test Label 3")
    d0 = ui.number("Test Label 4")
    e0 = ui.textarea("Test Label 5")
    f0 = ui.upload(label="Test Label 6")

    def set_label():
        i = 1
        for element in (a0, b0, c0, d0, e0, f0):
            element.set_label(f"New Label {i}")
            i += 1

# Test `bind_label()`
with ui.card(), ui.row():
    i1 = ui.input("Bind Test")
    test_bind = ui.input().bind_label(i1, "value")
    test_bind.set_visibility(False)

    a1 = ui.input(label="Test Label 3").bind_label(test_bind)
    b1 = ui.select(["A Test Option"], label="Test Label 2").bind_label(test_bind)
    c1 = ui.color_input("Test Label 3").bind_label_from(test_bind)
    d1 = ui.number("Test Label 4").bind_label(test_bind)
    e1 = ui.textarea("Test Label 5").bind_label(test_bind)
    f1 = ui.upload(label="Test Label 6").bind_label(test_bind)


ui.run()
```

Patch:
diff --git a/nicegui/air.py b/nicegui/air.py
index c6f40b5d..3c57b4d6 100644
--- a/nicegui/air.py
+++ b/nicegui/air.py
@@ -70,13 +70,28 @@ class Air:
                 new_js_object = match.group(1).decode().rstrip('}') + ", 'fly_instance_id' : '" + instance_id + "'}"
                 content = content.replace(match.group(0), f'const query = {new_js_object}'.encode())
             compressed = gzip.compress(content)
-            response.headers.update({'content-encoding': 'gzip', 'content-length': str(len(compressed))})
-            return {
+            total_size = len(compressed)
+            response.headers.update({'content-encoding': 'gzip', 'content-length': str(total_size)})
+            result = {
                 'status_code': response.status_code,
                 'headers': response.headers.multi_items(),
                 'content': compressed,
             }
 
+            # NOTE: chunk large responses to stay within the SocketIO limit
+            if total_size > 1_000_000:
+                async def chunk_iterator() -> AsyncIterator[bytes]:
+                    chunk_size = 512 * 1024
+                    for i in range(0, total_size, chunk_size):
+                        yield compressed[i:i + chunk_size]
+                stream_id = str(uuid4())
+                self.streams[stream_id] = Stream(data=chunk_iterator(), response=response)
+                result['stream_id'] = stream_id
+                result['total_size'] = total_size
+                del result['content']
+
+            return result
+
         @self.relay.on('range-request')
         async def _handle_range_request(data: Dict[str, Any]) -> Dict[str, Any]:
             headers: Dict[str, Any] = data['headers']
@@ -156,7 +171,7 @@ class Air:
 
         @self.relay.on('connect_error')
         async def _handle_connect_error(data) -> None:
-            self.log.debug(f'Connection error: {data}')
+            self.log.warning(f'Connection error: {data.get("message", "Unknown error")}')
 
         @self.relay.on('event')
         def _handle_event(data: Dict[str, Any]) -> None:
diff --git a/nicegui/elements/chat_message.py b/nicegui/elements/chat_message.py
index 4aad032e..f55ca19b 100644
--- a/nicegui/elements/chat_message.py
+++ b/nicegui/elements/chat_message.py
@@ -1,11 +1,11 @@
 import html
 from typing import List, Optional, Union
 
-from ..element import Element
 from .html import Html
+from .mixins.label_element import LabelElement
 
 
-class ChatMessage(Element):
+class ChatMessage(LabelElement):
 
     def __init__(self,
                  text: Union[str, List[str]] = ..., *,  # type: ignore
@@ -28,7 +28,7 @@ class ChatMessage(Element):
         :param sent: render as a sent message (so from current user) (default: False)
         :param text_html: render text as HTML (default: False)
         """
-        super().__init__('q-chat-message')
+        super().__init__(tag='q-chat-message', label=label)
 
         if text is ...:
             text = []
@@ -40,8 +40,6 @@ class ChatMessage(Element):
 
         if name is not None:
             self._props['name'] = name
-        if label is not None:
-            self._props['label'] = label
         if stamp is not None:
             self._props['stamp'] = stamp
         if avatar is not None:
diff --git a/nicegui/elements/color_input.py b/nicegui/elements/color_input.py
index 9c6a7158..6c5c5d83 100644
--- a/nicegui/elements/color_input.py
+++ b/nicegui/elements/color_input.py
@@ -4,10 +4,11 @@ from ..events import Handler, ValueChangeEventArguments
 from .button import Button as button
 from .color_picker import ColorPicker as color_picker
 from .mixins.disableable_element import DisableableElement
+from .mixins.label_element import LabelElement
 from .mixins.value_element import ValueElement
 
 
-class ColorInput(ValueElement, DisableableElement):
+class ColorInput(LabelElement, ValueElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -27,9 +28,7 @@ class ColorInput(ValueElement, DisableableElement):
         :param on_change: callback to execute when the value changes
         :param preview: change button background to selected color (default: False)
         """
-        super().__init__(tag='q-input', value=value, on_value_change=on_change)
-        if label is not None:
-            self._props['label'] = label
+        super().__init__(tag='q-input', label=label, value=value, on_value_change=on_change)
         if placeholder is not None:
             self._props['placeholder'] = placeholder
 
diff --git a/nicegui/elements/input.py b/nicegui/elements/input.py
index ac487d3d..c6b60a00 100644
--- a/nicegui/elements/input.py
+++ b/nicegui/elements/input.py
@@ -3,10 +3,11 @@ from typing import Any, List, Optional, Union
 from ..events import Handler, ValueChangeEventArguments
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
+from .mixins.label_element import LabelElement
 from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction
 
 
-class Input(ValidationElement, DisableableElement, component='input.js'):
+class Input(LabelElement, ValidationElement, DisableableElement, component='input.js'):
     VALUE_PROP: str = 'value'
     LOOPBACK = False
 
@@ -49,9 +50,7 @@ class Input(ValidationElement, DisableableElement, component='input.js'):
         :param autocomplete: optional list of strings for autocompletion
         :param validation: dictionary of validation rules or a callable that returns an optional error message (default: None for no validation)
         """
-        super().__init__(value=value, on_value_change=on_change, validation=validation)
-        if label is not None:
-            self._props['label'] = label
+        super().__init__(label=label, value=value, on_value_change=on_change, validation=validation)
         if placeholder is not None:
             self._props['placeholder'] = placeholder
         self._props['type'] = 'password' if password else 'text'
diff --git a/nicegui/elements/mixins/label_element.py b/nicegui/elements/mixins/label_element.py
new file mode 100644
index 00000000..3758b197
--- /dev/null
+++ b/nicegui/elements/mixins/label_element.py
@@ -0,0 +1,89 @@
+from typing import Any, Callable, Optional, cast
+
+from typing_extensions import Self
+
+from ...binding import BindableProperty, bind, bind_from, bind_to
+from ...element import Element
+
+
+class LabelElement(Element):
+    label = BindableProperty(
+        on_change=lambda sender, label: cast(Self, sender)._handle_label_change(label))  # pylint: disable=protected-access
+
+    def __init__(self, *, label: Optional[str], **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        self.label = label
+        if label is not None:
+            self._props['label'] = label
+
+    def bind_label_to(self,
+                      target_object: Any,
+                      target_name: str = 'label',
+                      forward: Callable[..., Any] = lambda x: x,
+                      ) -> Self:
+        """Bind the label 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, 'label', target_object, target_name, forward)
+        return self
+
+    def bind_label_from(self,
+                        target_object: Any,
+                        target_name: str = 'label',
+                        backward: Callable[..., Any] = lambda x: x,
+                        ) -> Self:
+        """Bind the label 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, 'label', target_object, target_name, backward)
+        return self
+
+    def bind_label(self,
+                   target_object: Any,
+                   target_name: str = 'label', *,
+                   forward: Callable[..., Any] = lambda x: x,
+                   backward: Callable[..., Any] = lambda x: x,
+                   ) -> Self:
+        """Bind the label 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, 'label', target_object, target_name, forward=forward, backward=backward)
+        return self
+
+    def set_label(self, label: Optional[str]) -> None:
+        """Set the label of this element.
+
+        :param label: The new label.
+        """
+        self.label = label
+
+    def _handle_label_change(self, label: Optional[str]) -> None:
+        """Called when the label of this element changes.
+
+        :param label: The new label.
+        """
+        if label is None:
+            del self._props['label']
+        else:
+            self._props['label'] = label
+        self.update()
diff --git a/nicegui/elements/number.py b/nicegui/elements/number.py
index 39f3bccc..553e7b1d 100644
--- a/nicegui/elements/number.py
+++ b/nicegui/elements/number.py
@@ -2,10 +2,11 @@ from typing import Any, Optional, Union
 
 from ..events import GenericEventArguments, Handler, ValueChangeEventArguments
 from .mixins.disableable_element import DisableableElement
+from .mixins.label_element import LabelElement
 from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction
 
 
-class Number(ValidationElement, DisableableElement):
+class Number(LabelElement, ValidationElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -46,10 +47,8 @@ class Number(ValidationElement, DisableableElement):
         :param validation: dictionary of validation rules or a callable that returns an optional error message (default: None for no validation)
         """
         self.format = format
-        super().__init__(tag='q-input', value=value, on_value_change=on_change, validation=validation)
+        super().__init__(tag='q-input', label=label, value=value, on_value_change=on_change, validation=validation)
         self._props['type'] = 'number'
-        if label is not None:
-            self._props['label'] = label
         if placeholder is not None:
             self._props['placeholder'] = placeholder
         if min is not None:
diff --git a/nicegui/elements/select.py b/nicegui/elements/select.py
index c008fe77..6322111c 100644
--- a/nicegui/elements/select.py
+++ b/nicegui/elements/select.py
@@ -5,10 +5,11 @@ from typing import Any, Callable, Dict, Iterator, List, Literal, Optional, Union
 from ..events import GenericEventArguments, Handler, ValueChangeEventArguments
 from .choice_element import ChoiceElement
 from .mixins.disableable_element import DisableableElement
+from .mixins.label_element import LabelElement
 from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction
 
 
-class Select(ValidationElement, ChoiceElement, DisableableElement, component='select.js'):
+class Select(LabelElement, ValidationElement, ChoiceElement, DisableableElement, component='select.js'):
 
     def __init__(self,
                  options: Union[List, Dict], *,
@@ -60,9 +61,7 @@ class Select(ValidationElement, ChoiceElement, DisableableElement, component='se
                 value = [value]
             else:
                 value = value[:]  # NOTE: avoid modifying the original list which could be the list of options (#3014)
-        super().__init__(options=options, value=value, on_change=on_change, validation=validation)
-        if label is not None:
-            self._props['label'] = label
+        super().__init__(label=label, options=options, value=value, on_change=on_change, validation=validation)
         if isinstance(key_generator, Generator):
             next(key_generator)  # prime the key generator, prepare it to receive the first value
         self.key_generator = key_generator
diff --git a/nicegui/elements/tabs.py b/nicegui/elements/tabs.py
index fce5b7b6..5d714f44 100644
--- a/nicegui/elements/tabs.py
+++ b/nicegui/elements/tabs.py
@@ -6,6 +6,7 @@ from ..context import context
 from ..events import Handler, ValueChangeEventArguments
 from .mixins.disableable_element import DisableableElement
 from .mixins.icon_element import IconElement
+from .mixins.label_element import LabelElement
 from .mixins.value_element import ValueElement
 
 
@@ -29,7 +30,7 @@ class Tabs(ValueElement):
         return value.props['name'] if isinstance(value, (Tab, TabPanel)) else value
 
 
-class Tab(IconElement, DisableableElement):
+class Tab(LabelElement, IconElement, DisableableElement):
 
     def __init__(self, name: str, label: Optional[str] = None, icon: Optional[str] = None) -> None:
         """Tab
@@ -41,9 +42,10 @@ class Tab(IconElement, DisableableElement):
         :param label: label of the tab (default: `None`, meaning the same as `name`)
         :param icon: icon of the tab (default: `None`)
         """
-        super().__init__(tag='q-tab', icon=icon)
+        if label is None:
+            label = name
+        super().__init__(tag='q-tab', label=label, icon=icon)
         self._props['name'] = name
-        self._props['label'] = label if label is not None else name
         self.tabs = context.slot.parent
 
 
diff --git a/nicegui/elements/upload.py b/nicegui/elements/upload.py
index d3bbcffd..eb38f25e 100644
--- a/nicegui/elements/upload.py
+++ b/nicegui/elements/upload.py
@@ -7,9 +7,10 @@ from typing_extensions import Self
 from ..events import Handler, MultiUploadEventArguments, UiEventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
 from .mixins.disableable_element import DisableableElement
+from .mixins.label_element import LabelElement
 
 
-class Upload(DisableableElement, component='upload.js'):
+class Upload(LabelElement, DisableableElement, component='upload.js'):
 
     def __init__(self, *,
                  multiple: bool = False,
@@ -36,9 +37,8 @@ class Upload(DisableableElement, component='upload.js'):
         :param label: label for the uploader (default: `''`)
         :param auto_upload: automatically upload files when they are selected (default: `False`)
         """
-        super().__init__()
+        super().__init__(label=label)
         self._props['multiple'] = multiple
-        self._props['label'] = label
         self._props['auto-upload'] = auto_upload
         self._props['url'] = f'/_nicegui/client/{self.client.id}/upload/{self.id}'
 
diff --git a/nicegui/page.py b/nicegui/page.py
index 3a69fbc1..b7b9760a 100644
--- a/nicegui/page.py
+++ b/nicegui/page.py
@@ -123,10 +123,13 @@ class page:
                     with client:
                         return await result
                 task = background_tasks.create(wait_for_result())
+                task_wait_for_connection = background_tasks.create(
+                    client._waiting_for_connection.wait(),  # pylint: disable=protected-access
+                )
                 try:
                     await asyncio.wait([
                         task,
-                        asyncio.create_task(client._waiting_for_connection.wait()),  # pylint: disable=protected-access
+                        task_wait_for_connection,
                     ], timeout=self.response_timeout, return_when=asyncio.FIRST_COMPLETED)
                 except asyncio.TimeoutError as e:
                     raise TimeoutError(f'Response not ready after {self.response_timeout} seconds') from e
diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py
index b869c687..7c23a0da 100644
--- a/nicegui/page_layout.py
+++ b/nicegui/page_layout.py
@@ -132,8 +132,9 @@ class Drawer(ValueElement, default_classes='nicegui-drawer'):
 
         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.value = await context.client.run_javascript(
+                    f'!getHtmlElement({self.id}).parentElement.classList.contains("q-layout--prevent-focus")  // __IS_DRAWER_OPEN__'
+                )
             self.client.on_connect(_request_value)
 
     def toggle(self) -> None:
diff --git a/website/documentation/content/user_documentation.py b/website/documentation/content/user_documentation.py
index 33b9bced..22948b47 100644
--- a/website/documentation/content/user_documentation.py
+++ b/website/documentation/content/user_documentation.py
@@ -248,11 +248,46 @@ def multiple_users():
         ''')
 
 
+doc.text('Simulate JavasScript', '''
+    The `User` class has a `javascript_rules` dictionary to simulate JavaScript execution.
+    The key is a compiled regular expression and the value is a function that returns the JavaScript response.
+    The function will be called with the match object of the regular expression on the JavaScript command.
+
+    *Added in version 2.14.0*
+''')
+
+
+@doc.ui
+def simulate_javascript():
+    with ui.row().classes('gap-4 items-stretch'):
+        with python_window(classes='w-[500px]', title='some UI code'):
+            ui.markdown('''
+                ```python
+                @ui.page('/')
+                async def page():
+                    await context.client.connected()
+                    date = await ui.run_javascript('Math.sqrt(1764)')
+                    ui.label(date)
+                ```
+            ''')
+
+        with python_window(classes='w-[500px]', title='user assertions'):
+            ui.markdown('''
+                ```python
+                user.javascript_rules[re.compile(r'Math.sqrt\\((\\d+)\\)')] = \\
+                    lambda match: int(match.group(1))**0.5
+                await user.open('/')
+                await user.should_see('42')
+                ```
+            ''')
+
+
 doc.text('Comparison with the screen fixture', '''
     By cutting out the browser, test execution becomes much faster than the [`screen` fixture](/documentation/screen).
-    Of course, some features like screenshots or browser-specific behavior are not available.
     See our [pytests example](https://github.com/zauberzeug/nicegui/tree/main/examples/pytests)
     which implements the same tests with both fixtures.
+    Of course, some features like screenshots or browser-specific behavior are not available,
+    but in most cases the speed of the `user` fixture makes it the first choice.
 ''')
 
 doc.reference(User, title='User Reference')


Test patch:
diff --git a/nicegui/testing/user.py b/nicegui/testing/user.py
index 5ae2a7f2..e046e042 100644
--- a/nicegui/testing/user.py
+++ b/nicegui/testing/user.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import asyncio
 import re
-from typing import Any, List, Optional, Set, Type, TypeVar, Union, overload
+from typing import Any, Callable, Dict, List, Optional, Set, Type, TypeVar, Union, overload
 from uuid import uuid4
 
 import httpx
@@ -11,6 +11,7 @@ import socketio
 from nicegui import Client, ElementFilter, ui
 from nicegui.element import Element
 from nicegui.nicegui import _on_handshake
+from nicegui.outbox import Message
 
 from .user_download import UserDownload
 from .user_interaction import UserInteraction
@@ -35,6 +36,9 @@ class User:
         self.navigate = UserNavigate(self)
         self.notify = UserNotify()
         self.download = UserDownload(self)
+        self.javascript_rules: Dict[re.Pattern, Callable[[re.Match], Any]] = {
+            re.compile('.*__IS_DRAWER_OPEN__'): lambda _: True,  # see https://github.com/zauberzeug/nicegui/issues/4508
+        }
 
     @property
     def _client(self) -> Client:
@@ -69,8 +73,26 @@ class User:
         self.back_history.append(path)
         if clear_forward_history:
             self.forward_history.clear()
+        self._patch_outbox_emit_function()
         return self.client
 
+    def _patch_outbox_emit_function(self) -> None:
+        original_emit = self._client.outbox._emit
+
+        async def simulated_emit(message: Message) -> None:
+            await original_emit(message)
+            _, type_, data = message
+            if type_ == 'run_javascript':
+                for rule, result in self.javascript_rules.items():
+                    match = rule.match(data['code'])
+                    if match:
+                        self._client.handle_javascript_response({
+                            'request_id': data['request_id'],
+                            'result': result(match),
+                        })
+
+        self._client.outbox._emit = simulated_emit  # type: ignore
+
     @overload
     async def should_see(self,
                          target: Union[str, Type[T]],
diff --git a/nicegui/testing/user_plugin.py b/nicegui/testing/user_plugin.py
index 0ca74207..d5d695e0 100644
--- a/nicegui/testing/user_plugin.py
+++ b/nicegui/testing/user_plugin.py
@@ -38,6 +38,7 @@ def prepare_simulated_auto_index_client(request):
 @pytest.fixture
 async def user(nicegui_reset_globals,  # noqa: F811, pylint: disable=unused-argument
                prepare_simulated_auto_index_client,  # pylint: disable=unused-argument
+               caplog: pytest.LogCaptureFixture,
                request: pytest.FixtureRequest,
                ) -> AsyncGenerator[User, None]:
     """Create a new user fixture."""
@@ -48,6 +49,9 @@ async def user(nicegui_reset_globals,  # noqa: F811, pylint: disable=unused-argu
     ui.navigate = Navigate()
     ui.notify = notify
     ui.download = download
+    logs = caplog.get_records('call')
+    if logs:
+        pytest.fail('There were unexpected logs.', pytrace=False)
 
 
 @pytest.fixture
diff --git a/tests/test_user_simulation.py b/tests/test_user_simulation.py
index c499335c..3f3bc308 100644
--- a/tests/test_user_simulation.py
+++ b/tests/test_user_simulation.py
@@ -1,4 +1,5 @@
 import csv
+import re
 from io import BytesIO
 from typing import Callable, Dict, Type, Union
 
@@ -540,3 +541,27 @@ async def test_typing_to_disabled_element(user: User) -> None:
     assert target.value == initial_value
     await user.should_see(initial_value)
     await user.should_not_see(given_new_input)
+
+
+async def test_drawer(user: User):
+    @ui.page('/')
+    def test_page():
+        with ui.left_drawer() as drawer:
+            ui.label('Hello')
+        ui.label().bind_text_from(drawer, 'value', lambda v: f'Drawer: {v}')
+
+    await user.open('/')
+    await user.should_see('Hello')
+    await user.should_see('Drawer: True')
+
+
+async def test_run_javascript(user: User):
+    @ui.page('/')
+    async def page():
+        await ui.context.client.connected()
+        date = await ui.run_javascript('Math.sqrt(1764)')
+        ui.label(date)
+
+    user.javascript_rules[re.compile(r'Math.sqrt\((\d+)\)')] = lambda match: int(match.group(1))**0.5
+    await user.open('/')
+    await user.should_see('42')