瀏覽代碼

Merge branch 'main' into changeable_native_window

Falko Schindler 2 年之前
父節點
當前提交
4057c6c693

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.2.14
-date-released: '2023-05-14'
+version: v1.2.15
+date-released: '2023-05-27'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.7933863
+doi: 10.5281/zenodo.7976420

+ 7 - 6
examples/authentication/main.py

@@ -1,11 +1,12 @@
 #!/usr/bin/env python3
-'''This is only a very simple authentication example which stores session IDs in memory and does not do any password hashing.
+"""This is a very simple authentication example which stores session IDs in memory and does not do any password hashing.
 
 Please see the `OAuth2 example at FastAPI <https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/>`_  or
-use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_ to implement a real authentication system.
+use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_
+to implement a real authentication system.
 
 Here we just demonstrate the NiceGUI integration.
-'''
+"""
 
 import os
 import uuid
@@ -38,7 +39,7 @@ def main_page(request: Request) -> None:
         ui.label(f'Hello {session["username"]}!').classes('text-2xl')
         # NOTE we navigate to a new page here to be able to modify the session cookie (it is only editable while a request is en-route)
         # see https://github.com/zauberzeug/nicegui/issues/527 for more details
-        ui.button('', on_click=lambda: ui.open('/logout')).props('outline round icon=logout')
+        ui.button(on_click=lambda: ui.open('/logout')).props('outline round icon=logout')
 
 
 @ui.page('/login')
@@ -55,12 +56,12 @@ def login(request: Request) -> None:
     request.session['id'] = str(uuid.uuid4())  # NOTE this stores a new session ID in the cookie of the client
     with ui.card().classes('absolute-center'):
         username = ui.input('Username').on('keydown.enter', try_login)
-        password = ui.input('Password').props('type=password').on('keydown.enter', try_login)
+        password = ui.input('Password').on('keydown.enter', try_login).props('type=password')
         ui.button('Log in', on_click=try_login)
 
 
 @ui.page('/logout')
-def logout(request: Request) -> None:
+def logout(request: Request) -> RedirectResponse:
     if is_authenticated(request):
         session_info.pop(request.session['id'])
         request.session['id'] = None

+ 57 - 0
examples/lightbox/main.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+from typing import List
+
+import httpx
+
+from nicegui import events, ui
+
+
+class Lightbox:
+    """A thumbnail gallery where each image can be clicked to enlarge.
+    Inspired by https://lokeshdhakar.com/projects/lightbox2/.
+    """
+
+    def __init__(self) -> None:
+        with ui.dialog().props('maximized').classes('bg-black') as self.dialog:
+            ui.keyboard(self._on_key)
+            self.large_image = ui.image().props('no-spinner')
+        self.image_list: List[str] = []
+
+    def add_image(self, thumb_url: str, orig_url: str) -> ui.image:
+        """Place a thumbnail image in the UI and make it clickable to enlarge."""
+        self.image_list.append(orig_url)
+        with ui.button(on_click=lambda: self._open(orig_url)).props('flat dense square'):
+            return ui.image(thumb_url)
+
+    def _on_key(self, event_args: events.KeyEventArguments) -> None:
+        if not event_args.action.keydown:
+            return
+        if event_args.key.escape:
+            self.dialog.close()
+        image_index = self.image_list.index(self.large_image.source)
+        if event_args.key.arrow_left and image_index > 0:
+            self._open(self.image_list[image_index - 1])
+        if event_args.key.arrow_right and image_index < len(self.image_list) - 1:
+            self._open(self.image_list[image_index + 1])
+
+    def _open(self, url: str) -> None:
+        self.large_image.set_source(url)
+        self.dialog.open()
+
+
+@ui.page('/')
+async def page():
+    lightbox = Lightbox()
+    async with httpx.AsyncClient() as client:  # using async httpx instead of sync requests to avoid blocking the event loop
+        images = await client.get('https://picsum.photos/v2/list?page=4&limit=30')
+    with ui.row().classes('w-full'):
+        for image in images.json():  # picsum returns a list of images as json data
+            # we can use the image ID to construct the image URLs
+            image_base_url = f'https://picsum.photos/id/{image["id"]}'
+            # the lightbox allows us to add images which can be opened in a full screen dialog
+            lightbox.add_image(
+                thumb_url=f'{image_base_url}/300/200',
+                orig_url=f'{image_base_url}/{image["width"]}/{image["height"]}',
+            ).classes('w-[300px] h-[200px]')
+
+ui.run()

+ 5 - 3
fetch_tailwind.py

@@ -94,7 +94,7 @@ for property in properties:
 with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('from __future__ import annotations\n')
     f.write('\n')
-    f.write('from typing import TYPE_CHECKING, List, Optional, overload\n')
+    f.write('from typing import TYPE_CHECKING, List, Optional, Union, overload\n')
     f.write('\n')
     f.write('if TYPE_CHECKING:\n')
     f.write('    from .element import Element\n')
@@ -116,10 +116,10 @@ with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('class Tailwind:\n')
     f.write('\n')
     f.write("    def __init__(self, _element: Optional['Element'] = None) -> None:\n")
-    f.write('        self.element = _element or PseudoElement()\n')
+    f.write('        self.element: Union[PseudoElement, Element] = PseudoElement() if _element is None else _element\n')
     f.write('\n')
     f.write('    @overload\n')
-    f.write('    def __call__(self, Tailwind) -> Tailwind:\n')
+    f.write('    def __call__(self, tailwind: Tailwind) -> Tailwind:\n')
     f.write('        ...\n')
     f.write('\n')
     f.write('    @overload\n')
@@ -127,6 +127,8 @@ with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('        ...\n')
     f.write('\n')
     f.write('    def __call__(self, *args) -> Tailwind:\n')
+    f.write('        if not args:\n')
+    f.write('           return self\n')
     f.write('        if isinstance(args[0], Tailwind):\n')
     f.write('            args[0].apply(self.element)\n')
     f.write('        else:\n')

+ 1 - 0
main.py

@@ -279,6 +279,7 @@ async def index_page(client: Client) -> None:
             example_link('Chat with AI', 'a simple chat app with AI')
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
+            example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
 
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')

+ 2 - 0
mypy.ini

@@ -0,0 +1,2 @@
+[mypy]
+ignore_missing_imports = True

+ 1 - 1
nicegui/__init__.py

@@ -3,7 +3,7 @@ try:
 except ModuleNotFoundError:
     import importlib_metadata
 
-__version__ = importlib_metadata.version('nicegui')
+__version__: str = importlib_metadata.version('nicegui')
 
 from . import elements, globals, ui
 from .client import Client

+ 1 - 1
nicegui/app.py

@@ -43,7 +43,7 @@ class App(FastAPI):
         """
         globals.shutdown_handlers.append(handler)
 
-    def on_exception(self, handler: Union[Callable, Awaitable]) -> None:
+    def on_exception(self, handler: Callable) -> None:
         """Called when an exception occurs.
 
         The callback has an optional parameter of `Exception`.

+ 3 - 1
nicegui/background_tasks.py

@@ -21,7 +21,9 @@ def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.T
     Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
     """
-    task = globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
+    assert globals.loop is not None
+    task: asyncio.Task = \
+        globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
     task.add_done_callback(_handle_task_result)
     running_tasks.add(task)
     task.add_done_callback(running_tasks.discard)

+ 1 - 1
nicegui/binding.py

@@ -25,7 +25,7 @@ def set_attribute(obj: Union[object, Dict], name: str, value: Any) -> None:
         setattr(obj, name, value)
 
 
-async def loop():
+async def loop() -> None:
     while True:
         visited: Set[Tuple[int, str]] = set()
         t = time.time()

+ 2 - 2
nicegui/client.py

@@ -40,7 +40,7 @@ class Client:
                 with Element('q-page'):
                     self.content = Element('div').classes('nicegui-content')
 
-        self.waiting_javascript_commands: Dict[str, str] = {}
+        self.waiting_javascript_commands: Dict[str, Any] = {}
 
         self.head_html = ''
         self.body_html = ''
@@ -108,7 +108,7 @@ class Client:
         self.is_waiting_for_disconnect = False
 
     async def run_javascript(self, code: str, *,
-                             respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
+                             respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[Any]:
         """Execute JavaScript on the client.
 
         The client connection must be established before this method is called.

+ 3 - 2
nicegui/element.py

@@ -18,7 +18,7 @@ from .tailwind import Tailwind
 if TYPE_CHECKING:
     from .client import Client
 
-PROPS_PATTERN = re.compile(r'([\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)')
+PROPS_PATTERN = re.compile(r'([:\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)')
 
 
 class Element(Visibility):
@@ -80,7 +80,7 @@ class Element(Visibility):
             for child in slot:
                 yield child
 
-    def _collect_slot_dict(self) -> Dict[str, List[int]]:
+    def _collect_slot_dict(self) -> Dict[str, Any]:
         return {
             name: {'template': slot.template, 'ids': [child.id for child in slot]}
             for name, slot in self.slots.items()
@@ -276,6 +276,7 @@ class Element(Visibility):
         :param target_container: container to move the element to (default: the parent container)
         :param target_index: index within the target slot (default: append to the end)
         """
+        assert self.parent_slot is not None
         self.parent_slot.children.remove(self)
         self.parent_slot.parent.update()
         target_container = target_container or self.parent_slot.parent

+ 3 - 2
nicegui/elements/aggrid.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, cast
 
 from ..dependencies import register_component
 from ..element import Element
@@ -67,7 +67,8 @@ class AgGrid(Element):
 
         :return: list of selected row data
         """
-        return await run_javascript(f'return getElement({self.id}).gridOptions.api.getSelectedRows();')
+        result = await run_javascript(f'return getElement({self.id}).gridOptions.api.getSelectedRows();')
+        return cast(List[Dict], result)
 
     async def get_selected_row(self) -> Optional[Dict]:
         """Get the single currently selected row.

+ 2 - 1
nicegui/elements/input.py

@@ -58,9 +58,10 @@ class Input(ValueElement, DisableableElement):
             def find_autocompletion() -> Optional[str]:
                 if self.value:
                     needle = str(self.value).casefold()
-                    for item in autocomplete:
+                    for item in autocomplete or []:
                         if item.casefold().startswith(needle):
                             return item
+                return None  # required by mypy
 
             def autocomplete_input() -> None:
                 match = find_autocompletion() or ''

+ 4 - 4
nicegui/elements/mixins/value_element.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, List, Optional
 
 from typing_extensions import Self
 
@@ -8,9 +8,9 @@ from ...events import ValueChangeEventArguments, handle_event
 
 
 class ValueElement(Element):
-    VALUE_PROP = 'model-value'
-    EVENT_ARGS = ['value']
-    LOOPBACK = True
+    VALUE_PROP: str = 'model-value'
+    EVENT_ARGS: Optional[List[str]] = ['value']
+    LOOPBACK: bool = True
     value = BindableProperty(on_change=lambda sender, value: sender.on_value_change(value))
 
     def __init__(self, *,

+ 3 - 2
nicegui/elements/mixins/visibility.py

@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING, Any, Callable
+from typing import TYPE_CHECKING, Any, Callable, cast
 
 from typing_extensions import Self
 
@@ -79,11 +79,12 @@ class Visibility:
         """
         self.visible = visible
 
-    def on_visibility_change(self: 'Element', visible: str) -> None:
+    def on_visibility_change(self, visible: str) -> None:
         """Called when the visibility of this element changes.
 
         :param visible: Whether the element should be visible.
         """
+        self = cast('Element', self)
         if visible and 'hidden' in self._classes:
             self._classes.remove('hidden')
             self.update()

+ 4 - 4
nicegui/elements/scene_object3d.py

@@ -1,12 +1,12 @@
 import uuid
-from typing import TYPE_CHECKING, Any, List, Optional
+from typing import TYPE_CHECKING, Any, List, Optional, Union, cast
 
 import numpy as np
 
 from .. import globals
 
 if TYPE_CHECKING:
-    from .scene import Scene
+    from .scene import Scene, SceneObject
 
 
 class Object3D:
@@ -15,9 +15,9 @@ class Object3D:
         self.type = type
         self.id = str(uuid.uuid4())
         self.name: Optional[str] = None
-        self.scene: 'Scene' = globals.get_slot().parent
+        self.scene: 'Scene' = cast('Scene', globals.get_slot().parent)
         self.scene.objects[self.id] = self
-        self.parent: Object3D = self.scene.stack[-1]
+        self.parent: Union[Object3D, SceneObject] = self.scene.stack[-1]
         self.args: List = list(args)
         self.color: str = '#ffffff'
         self.opacity: float = 1.0

+ 7 - 5
nicegui/elements/upload.py

@@ -1,6 +1,7 @@
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Dict, Optional
 
-from fastapi import Request, Response
+from fastapi import Request
+from starlette.datastructures import UploadFile
 
 from ..dependencies import register_component
 from ..events import EventArguments, UploadEventArguments, handle_event
@@ -51,14 +52,15 @@ class Upload(DisableableElement):
             self._props['max-files'] = max_files
 
         @app.post(self._props['url'])
-        async def upload_route(request: Request) -> Response:
+        async def upload_route(request: Request) -> Dict[str, str]:
             for data in (await request.form()).values():
+                assert isinstance(data, UploadFile)
                 args = UploadEventArguments(
                     sender=self,
                     client=self.client,
                     content=data.file,
-                    name=data.filename,
-                    type=data.content_type,
+                    name=data.filename or '',
+                    type=data.content_type or '',
                 )
                 handle_event(on_upload, args)
             return {'upload': 'success'}

+ 2 - 2
nicegui/event_listener.py

@@ -1,6 +1,6 @@
 import uuid
 from dataclasses import dataclass, field
-from typing import Any, Callable, Dict, List
+from typing import Any, Callable, Dict, List, Optional
 
 from .helpers import KWONLY_SLOTS
 
@@ -10,7 +10,7 @@ class EventListener:
     id: str = field(init=False)
     element_id: int
     type: str
-    args: List[str]
+    args: Optional[List[str]]
     handler: Callable
     throttle: float
     leading_events: bool

+ 2 - 2
nicegui/events.py

@@ -1,5 +1,5 @@
 from dataclasses import dataclass
-from inspect import signature
+from inspect import Parameter, signature
 from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, List, Optional, Union
 
 from . import background_tasks, globals
@@ -274,7 +274,7 @@ def handle_event(handler: Optional[Callable[..., Any]],
     try:
         if handler is None:
             return
-        no_arguments = not signature(handler).parameters
+        no_arguments = not any(p.default is Parameter.empty for p in signature(handler).parameters.values())
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
         assert sender is not None and sender.parent_slot is not None
         with sender.parent_slot:

+ 2 - 2
nicegui/functions/javascript.py

@@ -1,10 +1,10 @@
-from typing import Optional
+from typing import Any, Optional
 
 from .. import globals
 
 
 async def run_javascript(code: str, *,
-                         respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
+                         respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[Any]:
     """Run JavaScript
 
     This function runs arbitrary JavaScript code on a page that is executed in the browser.

+ 40 - 31
nicegui/functions/refreshable.py

@@ -1,15 +1,41 @@
-from typing import Any, Callable, Dict, List, Tuple
+from dataclasses import dataclass
+from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union
 
 from typing_extensions import Self
 
 from .. import background_tasks, globals
 from ..dependencies import register_component
 from ..element import Element
-from ..helpers import is_coroutine
+from ..helpers import KWONLY_SLOTS, is_coroutine
 
 register_component('refreshable', __file__, 'refreshable.js')
 
 
+@dataclass(**KWONLY_SLOTS)
+class RefreshableTarget:
+    container: Element
+    instance: Any
+    args: Tuple[Any, ...]
+    kwargs: Dict[str, Any]
+
+    def run(self, func: Callable[..., Any]) -> Union[None, Awaitable]:
+        if is_coroutine(func):
+            async def wait_for_result() -> None:
+                with self.container:
+                    if self.instance is None:
+                        await func(*self.args, **self.kwargs)
+                    else:
+                        await func(self.instance, *self.args, **self.kwargs)
+            return wait_for_result()
+        else:
+            with self.container:
+                if self.instance is None:
+                    func(*self.args, **self.kwargs)
+                else:
+                    func(self.instance, *self.args, **self.kwargs)
+            return None  # required by mypy
+
+
 class refreshable:
 
     def __init__(self, func: Callable[..., Any]) -> None:
@@ -20,48 +46,31 @@ class refreshable:
         """
         self.func = func
         self.instance = None
-        self.containers: List[Tuple[Element, List[Any], Dict[str, Any]]] = []
+        self.targets: List[RefreshableTarget] = []
 
     def __get__(self, instance, _) -> Self:
         self.instance = instance
         return self
 
-    def __call__(self, *args: Any, **kwargs: Any) -> None:
+    def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]:
         self.prune()
-        with Element('refreshable') as container:
-            self.containers.append((container, args, kwargs))
-        return self._run_in_container(container, *args, **kwargs)
+        target = RefreshableTarget(container=Element('refreshable'), instance=self.instance, args=args, kwargs=kwargs)
+        self.targets.append(target)
+        return target.run(self.func)
 
     def refresh(self) -> None:
         self.prune()
-        for container, args, kwargs in self.containers:
-            container.clear()
-            result = self._run_in_container(container, *args, **kwargs)
+        for target in self.targets:
+            if target.instance != self.instance:
+                continue
+            target.container.clear()
+            result = target.run(self.func)
             if is_coroutine(self.func):
+                assert result is not None
                 if globals.loop and globals.loop.is_running():
                     background_tasks.create(result)
                 else:
                     globals.app.on_startup(result)
 
     def prune(self) -> None:
-        self.containers = [
-            (container, args, kwargs)
-            for container, args, kwargs in self.containers
-            if container.client.id in globals.clients
-        ]
-
-    def _run_in_container(self, container: Element, *args: Any, **kwargs: Any) -> None:
-        if is_coroutine(self.func):
-            async def wait_for_result() -> None:
-                with container:
-                    if self.instance is None:
-                        await self.func(*args, **kwargs)
-                    else:
-                        await self.func(self.instance, *args, **kwargs)
-            return wait_for_result()
-        else:
-            with container:
-                if self.instance is None:
-                    self.func(*args, **kwargs)
-                else:
-                    self.func(self.instance, *args, **kwargs)
+        self.targets = [target for target in self.targets if target.container.client.id in globals.clients]

+ 8 - 3
nicegui/functions/timer.py

@@ -1,10 +1,11 @@
 import asyncio
 import time
-from typing import Any, Callable
+from typing import Any, Callable, Optional
 
 from .. import background_tasks, globals
 from ..binding import BindableProperty
 from ..helpers import is_coroutine
+from ..slot import Slot
 
 
 class Timer:
@@ -29,9 +30,9 @@ class Timer:
         :param once: whether the callback is only executed once after a delay specified by `interval` (default: `False`)
         """
         self.interval = interval
-        self.callback = callback
+        self.callback: Optional[Callable[..., Any]] = callback
         self.active = active
-        self.slot = globals.get_slot()
+        self.slot: Optional[Slot] = globals.get_slot()
 
         coroutine = self._run_once if once else self._run_in_loop
         if globals.state == globals.State.STARTED:
@@ -43,6 +44,7 @@ class Timer:
         try:
             if not await self._connected():
                 return
+            assert self.slot is not None
             with self.slot:
                 await asyncio.sleep(self.interval)
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
@@ -54,6 +56,7 @@ class Timer:
         try:
             if not await self._connected():
                 return
+            assert self.slot is not None
             with self.slot:
                 while True:
                     if self.slot.parent.client.id not in globals.clients:
@@ -76,6 +79,7 @@ class Timer:
 
     async def _invoke_callback(self) -> None:
         try:
+            assert self.callback is not None
             result = self.callback()
             if is_coroutine(self.callback):
                 await result
@@ -88,6 +92,7 @@ class Timer:
         See https://github.com/zauberzeug/nicegui/issues/206 for details.
         Returns True if the client is connected, False if the client is not connected and the timer should be cancelled.
         """
+        assert self.slot is not None
         if self.slot.parent.client.shared:
             return True
         else:

+ 2 - 2
nicegui/globals.py

@@ -3,7 +3,7 @@ import inspect
 import logging
 from contextlib import contextmanager
 from enum import Enum
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, Optional, Union
 
 from socketio import AsyncServer
 from uvicorn import Server
@@ -86,7 +86,7 @@ def get_client() -> 'Client':
 
 
 @contextmanager
-def socket_id(id: str) -> None:
+def socket_id(id: str) -> Iterator[None]:
     global _socket_id
     _socket_id = id
     yield

+ 1 - 1
nicegui/nicegui.py

@@ -27,7 +27,7 @@ from .page import page
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 # NOTE we use custom json module which wraps orjson
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
-globals.sio = sio = app.sio
+globals.sio = sio = socket_manager._sio
 
 app.add_middleware(GZipMiddleware)
 static_files = StaticFiles(

+ 3 - 3
nicegui/outbox.py

@@ -7,7 +7,7 @@ from . import globals
 if TYPE_CHECKING:
     from .element import Element
 
-ClientId = int
+ClientId = str
 ElementId = int
 MessageType = str
 Message = Tuple[ClientId, MessageType, Any]
@@ -32,8 +32,8 @@ async def loop() -> None:
         coros = []
         try:
             for client_id, elements in update_queue.items():
-                elements = {element_id: element._to_dict() for element_id, element in elements.items()}
-                coros.append(globals.sio.emit('update', elements, room=client_id))
+                data = {element_id: element._to_dict() for element_id, element in elements.items()}
+                coros.append(globals.sio.emit('update', data, room=client_id))
             update_queue.clear()
             for client_id, message_type, data in message_queue:
                 coros.append(globals.sio.emit(message_type, data, room=client_id))

+ 5 - 3
nicegui/tailwind.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, List, Optional, overload
+from typing import TYPE_CHECKING, List, Optional, Union, overload
 
 if TYPE_CHECKING:
     from .element import Element
@@ -177,10 +177,10 @@ class PseudoElement:
 class Tailwind:
 
     def __init__(self, _element: Optional['Element'] = None) -> None:
-        self.element = PseudoElement() if _element is None else _element
+        self.element: Union[PseudoElement, Element] = PseudoElement() if _element is None else _element
 
     @overload
-    def __call__(self, Tailwind) -> Tailwind:
+    def __call__(self, tailwind: Tailwind) -> Tailwind:
         ...
 
     @overload
@@ -188,6 +188,8 @@ class Tailwind:
         ...
 
     def __call__(self, *args) -> Tailwind:
+        if not args:
+            return self
         if isinstance(args[0], Tailwind):
             args[0].apply(self.element)
         else:

+ 6 - 0
nicegui/templates/index.html

@@ -72,6 +72,12 @@
           style: Object.entries(element.style).reduce((str, [p, val]) => `${str}${p}:${val};`, '') || undefined,
           ...element.props,
         };
+        Object.entries(props).forEach(([key, value]) => {
+          if (key.startsWith(':')) {
+            props[key.substring(1)] = eval(value);
+            delete props[key];
+          }
+        });
         element.events.forEach((event) => {
           let event_name = 'on' + event.type[0].toLocaleUpperCase() + event.type.substring(1);
           event.specials.forEach(s => event_name += s[0].toLocaleUpperCase() + s.substring(1));

+ 14 - 0
tests/test_date.py

@@ -50,3 +50,17 @@ def test_date_with_range_and_multi_selection(screen: Screen):
     screen.click('25')
     screen.click('28')
     screen.should_contain('8 days')
+
+
+def test_date_with_filter(screen: Screen):
+    d = ui.date().props('''default-year-month=2023/01 :options="date => date <= '2023/01/15'"''')
+    ui.label().bind_text_from(d, 'value')
+
+    screen.open('/')
+    screen.click('14')
+    screen.should_contain('2023-01-14')
+    screen.click('15')
+    screen.should_contain('2023-01-15')
+    screen.click('16')
+    screen.wait(0.5)
+    screen.should_not_contain('2023-01-16')

+ 37 - 0
tests/test_refreshable.py

@@ -61,3 +61,40 @@ async def test_async_refreshable(screen: Screen) -> None:
     numbers.clear()
     screen.click('Refresh')
     screen.should_contain('[]')
+
+
+def test_multiple_targets(screen: Screen) -> None:
+    count = 0
+
+    class MyClass:
+
+        def __init__(self, name: str) -> None:
+            self.name = name
+            self.state = 1
+
+        @ui.refreshable
+        def create_ui(self) -> None:
+            nonlocal count
+            count += 1
+            ui.label(f'{self.name} = {self.state} ({count})')
+            ui.button(f'increment {self.name}', on_click=self.increment)
+
+        def increment(self) -> None:
+            self.state += 1
+            self.create_ui.refresh()
+
+    a = MyClass('A')
+    a.create_ui()
+
+    b = MyClass('B')
+    b.create_ui()
+
+    screen.open('/')
+    screen.should_contain('A = 1 (1)')
+    screen.should_contain('B = 1 (2)')
+    screen.click('increment A')
+    screen.should_contain('A = 2 (3)')
+    screen.should_contain('B = 1 (2)')
+    screen.click('increment B')
+    screen.should_contain('A = 2 (3)')
+    screen.should_contain('B = 2 (4)')

+ 3 - 3
tests/test_time.py

@@ -9,12 +9,12 @@ def test_time(screen: Screen):
 
     screen.open('/')
     screen.should_contain('01:23')
-
+    screen.wait(0.2)
     screen.click('8')
     screen.should_contain('08:23')
-
+    screen.wait(0.2)
     screen.click('45')
     screen.should_contain('08:45')
-
+    screen.wait(0.2)
     screen.click('PM')
     screen.should_contain('20:45')

+ 1 - 0
website/documentation.py

@@ -207,6 +207,7 @@ def create_full() -> None:
         NiceGUI uses the [Quasar Framework](https://quasar.dev/) version 1.0 and hence has its full design power.
         Each NiceGUI element provides a `props` method whose content is passed [to the Quasar component](https://justpy.io/quasar_tutorial/introduction/#props-of-quasar-components):
         Have a look at [the Quasar documentation](https://quasar.dev/vue-components/button#design) for all styling props.
+        Props with a leading `:` can contain JavaScript expressions that are evaluated on the client.
         You can also apply [Tailwind CSS](https://tailwindcss.com/) utility classes with the `classes` method.
 
         If you really need to apply CSS, you can use the `styles` method. Here the delimiter is `;` instead of a blank space.

+ 8 - 0
website/more_documentation/date_documentation.py

@@ -23,3 +23,11 @@ def more() -> None:
                 ui.icon('edit_calendar').on('click', lambda: menu.open()).classes('cursor-pointer')
             with ui.menu() as menu:
                 ui.date().bind_value(date)
+
+    @text_demo('Date filter', '''
+        This demo shows how to filter the dates in a date picker.
+        In order to pass a function to the date picker, we use the `:options` property.
+        The leading `:` tells NiceGUI that the value is a JavaScript expression.
+    ''')
+    def date_filter():
+        ui.date().props('''default-year-month=2023/01 :options="date => date <= '2023/01/15'"''')

+ 7 - 0
website/more_documentation/input_documentation.py

@@ -19,3 +19,10 @@ def more() -> None:
     async def autocomplete_demo():
         options = ['AutoComplete', 'NiceGUI', 'Awesome']
         ui.input(label='Text', placeholder='start typing', autocomplete=options)
+
+    @text_demo('Clearable', '''
+        The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
+    ''')
+    async def clearable():
+        i = ui.input(value='some text').props('clearable')
+        ui.label().bind_text_from(i, 'value')

+ 12 - 0
website/more_documentation/number_documentation.py

@@ -1,7 +1,19 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     ui.number(label='Number', value=3.1415927, format='%.2f',
               on_change=lambda e: result.set_text(f'you entered: {e.value}'))
     result = ui.label()
+
+
+def more() -> None:
+
+    @text_demo('Clearable', '''
+        The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
+    ''')
+    async def clearable():
+        i = ui.number(value=42).props('clearable')
+        ui.label().bind_text_from(i, 'value')

+ 12 - 0
website/more_documentation/textarea_documentation.py

@@ -1,7 +1,19 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     ui.textarea(label='Text', placeholder='start typing',
                 on_change=lambda e: result.set_text('you typed: ' + e.value))
     result = ui.label()
+
+
+def more() -> None:
+
+    @text_demo('Clearable', '''
+        The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
+    ''')
+    async def clearable():
+        i = ui.textarea(value='some text').props('clearable')
+        ui.label().bind_text_from(i, 'value')