Repository: zauberzeug/nicegui
Pull request #4229 (2025-01-15 18:49:01)

A couple of small improvements to the custom vue component example
Hi there, this is my first PR for this project and I'm somewhat new to collaborative Open Source development, so please be patient.

### Description
This PR modifies the Custom Vue Component [example](https://github.com/zauberzeug/nicegui/tree/main/examples/custom_vue_component) to make it a bit more helpful for developers who need to create custom components but haven't used Vue before.

### Motivation
In the example linked above, the Vue component is defined in `counter.js`. This way of bundling a component in a vanilla JavaScript file is obviously supported by Vue, since every built-in NiceGUI component is defined this way, but the official Vue documentation (and basically every other resource on the web) assumes that developers are writing `.vue` files, which are supported by additional tooling. I think the example should structure its Vue code in a way that makes it easier for new developers to understand how it works in relation to existing Vue documentation.

### Changes
In the new modified example, the python class definition now uses a new file, `counter.vue`, which re-implements `counter.js` in the more customary Vue Single-File Component format.

The call to `ui.run()` has been modified to ensure browser reload on changes to the `.js` and `.vue` files, which is important for iterative development on the Vue side.

### Testing
These changes do not add any new functionality that needs to be covered by tests. I have run the modified example and ensured that it does not error.

### Additional Notes
I am curious if the developers would be interested in adding a second, more advanced custom vue component example. I am in the process of creating a pannable and zoomable image component, which I would be happy to wrap up in a nice example and provide in a separate PR.


Patch:
diff --git a/.dockerignore b/.dockerignore
index cd7d0cf4..e8f70510 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -11,13 +11,14 @@ demo.py
 **/.pytest_cache
 **/.coverage
 **/.git
+**/.github
 **/.mypy_cache
 **/.nicegui
 
-# flyctl launch added from .pytest_cache/.gitignore
-# Created by pytest automatically.
 .pytest_cache/**/*
-
-# flyctl launch added from tests/.pytest_cache/.gitignore
-# Created by pytest automatically.
 tests/.pytest_cache/**/*
+**/.ruff_cache
+**/*.pyc
+**/.DS_Store
+**/.idea
+**/.vscode
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 1be96e84..9ec63fa1 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -16,7 +16,7 @@ jobs:
         with:
           python-version: "3.x"
       - name: set up Poetry
-        uses: abatilo/actions-poetry@v3
+        uses: abatilo/actions-poetry@v4
       - name: get version
         run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
       - name: Set version
diff --git a/CITATION.cff b/CITATION.cff
index 2caf16b4..113b7d86 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -8,10 +8,10 @@ authors:
     given-names: Rodja
     orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: 2.9.1
-date-released: '2025-01-06'
+version: 2.10.1
+date-released: '2025-01-21'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.14605360
+doi: 10.5281/zenodo.14711872
 identifiers:
   - description: DOI for this application's record on Zenodo
     type: doi
diff --git a/examples/custom_vue_component/README.md b/examples/custom_vue_component/README.md
new file mode 100644
index 00000000..820a2615
--- /dev/null
+++ b/examples/custom_vue_component/README.md
@@ -0,0 +1,21 @@
+# Custom Vue Component
+
+This example demonstrates how to create and use custom Vue components in NiceGUI.
+One component is implemented using JavaScript, the other using Vue's Single-File Component (SFC) syntax.
+
+## Counter Component (counter.js)
+
+The `Counter` component is a simple counter that increments a value.
+On change, it emits an event with the new value.
+A reset method allows to reset the counter to 0.
+
+The JavaScript code in `counter.js` defines the front-end logic of the component using JavaScript.
+
+## OnOff Component (on_off.vue)
+
+The `OnOff` component is a simple toggle that switches between on and off.
+On change, it emits an event with the new value.
+A reset method is also provided to reset the toggle to off.
+
+The Single-File Component in `on_off.vue` defines the front-end logic of the component using Vue 2.
+In contrast to the JavaScript code in `counter.js`, it splits the template, script, and style into separate sections.
diff --git a/examples/custom_vue_component/counter.js b/examples/custom_vue_component/counter.js
index 16b40fd1..f005a992 100644
--- a/examples/custom_vue_component/counter.js
+++ b/examples/custom_vue_component/counter.js
@@ -1,9 +1,13 @@
 // NOTE: Make sure to reload the browser with cache disabled after making changes to this file.
 export default {
   template: `
-  <button @click="handle_click">
-    <strong>{{title}}: {{value}}</strong>
-  </button>`,
+    <button @click="handle_click" :style="{ background: value > 0 ? '#bf8' : '#eee', padding: '8px 16px', borderRadius: '4px' }">
+      <strong>{{title}}: {{value}}</strong>
+    </button>
+  `,
+  props: {
+    title: String,
+  },
   data() {
     return {
       value: 0,
@@ -18,7 +22,4 @@ export default {
       this.value = 0;
     },
   },
-  props: {
-    title: String,
-  },
 };
diff --git a/examples/custom_vue_component/main.py b/examples/custom_vue_component/main.py
index bd373eec..f6b10462 100755
--- a/examples/custom_vue_component/main.py
+++ b/examples/custom_vue_component/main.py
@@ -1,17 +1,16 @@
 #!/usr/bin/env python3
 from counter import Counter
+from on_off import OnOff
 
 from nicegui import ui
 
-ui.markdown('''
-#### Try the new click counter!
+with ui.row(align_items='center'):
+    counter = Counter('Count', on_change=lambda e: ui.notify(f'The value changed to {e.args}.'))
+    ui.button('Reset', on_click=counter.reset).props('outline')
 
-Click to increment its value.
-''')
-with ui.card():
-    counter = Counter('Clicks', on_change=lambda e: ui.notify(f'The value changed to {e.args}.'))
+with ui.row(align_items='center'):
+    on_off = OnOff('State', on_change=lambda e: ui.notify(f'The value changed to {e.args}.'))
+    ui.button('Reset', on_click=on_off.reset).props('outline')
 
 
-ui.button('Reset', on_click=counter.reset).props('small outline')
-
-ui.run()
+ui.run(uvicorn_reload_includes='*.py,*.js,*.vue')
diff --git a/examples/custom_vue_component/on_off.py b/examples/custom_vue_component/on_off.py
new file mode 100644
index 00000000..90328b8a
--- /dev/null
+++ b/examples/custom_vue_component/on_off.py
@@ -0,0 +1,14 @@
+from typing import Callable, Optional
+
+from nicegui.element import Element
+
+
+class OnOff(Element, component='on_off.vue'):
+
+    def __init__(self, title: str, *, on_change: Optional[Callable] = None) -> None:
+        super().__init__()
+        self._props['title'] = title
+        self.on('change', on_change)
+
+    def reset(self) -> None:
+        self.run_method('reset')
diff --git a/examples/custom_vue_component/on_off.vue b/examples/custom_vue_component/on_off.vue
new file mode 100644
index 00000000..f5d91b1d
--- /dev/null
+++ b/examples/custom_vue_component/on_off.vue
@@ -0,0 +1,41 @@
+<template>
+  <div>
+    <button @click="handle_click" :class="{ active: value }">
+      <strong>{{ title }}: {{ value ? "ON" : "OFF" }}</strong>
+    </button>
+  </div>
+</template>
+
+<script>
+export default {
+  props: {
+    title: String,
+  },
+  data() {
+    return {
+      value: false,
+    };
+  },
+  methods: {
+    handle_click() {
+      this.value = !this.value;
+      this.$emit("change", this.value);
+    },
+    reset() {
+      this.value = false;
+    },
+  },
+};
+</script>
+
+<style scoped>
+button {
+  background-color: #eee;
+  padding: 8px 16px;
+  border-radius: 4px;
+}
+
+button.active {
+  background-color: #fb8;
+}
+</style>
diff --git a/fly.toml b/fly.toml
index 667a91cd..a6a7e7d9 100644
--- a/fly.toml
+++ b/fly.toml
@@ -37,7 +37,7 @@ swap_size_mb = 2048
     handlers = ["tls", "http"]
   [services.concurrency]
     type = "requests"
-    hard_limit = 120
+    hard_limit = 80
     soft_limit = 50
 
   [[services.tcp_checks]]
diff --git a/nicegui/__init__.py b/nicegui/__init__.py
index a3b27dfe..6a1cff6a 100644
--- a/nicegui/__init__.py
+++ b/nicegui/__init__.py
@@ -1,4 +1,4 @@
-from . import elements, html, run, storage, ui
+from . import binding, elements, html, run, storage, ui
 from .api_router import APIRouter
 from .app.app import App
 from .client import Client
@@ -16,6 +16,7 @@ __all__ = [
     'Tailwind',
     '__version__',
     'app',
+    'binding',
     'context',
     'elements',
     'html',
diff --git a/nicegui/binding.py b/nicegui/binding.py
index 1854cc93..d047d958 100644
--- a/nicegui/binding.py
+++ b/nicegui/binding.py
@@ -1,18 +1,42 @@
+from __future__ import annotations
+
 import asyncio
+import dataclasses
 import time
 from collections import defaultdict
-from collections.abc import Mapping
-from typing import Any, Callable, DefaultDict, Dict, Iterable, List, Optional, Set, Tuple, Union
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    DefaultDict,
+    Dict,
+    Iterable,
+    List,
+    Mapping,
+    Optional,
+    Set,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+)
+
+from typing_extensions import dataclass_transform
 
 from . import core
 from .logging import log
 
+if TYPE_CHECKING:
+    from _typeshed import DataclassInstance, IdentityFunction
+
 MAX_PROPAGATION_TIME = 0.01
 
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], Any] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 
+T = TypeVar('T', bound=type)
+
 
 def _has_attribute(obj: Union[object, Mapping], name: str) -> Any:
     if isinstance(obj, Mapping):
@@ -187,3 +211,44 @@ def reset() -> None:
     bindings.clear()
     bindable_properties.clear()
     active_links.clear()
+
+
+@dataclass_transform()
+def bindable_dataclass(cls: Optional[T] = None, /, *,
+                       bindable_fields: Optional[Iterable[str]] = None,
+                       **kwargs: Any) -> Union[Type[DataclassInstance], IdentityFunction]:
+    """A decorator that transforms a class into a dataclass with bindable fields.
+
+    This decorator extends the functionality of ``dataclasses.dataclass`` by making specified fields bindable.
+    If ``bindable_fields`` is provided, only the listed fields are made bindable.
+    Otherwise, all fields are made bindable by default.
+
+    *Added in version 2.11.0*
+
+    :param cls: class to be transformed into a dataclass
+    :param bindable_fields: optional list of field names to make bindable (defaults to all fields)
+    :param kwargs: optional keyword arguments to be forwarded to ``dataclasses.dataclass``.
+    Usage of ``slots=True`` and ``frozen=True`` are not supported and will raise a ValueError.
+
+    :return: resulting dataclass type
+    """
+    if cls is None:
+        def wrap(cls_):
+            return bindable_dataclass(cls_, bindable_fields=bindable_fields, **kwargs)
+        return wrap
+
+    for unsupported_option in ('slots', 'frozen'):
+        if kwargs.get(unsupported_option):
+            raise ValueError(f'`{unsupported_option}=True` is not supported with bindable_dataclass')
+
+    dataclass: Type[DataclassInstance] = dataclasses.dataclass(**kwargs)(cls)
+    field_names = set(field.name for field in dataclasses.fields(dataclass))
+    if bindable_fields is None:
+        bindable_fields = field_names
+    for field_name in bindable_fields:
+        if field_name not in field_names:
+            raise ValueError(f'"{field_name}" is not a dataclass field')
+        bindable_property = BindableProperty()
+        bindable_property.__set_name__(dataclass, field_name)
+        setattr(dataclass, field_name, bindable_property)
+    return dataclass
diff --git a/nicegui/elements/fullscreen.js b/nicegui/elements/fullscreen.js
new file mode 100644
index 00000000..f6bb34ba
--- /dev/null
+++ b/nicegui/elements/fullscreen.js
@@ -0,0 +1,32 @@
+export default {
+  props: {
+    requireEscapeHold: Boolean,
+  },
+  mounted() {
+    document.addEventListener("fullscreenchange", this.handleFullscreenChange);
+    document.addEventListener("mozfullscreenchange", this.handleFullscreenChange);
+    document.addEventListener("webkitfullscreenchange", this.handleFullscreenChange);
+    document.addEventListener("msfullscreenchange", this.handleFullscreenChange);
+  },
+  unmounted() {
+    document.removeEventListener("fullscreenchange", this.handleFullscreenChange);
+    document.removeEventListener("mozfullscreenchange", this.handleFullscreenChange);
+    document.removeEventListener("webkitfullscreenchange", this.handleFullscreenChange);
+    document.removeEventListener("msfullscreenchange", this.handleFullscreenChange);
+  },
+  methods: {
+    handleFullscreenChange() {
+      this.$emit("update:model-value", Quasar.AppFullscreen.isActive);
+    },
+    enter() {
+      Quasar.AppFullscreen.request().then(() => {
+        if (this.requireEscapeHold && navigator.keyboard && typeof navigator.keyboard.lock === "function") {
+          navigator.keyboard.lock(["Escape"]);
+        }
+      });
+    },
+    exit() {
+      Quasar.AppFullscreen.exit();
+    },
+  },
+};
diff --git a/nicegui/elements/fullscreen.py b/nicegui/elements/fullscreen.py
new file mode 100644
index 00000000..5fb11f5e
--- /dev/null
+++ b/nicegui/elements/fullscreen.py
@@ -0,0 +1,59 @@
+from typing import Optional
+
+from ..events import Handler, ValueChangeEventArguments
+from .mixins.value_element import ValueElement
+
+
+class Fullscreen(ValueElement, component='fullscreen.js'):
+    LOOPBACK = None
+
+    def __init__(self, *,
+                 require_escape_hold: bool = False,
+                 on_value_change: Optional[Handler[ValueChangeEventArguments]] = None) -> None:
+        """Fullscreen control element
+
+        This element is based on Quasar's `AppFullscreen <https://quasar.dev/quasar-plugins/app-fullscreen>`_ plugin
+        and provides a way to enter, exit and toggle the fullscreen mode.
+
+        Important notes:
+
+        * Due to security reasons, the fullscreen mode can only be entered from a previous user interaction such as a button click.
+        * The long-press escape requirement only works in some browsers like Google Chrome or Microsoft Edge.
+
+        *Added in version 2.11.0*
+
+        :param require_escape_hold: whether the user needs to long-press the escape key to exit fullscreen mode
+        :param on_value_change: callback which is invoked when the fullscreen state changes
+        """
+        super().__init__(value=False, on_value_change=on_value_change)
+        self._props['requireEscapeHold'] = require_escape_hold
+
+    @property
+    def require_escape_hold(self) -> bool:
+        """Whether the user needs to long-press of the escape key to exit fullscreen mode.
+
+        This feature is only supported in some browsers like Google Chrome or Microsoft Edge.
+        In unsupported browsers, this setting has no effect.
+        """
+        return self._props['requireEscapeHold']
+
+    @require_escape_hold.setter
+    def require_escape_hold(self, value: bool) -> None:
+        self._props['requireEscapeHold'] = value
+        self.update()
+
+    def enter(self) -> None:
+        """Enter fullscreen mode."""
+        self.value = True
+
+    def exit(self) -> None:
+        """Exit fullscreen mode."""
+        self.value = False
+
+    def toggle(self) -> None:
+        """Toggle fullscreen mode."""
+        self.value = not self.value
+
+    def _handle_value_change(self, value: bool) -> None:
+        super()._handle_value_change(value)
+        self.run_method('enter' if value else 'exit')
diff --git a/nicegui/elements/leaflet.js b/nicegui/elements/leaflet.js
index 438af927..1d554143 100644
--- a/nicegui/elements/leaflet.js
+++ b/nicegui/elements/leaflet.js
@@ -123,9 +123,6 @@ export default {
       clearInterval(connectInterval);
     }, 100);
   },
-  updated() {
-    this.map?.setView(this.center, this.zoom);
-  },
   methods: {
     add_layer(layer, id) {
       const l = L[layer.type](...layer.args);
diff --git a/nicegui/elements/leaflet.py b/nicegui/elements/leaflet.py
index 458e6d29..c779d204 100644
--- a/nicegui/elements/leaflet.py
+++ b/nicegui/elements/leaflet.py
@@ -110,7 +110,7 @@ class Leaflet(Element, component='leaflet.js', default_classes='nicegui-leaflet'
             return
         self._props['center'] = center
         if self._send_update_on_value_change:
-            self.update()
+            self.run_map_method('setView', center, self.zoom)
 
     def set_zoom(self, zoom: int) -> None:
         """Set the zoom level of the map."""
@@ -118,7 +118,7 @@ class Leaflet(Element, component='leaflet.js', default_classes='nicegui-leaflet'
             return
         self._props['zoom'] = zoom
         if self._send_update_on_value_change:
-            self.update()
+            self.run_map_method('setView', self.center, zoom)
 
     def remove_layer(self, layer: Layer) -> None:
         """Remove a layer from the map."""
diff --git a/nicegui/elements/line_plot.py b/nicegui/elements/line_plot.py
index d90ef777..acfdf29b 100644
--- a/nicegui/elements/line_plot.py
+++ b/nicegui/elements/line_plot.py
@@ -53,8 +53,8 @@ class LinePlot(Pyplot):
 
         :param x: list of x values
         :param Y: list of lists of y values (one list per line)
-        :param x_limits: new x limits (tuple of floats, or "auto" to fit the data points, or ``None`` to leave unchanged)
-        :param y_limits: new y limits (tuple of floats, or "auto" to fit the data points, or ``None`` to leave unchanged)
+        :param x_limits: new x limits (tuple of floats, or "auto" to fit the data points, or ``None`` to leave unchanged, *added in version 2.10.0*)
+        :param y_limits: new y limits (tuple of floats, or "auto" to fit the data points, or ``None`` to leave unchanged, *added in version 2.10.0*)
         """
         self.push_counter += 1
 
diff --git a/nicegui/functions/clipboard.py b/nicegui/functions/clipboard.py
index 43fa3beb..ae104ab0 100644
--- a/nicegui/functions/clipboard.py
+++ b/nicegui/functions/clipboard.py
@@ -51,6 +51,8 @@ async def read_image() -> Union['PIL_Image.Image', None]:
     """Read PIL images from the clipboard.
 
     Note: This function only works in secure contexts (HTTPS or localhost) and requires Pillow to be installed.
+
+    *Added in version 2.10.0*
     """
     if not optional_features.has('pillow'):
         log.warning('Pillow is not installed, so we cannot read images from the clipboard.')
diff --git a/nicegui/native/__init__.py b/nicegui/native/__init__.py
index cabde5d0..2fb64809 100644
--- a/nicegui/native/__init__.py
+++ b/nicegui/native/__init__.py
@@ -1,4 +1,4 @@
-from .native import WindowProxy, create_queues, method_queue, remove_queues, response_queue
+from .native import WindowProxy, method_queue, response_queue
 from .native_config import NativeConfig
 from .native_mode import activate, find_open_port
 
@@ -6,9 +6,7 @@ __all__ = [
     'NativeConfig',
     'WindowProxy',
     'activate',
-    'create_queues',
     'find_open_port',
     'method_queue',
-    'remove_queues',
     'response_queue',
 ]
diff --git a/nicegui/native/native.py b/nicegui/native/native.py
index db777506..dcce1045 100644
--- a/nicegui/native/native.py
+++ b/nicegui/native/native.py
@@ -2,34 +2,13 @@
 import inspect
 import warnings
 from multiprocessing import Queue
-from typing import Any, Callable, Optional, Tuple
+from typing import Any, Callable, Tuple
 
 from .. import run
 from ..logging import log
 
-method_queue: Optional[Queue] = None
-response_queue: Optional[Queue] = None
-
-
-def create_queues() -> None:
-    """Create the message queues."""
-    global method_queue, response_queue  # pylint: disable=global-statement # noqa: PLW0603
-    method_queue = Queue()
-    response_queue = Queue()
-
-
-def remove_queues() -> None:
-    """Remove the message queues by closing them and waiting for threads to finish."""
-    global method_queue, response_queue  # pylint: disable=global-statement # noqa: PLW0603
-    if method_queue is not None:
-        method_queue.close()
-        method_queue.join_thread()
-        method_queue = None
-    if response_queue is not None:
-        response_queue.close()
-        response_queue.join_thread()
-        response_queue = None
-
+method_queue: Queue = Queue()
+response_queue: Queue = Queue()
 
 try:
     with warnings.catch_warnings():
@@ -62,7 +41,8 @@ try:
         def load_url(self, url: str) -> None:
             self._send(url)
 
-        def load_html(self, content: str, base_uri: str = ...) -> None:  # type: ignore
+        def load_html(self, content: str, base_uri: str = ...) -> None:  # type: ignore # pylint: disable=arguments-renamed
+            # DEPRECATED: `content` will be renamed to `html` in NiceGUI 3.0 to match the parameter name in pywebview>=5.4
             self._send(content, base_uri)
 
         def load_css(self, stylesheet: str) -> None:
@@ -141,14 +121,11 @@ try:
 
         def _send(self, *args: Any, **kwargs: Any) -> None:
             name = inspect.currentframe().f_back.f_code.co_name  # type: ignore
-            assert method_queue is not None
             method_queue.put((name, args, kwargs))
 
         async def _request(self, *args: Any, **kwargs: Any) -> Any:
             def wrapper(*args: Any, **kwargs: Any) -> Any:
                 try:
-                    assert method_queue is not None
-                    assert response_queue is not None
                     method_queue.put((name, args, kwargs))
                     return response_queue.get()  # wait for the method to be called and writing its result to the queue
                 except Exception:
diff --git a/nicegui/native/native_mode.py b/nicegui/native/native_mode.py
index d0de71b9..fde7fc36 100644
--- a/nicegui/native/native_mode.py
+++ b/nicegui/native/native_mode.py
@@ -104,7 +104,6 @@ def activate(host: str, port: int, title: str, width: int, height: int, fullscre
         while not core.app.is_stopped:
             time.sleep(0.1)
         _thread.interrupt_main()
-        native.remove_queues()
 
     if not optional_features.has('webview'):
         log.error('Native mode is not supported in this configuration.\n'
@@ -112,7 +111,6 @@ def activate(host: str, port: int, title: str, width: int, height: int, fullscre
         sys.exit(1)
 
     mp.freeze_support()
-    native.create_queues()
     args = host, port, title, width, height, fullscreen, frameless, native.method_queue, native.response_queue
     process = mp.Process(target=_open_window, args=args, daemon=True)
     process.start()
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index 7c86d490..76283807 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -376,14 +376,17 @@ function createApp(elements, options) {
           document.getElementById("popup").ariaHidden = false;
         },
         update: async (msg) => {
+          const loadPromises = Object.entries(msg)
+            .filter(([_, element]) => element && (element.component || element.libraries))
+            .map(([_, element]) => loadDependencies(element, options.prefix, options.version));
+
+          await Promise.all(loadPromises);
+
           for (const [id, element] of Object.entries(msg)) {
             if (element === null) {
               delete this.elements[id];
               continue;
             }
-            if (element.component || element.libraries) {
-              await loadDependencies(element, options.prefix, options.version);
-            }
             this.elements[id] = element;
             replaceUndefinedAttributes(this.elements, id);
           }
diff --git a/nicegui/ui.py b/nicegui/ui.py
index 5279ab87..452db9d5 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -39,6 +39,7 @@ __all__ = [
     'element',
     'expansion',
     'footer',
+    'fullscreen',
     'grid',
     'header',
     'highchart',
@@ -154,6 +155,7 @@ from .elements.dialog import Dialog as dialog
 from .elements.echart import EChart as echart
 from .elements.editor import Editor as editor
 from .elements.expansion import Expansion as expansion
+from .elements.fullscreen import Fullscreen as fullscreen
 from .elements.grid import Grid as grid
 from .elements.highchart import highchart
 from .elements.html import Html as html
diff --git a/poetry.lock b/poetry.lock
index f546d5e4..95875eec 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1682,6 +1682,32 @@ files = [
     {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
 ]
 
+[[package]]
+name = "narwhals"
+version = "1.24.1"
+description = "Extremely lightweight compatibility layer between dataframe libraries"
+optional = true
+python-versions = ">=3.8"
+files = [
+    {file = "narwhals-1.24.1-py3-none-any.whl", hash = "sha256:d8983fe14851c95d60576ddca37c094bd4ed24ab9ea98396844fb20ad9aaf184"},
+    {file = "narwhals-1.24.1.tar.gz", hash = "sha256:b09b8253d945f23cdb683a84685abf3afb9f96114d89e9f35dc876e143f65007"},
+]
+
+[package.extras]
+core = ["duckdb", "pandas", "polars", "pyarrow", "pyarrow-stubs"]
+cudf = ["cudf (>=24.10.0)"]
+dask = ["dask[dataframe] (>=2024.8)"]
+dev = ["covdefaults", "hypothesis", "pre-commit", "pytest", "pytest-cov", "pytest-env", "pytest-randomly", "typing-extensions"]
+docs = ["black", "duckdb", "jinja2", "markdown-exec[ansi]", "mkdocs", "mkdocs-autorefs", "mkdocs-material", "mkdocstrings[python]", "pandas", "polars (>=1.0.0)", "pyarrow"]
+duckdb = ["duckdb (>=1.0)"]
+extra = ["scikit-learn"]
+ibis = ["ibis-framework (>=6.0.0)", "packaging", "pyarrow-hotfix", "rich"]
+modin = ["modin"]
+pandas = ["pandas (>=0.25.3)"]
+polars = ["polars (>=0.20.3)"]
+pyarrow = ["pyarrow (>=11.0.0)"]
+pyspark = ["pyspark (>=3.5.0)"]
+
 [[package]]
 name = "nicegui-highcharts"
 version = "2.0.2"
@@ -2163,18 +2189,21 @@ type = ["mypy (>=1.11.2)"]
 
 [[package]]
 name = "plotly"
-version = "5.24.1"
+version = "6.0.0"
 description = "An open-source, interactive data visualization library for Python"
 optional = true
 python-versions = ">=3.8"
 files = [
-    {file = "plotly-5.24.1-py3-none-any.whl", hash = "sha256:f67073a1e637eb0dc3e46324d9d51e2fe76e9727c892dde64ddf1e1b51f29089"},
-    {file = "plotly-5.24.1.tar.gz", hash = "sha256:dbc8ac8339d248a4bcc36e08a5659bacfe1b079390b8953533f4eb22169b4bae"},
+    {file = "plotly-6.0.0-py3-none-any.whl", hash = "sha256:f708871c3a9349a68791ff943a5781b1ec04de7769ea69068adcd9202e57653a"},
+    {file = "plotly-6.0.0.tar.gz", hash = "sha256:c4aad38b8c3d65e4a5e7dd308b084143b9025c2cc9d5317fc1f1d30958db87d3"},
 ]
 
 [package.dependencies]
+narwhals = ">=1.15.1"
 packaging = "*"
-tenacity = ">=6.2.0"
+
+[package.extras]
+express = ["numpy"]
 
 [[package]]
 name = "pluggy"
@@ -3097,18 +3126,18 @@ files = [
 
 [[package]]
 name = "pywebview"
-version = "5.3.2"
+version = "5.4"
 description = "Build GUI for your Python program with JavaScript, HTML, and CSS"
 optional = true
 python-versions = ">=3.7"
 files = [
-    {file = "pywebview-5.3.2-py3-none-any.whl", hash = "sha256:55f5a4bcfaa56738f2d6206977bd3d2864784249b0646c67e84f402ba1740000"},
-    {file = "pywebview-5.3.2.tar.gz", hash = "sha256:77b88a63e65e12913d269205e9c6d357666d4864826749c738bf432bd3ad23d9"},
+    {file = "pywebview-5.4-py3-none-any.whl", hash = "sha256:0559c47db543556498dd38604a2a0479896c320f86c9b23499b8e580b58b699d"},
+    {file = "pywebview-5.4.tar.gz", hash = "sha256:b5e2c6c7502aaf72a9ae6034daf83785f5fad874fac7fa82bf4fcf854f1f083a"},
 ]
 
 [package.dependencies]
 bottle = "*"
-proxy-tools = "*"
+proxy_tools = "*"
 pyobjc-core = {version = "*", markers = "sys_platform == \"darwin\""}
 pyobjc-framework-Cocoa = {version = "*", markers = "sys_platform == \"darwin\""}
 pyobjc-framework-Quartz = {version = "*", markers = "sys_platform == \"darwin\""}
@@ -3116,7 +3145,7 @@ pyobjc-framework-security = {version = "*", markers = "sys_platform == \"darwin\
 pyobjc-framework-WebKit = {version = "*", markers = "sys_platform == \"darwin\""}
 pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
 QtPy = {version = "*", markers = "sys_platform == \"openbsd6\""}
-typing-extensions = "*"
+typing_extensions = "*"
 
 [package.extras]
 android = ["jnius", "kivy"]
@@ -3124,7 +3153,10 @@ cef = ["cefpython3"]
 gtk = ["PyGObject", "PyGObject-stubs"]
 pyside2 = ["PySide2", "QtPy"]
 pyside6 = ["PySide6", "QtPy"]
-qt = ["PyQt5", "QtPy", "pyqtwebengine"]
+qt = ["PyQt6", "PyQt6-WebEngine", "QtPy"]
+qt5 = ["PyQt5", "QtPy", "pyqtwebengine"]
+qt6 = ["PyQt6", "PyQt6-WebEngine", "QtPy"]
+ssl = ["crpytography"]
 
 [[package]]
 name = "pyyaml"
@@ -4181,4 +4213,4 @@ sass = ["libsass"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "070f7b280683a2fec6486d3bc4908f3e647efdc970e11f703eb5311944499d72"
+content-hash = "a9abccd4853ef4ef40bd935b9a212e18d2230236b84b33d1d1c7047121cf4e4a"
diff --git a/pyproject.toml b/pyproject.toml
index ffb9d7cb..2b175694 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "2.9.1-dev"
+version = "2.10.1-dev"
 description = "Create web-based user interfaces with Python. The nice way."
 authors = ["Zauberzeug GmbH <info@zauberzeug.com>"]
 license = "MIT"
@@ -24,7 +24,7 @@ orjson = {version = ">=3.9.15", markers = "platform_machine != 'i386' and platfo
 itsdangerous = "^2.1.2"
 aiofiles = ">=23.1.0"
 pywebview = { version = "^5.0.1", optional = true }
-plotly = { version = "^5.13.0", optional = true }
+plotly = { version = ">=5.13,<7.0", optional = true }
 matplotlib = { version = "^3.5.0", optional = true }
 httpx = ">=0.24.0"
 nicegui-highcharts = { version = "^2.0.2", optional = true }
diff --git a/set_scale.sh b/set_scale.sh
index d29b20cd..77e663e0 100755
--- a/set_scale.sh
+++ b/set_scale.sh
@@ -2,17 +2,17 @@
 
 fly scale count app=1  --region yul -y # Montreal, Quebec (Canada)
 
-fly scale count app=1  --region iad -y # Washington DC, Virginia (US)
+fly scale count app=2  --region iad -y # Washington DC, Virginia (US)
 fly scale count app=1  --region sjc -y # San Jose, California (US)
 fly scale count app=1  --region lax -y # Los Angeles, California (US)
-fly scale count app=1  --region mia -y # Miami, Florida (US)
+fly scale count app=2  --region mia -y # Miami, Florida (US)
 fly scale count app=1  --region sea -y # Seattle, Washington (US)
 
-fly scale count app=2  --region fra -y # Frankfurt, Germany
+fly scale count app=3  --region fra -y # Frankfurt, Germany
 fly scale count app=2  --region ams -y # Amsterdam, Netherlands
 fly scale count app=1  --region mad -y # Madrid, Spain
 fly scale count app=1  --region cdg -y # Paris, France
-fly scale count app=1  --region lhr -y # London, England (UK)
+fly scale count app=2  --region lhr -y # London, England (UK)
 fly scale count app=1  --region otp -y # Bucharest, Romania
 
 fly scale count app=1  --region jnb -y # Johannesburg, South Africa
diff --git a/website/documentation/content/fullscreen_documentation.py b/website/documentation/content/fullscreen_documentation.py
new file mode 100644
index 00000000..0a7abc4a
--- /dev/null
+++ b/website/documentation/content/fullscreen_documentation.py
@@ -0,0 +1,42 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.fullscreen)
+def main_demo() -> None:
+    fullscreen = ui.fullscreen()
+
+    ui.button('Enter Fullscreen', on_click=fullscreen.enter)
+    ui.button('Exit Fullscreen', on_click=fullscreen.exit)
+    ui.button('Toggle Fullscreen', on_click=fullscreen.toggle)
+
+
+@doc.demo('Requiring long-press to exit', '''
+    You can require users to long-press the escape key to exit fullscreen mode.
+    This is useful to prevent accidental exits, for example when working on forms or editing data.
+
+    Note that this feature only works in some browsers like Google Chrome or Microsoft Edge.
+''')
+def long_press_demo():
+    fullscreen = ui.fullscreen()
+    ui.switch('Require escape hold').bind_value_to(fullscreen, 'require_escape_hold')
+    ui.button('Toggle Fullscreen', on_click=fullscreen.toggle)
+
+
+@doc.demo('Tracking fullscreen state', '''
+    You can track when the fullscreen state changes.
+
+    Note that due to security reasons, fullscreen mode can only be entered from a previous user interaction
+    such as a button click.
+''')
+def state_demo():
+    fullscreen = ui.fullscreen(
+        on_value_change=lambda e: ui.notify('Enter' if e.value else 'Exit')
+    )
+    ui.button('Toggle Fullscreen', on_click=fullscreen.toggle)
+    ui.label().bind_text_from(fullscreen, 'state',
+                              lambda state: 'Fullscreen' if state else '')
+
+
+doc.reference(ui.fullscreen)
diff --git a/website/documentation/content/html_documentation.py b/website/documentation/content/html_documentation.py
index 83471ccb..1542b99c 100644
--- a/website/documentation/content/html_documentation.py
+++ b/website/documentation/content/html_documentation.py
@@ -15,7 +15,7 @@ def demo_inline() -> None:
     ui.html('This is <u>emphasized</u>.', tag='em')
 
 
-@doc.demo('Other HTML Elements', '''
+@doc.demo(other_html_elements_title := 'Other HTML Elements', other_html_elements_description := '''
     There is also an `html` module that allows you to insert other HTML elements like `<span>`, `<div>`, `<p>`, etc.
     It is equivalent to using the `ui.element` method with the `tag` argument.
 
diff --git a/website/documentation/content/overview.py b/website/documentation/content/overview.py
index 1ca72a6a..c8d48774 100644
--- a/website/documentation/content/overview.py
+++ b/website/documentation/content/overview.py
@@ -291,6 +291,7 @@ def map_of_nicegui():
             - `ui.context`: get the current UI context including the `client` and `request` objects
             - [`ui.dark_mode`](/documentation/dark_mode): get and set the dark mode on a page
             - [`ui.download`](/documentation/download): download a file to the client
+            - [`ui.fullscreen`](/documentation/fullscreen): enter, exit and toggle fullscreen mode
             - [`ui.keyboard`](/documentation/keyboard): define keyboard event handlers
             - [`ui.navigate`](/documentation/navigate): let the browser navigate to another location
             - [`ui.notify`](/documentation/notification): show a notification
@@ -451,6 +452,14 @@ def map_of_nicegui():
         - [`run.cpu_bound()`](/documentation/section_action_events#running_cpu-bound_tasks): run a CPU-bound function in a separate process
         - [`run.io_bound()`](/documentation/section_action_events#running_i_o-bound_tasks): run an IO-bound function in a separate thread
 
+        #### `binding`
+
+        [Bind properties of objects to each other](/documentation/section_binding_properties).
+
+        - [`binding.BindableProperty`](/documentation/section_binding_properties#bindable_properties_for_maximum_performance): bindable properties for maximum performance
+        - [`binding.bindable_dataclass()`](/documentation/section_binding_properties#bindable_dataclass): create a dataclass with bindable properties
+        - `binding.bind()`, `binding.bind_from()`, `binding.bind_to()`: methods to bind two properties
+
         #### `observables`
 
         Observable collections that notify observers when their contents change.
diff --git a/website/documentation/content/section_binding_properties.py b/website/documentation/content/section_binding_properties.py
index 63908fb2..3887fc5b 100644
--- a/website/documentation/content/section_binding_properties.py
+++ b/website/documentation/content/section_binding_properties.py
@@ -111,3 +111,25 @@ def bindable_properties():
     ui.slider(min=1, max=3).bind_value(demo, 'number')
     ui.toggle({1: 'A', 2: 'B', 3: 'C'}).bind_value(demo, 'number')
     ui.number(min=1, max=3).bind_value(demo, 'number')
+
+
+@doc.demo('Bindable dataclass', '''
+    The `bindable_dataclass` decorator provides a convenient way to create classes with bindable properties.
+    It extends the functionality of Python's standard `dataclasses.dataclass` decorator
+    by automatically making all dataclass fields bindable.
+    This eliminates the need to manually declare each field as a `BindableProperty`
+    while retaining all the benefits of regular dataclasses.
+
+    *Added in version 2.11.0*
+''')
+def bindable_dataclass():
+    from nicegui import binding
+
+    @binding.bindable_dataclass
+    class Demo:
+        number: int = 1
+
+    demo = Demo()
+    ui.slider(min=1, max=3).bind_value(demo, 'number')
+    ui.toggle({1: 'A', 2: 'B', 3: 'C'}).bind_value(demo, 'number')
+    ui.number(min=1, max=3).bind_value(demo, 'number')
diff --git a/website/documentation/content/section_page_layout.py b/website/documentation/content/section_page_layout.py
index da2d01f3..aa130dca 100644
--- a/website/documentation/content/section_page_layout.py
+++ b/website/documentation/content/section_page_layout.py
@@ -8,6 +8,7 @@ from . import (
     dialog_documentation,
     doc,
     expansion_documentation,
+    fullscreen_documentation,
     grid_documentation,
     list_documentation,
     menu_documentation,
@@ -55,6 +56,7 @@ doc.intro(column_documentation)
 doc.intro(row_documentation)
 doc.intro(grid_documentation)
 doc.intro(list_documentation)
+doc.intro(fullscreen_documentation)
 
 
 @doc.demo('Clear Containers', '''
diff --git a/website/documentation/content/section_text_elements.py b/website/documentation/content/section_text_elements.py
index a7820ae7..515dd0bf 100644
--- a/website/documentation/content/section_text_elements.py
+++ b/website/documentation/content/section_text_elements.py
@@ -9,6 +9,7 @@ from . import (
     mermaid_documentation,
     restructured_text_documentation,
 )
+from .html_documentation import other_html_elements_description, other_html_elements_title, other_html_elements
 
 doc.title('*Text* Elements')
 
@@ -20,26 +21,4 @@ doc.intro(markdown_documentation)
 doc.intro(restructured_text_documentation)
 doc.intro(mermaid_documentation)
 doc.intro(html_documentation)
-
-
-@doc.demo('Other HTML Elements', '''
-    There is an `html` module that allows you to insert other HTML elements like `<span>`, `<div>`, `<p>`, etc.
-    It is equivalent to using the `ui.element` method with the `tag` argument.
-
-    Like with any other element, you can add classes, style, props, tooltips and events.
-    One convenience is that the keyword arguments are automatically added to the element's `props` dictionary.
-
-    *Added in version 2.5.0*
-''')
-def other_html_elements():
-    from nicegui import html, ui
-
-    with html.section().style('font-size: 120%'):
-        html.strong('This is bold.') \
-            .classes('cursor-pointer') \
-            .on('click', lambda: ui.notify('Bold!'))
-        html.hr()
-        html.em('This is italic.').tooltip('Nice!')
-        with ui.row():
-            html.img().props('src=https://placehold.co/60')
-            html.img(src='https://placehold.co/60')
+doc.demo(other_html_elements_title, other_html_elements_description)(other_html_elements)
diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py
index 1eeffd35..6f1749f7 100644
--- a/website/documentation/content/storage_documentation.py
+++ b/website/documentation/content/storage_documentation.py
@@ -144,10 +144,9 @@ def max_tab_storage_age():
     # app.storage.max_tab_storage_age = timedelta(minutes=1).total_seconds()
     ui.label(f'Tab storage age: {timedelta(minutes=1).total_seconds()} seconds')  # HIDE
 
-    @ui.page('/')
-    def index():
-        # ui.label(f'Tab storage age: {app.storage.max_tab_storage_age} seconds')
-        pass  # HIDE
+    # @ui.page('/')
+    # def index():
+    #    ui.label(f'Tab storage age: {app.storage.max_tab_storage_age} seconds')
 
 
 @doc.demo('Short-term memory', '''
diff --git a/website/sponsors.json b/website/sponsors.json
index 639d16b2..6900056d 100644
--- a/website/sponsors.json
+++ b/website/sponsors.json
@@ -1,5 +1,5 @@
 {
   "top": [],
   "total": 22,
-  "contributors": 135
+  "contributors": 138
 }


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fc917e10..8645cf3c 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -12,7 +12,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: set up Poetry
-        uses: abatilo/actions-poetry@v3
+        uses: abatilo/actions-poetry@v4
       - name: set up Python
         uses: actions/setup-python@v5
         with:
@@ -35,7 +35,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: set up Poetry
-        uses: abatilo/actions-poetry@v3
+        uses: abatilo/actions-poetry@v4
       - name: set up Python
         uses: actions/setup-python@v5
         with:
diff --git a/tests/test_binding.py b/tests/test_binding.py
index 0aa015db..2c0ad2e3 100644
--- a/tests/test_binding.py
+++ b/tests/test_binding.py
@@ -2,7 +2,7 @@ from typing import Dict, Optional, Tuple
 
 from selenium.webdriver.common.keys import Keys
 
-from nicegui import ui
+from nicegui import binding, ui
 from nicegui.testing import Screen
 
 
@@ -105,3 +105,23 @@ def test_missing_target_attribute(screen: Screen):
 
     screen.open('/')
     screen.should_contain("text='Hello'")
+
+
+def test_bindable_dataclass(screen: Screen):
+    @binding.bindable_dataclass(bindable_fields=['bindable'])
+    class TestClass:
+        not_bindable: str = 'not_bindable_text'
+        bindable: str = 'bindable_text'
+
+    instance = TestClass()
+
+    ui.label().bind_text_from(instance, 'not_bindable')
+    ui.label().bind_text_from(instance, 'bindable')
+
+    screen.open('/')
+    screen.should_contain('not_bindable_text')
+    screen.should_contain('bindable_text')
+
+    assert len(binding.bindings) == 2
+    assert len(binding.active_links) == 1
+    assert binding.active_links[0][1] == 'not_bindable'
diff --git a/tests/test_fullscreen.py b/tests/test_fullscreen.py
new file mode 100644
index 00000000..290137b4
--- /dev/null
+++ b/tests/test_fullscreen.py
@@ -0,0 +1,68 @@
+from unittest.mock import patch
+
+import pytest
+
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+@pytest.mark.parametrize('require_escape_hold', [True, False])
+def test_fullscreen_creation(screen: Screen, require_escape_hold: bool):
+    fullscreen = ui.fullscreen(require_escape_hold=require_escape_hold)
+    assert not fullscreen.value
+    assert fullscreen.require_escape_hold == require_escape_hold
+
+    screen.open('/')
+
+
+def test_fullscreen_methods(screen: Screen):
+    values = []
+
+    fullscreen = ui.fullscreen(on_value_change=lambda e: values.append(e.value))
+
+    screen.open('/')
+
+    with patch.object(fullscreen, 'run_method') as mock_run:
+        fullscreen.enter()
+        mock_run.assert_called_once_with('enter')
+        mock_run.reset_mock()
+
+        fullscreen.exit()
+        mock_run.assert_called_once_with('exit')
+        mock_run.reset_mock()
+
+        fullscreen.toggle()
+        mock_run.assert_called_once_with('enter')
+        mock_run.reset_mock()
+
+        fullscreen.value = False
+        mock_run.assert_called_once_with('exit')
+        mock_run.reset_mock()
+
+        fullscreen.value = True
+        mock_run.assert_called_once_with('enter')
+        mock_run.reset_mock()
+
+    assert values == [True, False, True, False, True]
+
+
+def test_fullscreen_button_click(screen: Screen):
+    """Test that clicking a button to enter fullscreen creates the correct JavaScript call.
+
+    Note: We cannot test actual fullscreen behavior as it requires user interaction,
+    but we can verify the JavaScript method is called correctly.
+    """
+    values = []
+
+    fullscreen = ui.fullscreen(on_value_change=lambda e: values.append(e.value))
+    ui.button('Enter Fullscreen', on_click=fullscreen.enter)
+    ui.button('Exit Fullscreen', on_click=fullscreen.exit)
+
+    screen.open('/')
+    screen.click('Enter Fullscreen')
+    screen.wait(0.5)
+    assert values == [True]
+
+    screen.click('Exit Fullscreen')
+    screen.wait(0.5)
+    assert values == [True, False]