Browse Source

Allow registering click and value change events after element instantiation (#2704)

* allow registering click and value change events after element instantiation

* Event registration, continued :) (#2708)

* JsonEditor: on_select and on_change builder-style registration

* ValueElement: builder pattern for on_value_change

* Table: allow multiple on_selection and on_pagination_change handlers

via the builder pattern

* Tree: allow all Python handlers to be registered more than once

via the builder pattern

* Keyboard element: support multiple on_key handlers

via the builder pattern.

* color picker: support multiple on_pick callbacks via builder pattern

* list item: support multiple on_click callbacks via builder pattern

* MenuItem: support multiple on_click callbacks via builder pattern

* Upload: support multiple on_upload & on_rejected callbacks via builder pattern

* Scene: support multiple click/drag handlers via builder pattern

* ScrollArea: support multiple on_scroll callbacks via builder pattern

* EChart: support multiple on_point_click handlers via builder pattern

* InteractiveImage: support multiple on_mouse handlers via builder pattern

* Joystick: support multiple handlers of each event type via builder pattern

* code review

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>

* use builder pattern for ui.button.on_click

* make pick and key handlers optional

---------

Co-authored-by: Peter Gaultney <petergaultney@gmail.com>
Falko Schindler 1 năm trước cách đây
mục cha
commit
072381e39e

+ 1 - 1
examples/fullcalendar/fullcalendar.py

@@ -13,7 +13,7 @@ class FullCalendar(Element, component='fullcalendar.js'):
         An element that integrates the FullCalendar library (https://fullcalendar.io/) to create an interactive calendar display.
 
         :param options: dictionary of FullCalendar properties for customization, such as "initialView", "slotMinTime", "slotMaxTime", "allDaySlot", "timeZone", "height", and "events".
-        :param on_click: callback function that is called when a calendar event is clicked.
+        :param on_click: callback that is called when a calendar event is clicked.
         """
         super().__init__()
         self.add_resource(Path(__file__).parent / 'lib')

+ 2 - 2
nicegui/client.py

@@ -224,11 +224,11 @@ class Client:
         self.outbox.enqueue_message('download', {'src': src, 'filename': filename, 'media_type': media_type}, self.id)
 
     def on_connect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
-        """Register a callback to be called when the client connects."""
+        """Add a callback to be invoked when the client connects."""
         self.connect_handlers.append(handler)
 
     def on_disconnect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
-        """Register a callback to be called when the client disconnects."""
+        """Add a callback to be invoked when the client disconnects."""
         self.disconnect_handlers.append(handler)
 
     def handle_handshake(self) -> None:

+ 8 - 1
nicegui/elements/button.py

@@ -1,6 +1,8 @@
 import asyncio
 from typing import Any, Callable, Optional
 
+from typing_extensions import Self
+
 from ..events import ClickEventArguments, handle_event
 from .mixins.color_elements import BackgroundColorElement
 from .mixins.disableable_element import DisableableElement
@@ -35,7 +37,12 @@ class Button(TextElement, DisableableElement, BackgroundColorElement):
             self._props['icon'] = icon
 
         if on_click:
-            self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)), [])
+            self.on_click(on_click)
+
+    def on_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the button is clicked."""
+        self.on('click', lambda _: handle_event(callback, ClickEventArguments(sender=self, client=self.client)), [])
+        return self
 
     def _text_to_model_text(self, text: str) -> None:
         self._props['label'] = text

+ 15 - 3
nicegui/elements/color_picker.py

@@ -1,4 +1,6 @@
-from typing import Any, Callable
+from typing import Any, Callable, Optional
+
+from typing_extensions import Self
 
 from ..element import Element
 from ..events import ColorPickEventArguments, GenericEventArguments, handle_event
@@ -7,7 +9,10 @@ from .menu import Menu
 
 class ColorPicker(Menu):
 
-    def __init__(self, *, on_pick: Callable[..., Any], value: bool = False) -> None:
+    def __init__(self, *,
+                 on_pick: Optional[Callable[..., Any]] = None,
+                 value: bool = False,
+                 ) -> None:
         """Color Picker
 
         This element is based on Quasar's `QMenu <https://quasar.dev/vue-components/menu>`_ and
@@ -17,9 +22,11 @@ class ColorPicker(Menu):
         :param value: whether the menu is already opened (default: `False`)
         """
         super().__init__(value=value)
+        self._pick_handlers = [on_pick] if on_pick else []
         with self:
             def handle_change(e: GenericEventArguments):
-                handle_event(on_pick, ColorPickEventArguments(sender=self, client=self.client, color=e.args))
+                for handler in self._pick_handlers:
+                    handle_event(handler, ColorPickEventArguments(sender=self, client=self.client, color=e.args))
             self.q_color = Element('q-color').on('change', handle_change)
 
     def set_color(self, color: str) -> None:
@@ -28,3 +35,8 @@ class ColorPicker(Menu):
         :param color: the color to set
         """
         self.q_color.props(f'model-value="{color}"')
+
+    def on_pick(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a color is picked."""
+        self._pick_handlers.append(callback)
+        return self

+ 33 - 28
nicegui/elements/echart.py

@@ -1,4 +1,4 @@
-from typing import Callable, Dict, Optional
+from typing import Any, Callable, Dict, Optional
 
 from typing_extensions import Self
 
@@ -27,45 +27,50 @@ class EChart(Element, component='echart.js', libraries=['lib/echarts/echarts.min
         After data has changed, call the `update` method to refresh the chart.
 
         :param options: dictionary of EChart options
-        :param on_click_point: callback function that is called when a point is clicked
+        :param on_click_point: callback that is invoked when a point is clicked
         """
         super().__init__()
         self._props['options'] = options
         self._classes.append('nicegui-echart')
 
         if on_point_click:
-            def handle_point_click(e: GenericEventArguments) -> None:
-                handle_event(on_point_click, EChartPointClickEventArguments(
-                    sender=self,
-                    client=self.client,
-                    component_type=e.args['componentType'],
-                    series_type=e.args['seriesType'],
-                    series_index=e.args['seriesIndex'],
-                    series_name=e.args['seriesName'],
-                    name=e.args['name'],
-                    data_index=e.args['dataIndex'],
-                    data=e.args['data'],
-                    data_type=e.args.get('dataType'),
-                    value=e.args['value'],
-                ))
-            self.on('pointClick', handle_point_click, [
-                'componentType',
-                'seriesType',
-                'seriesIndex',
-                'seriesName',
-                'name',
-                'dataIndex',
-                'data',
-                'dataType',
-                'value',
-            ])
+            self.on_point_click(on_point_click)
+
+    def on_point_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a point is clicked."""
+        def handle_point_click(e: GenericEventArguments) -> None:
+            handle_event(callback, EChartPointClickEventArguments(
+                sender=self,
+                client=self.client,
+                component_type=e.args['componentType'],
+                series_type=e.args['seriesType'],
+                series_index=e.args['seriesIndex'],
+                series_name=e.args['seriesName'],
+                name=e.args['name'],
+                data_index=e.args['dataIndex'],
+                data=e.args['data'],
+                data_type=e.args.get('dataType'),
+                value=e.args['value'],
+            ))
+        self.on('pointClick', handle_point_click, [
+            'componentType',
+            'seriesType',
+            'seriesIndex',
+            'seriesName',
+            'name',
+            'dataIndex',
+            'data',
+            'dataType',
+            'value',
+        ])
+        return self
 
     @classmethod
     def from_pyecharts(cls, chart: 'Chart', on_point_click: Optional[Callable] = None) -> Self:
         """Create an echart element from a pyecharts object.
 
         :param chart: pyecharts chart object
-        :param on_click_point: callback function that is called when a point is clicked
+        :param on_click_point: callback which is invoked when a point is clicked
 
         :return: echart element
         """

+ 8 - 2
nicegui/elements/interactive_image.py

@@ -4,6 +4,8 @@ import time
 from pathlib import Path
 from typing import Any, Callable, List, Optional, Tuple, Union, cast
 
+from typing_extensions import Self
+
 from .. import optional_features
 from ..events import GenericEventArguments, MouseEventArguments, handle_event
 from .image import pil_to_base64
@@ -59,9 +61,12 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
         self._props['cross'] = cross
         self._props['size'] = size
 
+        if on_mouse:
+            self.on_mouse(on_mouse)
+
+    def on_mouse(self, on_mouse: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a mouse event occurs."""
         def handle_mouse(e: GenericEventArguments) -> None:
-            if on_mouse is None:
-                return
             args = cast(dict, e.args)
             arguments = MouseEventArguments(
                 sender=self,
@@ -78,6 +83,7 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
             )
             handle_event(on_mouse, arguments)
         self.on('mouse', handle_mouse)
+        return self
 
     def _set_props(self, source: Union[str, Path, 'PIL_Image']) -> None:
         if optional_features.has('pillow') and isinstance(source, PIL_Image):

+ 36 - 11
nicegui/elements/joystick.py

@@ -1,5 +1,7 @@
 from typing import Any, Callable, Optional
 
+from typing_extensions import Self
+
 from ..element import Element
 from ..events import GenericEventArguments, JoystickEventArguments, handle_event
 
@@ -26,26 +28,49 @@ class Joystick(Element, component='joystick.vue', libraries=['lib/nipplejs/nippl
         self._props['options'] = options
         self.active = False
 
+        self._start_handlers = [on_start] if on_start else []
+        self._move_handlers = [on_move] if on_move else []
+        self._end_handlers = [on_end] if on_end else []
+
         def handle_start() -> None:
             self.active = True
-            handle_event(on_start, JoystickEventArguments(sender=self,
-                                                          client=self.client,
-                                                          action='start'))
+            args = JoystickEventArguments(sender=self, client=self.client, action='start')
+            for handler in self._start_handlers:
+                handle_event(handler, args)
 
         def handle_move(e: GenericEventArguments) -> None:
             if self.active:
-                handle_event(on_move, JoystickEventArguments(sender=self,
-                                                             client=self.client,
-                                                             action='move',
-                                                             x=float(e.args['data']['vector']['x']),
-                                                             y=float(e.args['data']['vector']['y'])))
+                args = JoystickEventArguments(sender=self,
+                                              client=self.client,
+                                              action='move',
+                                              x=float(e.args['data']['vector']['x']),
+                                              y=float(e.args['data']['vector']['y']))
+                for handler in self._move_handlers:
+                    handle_event(handler, args)
 
         def handle_end() -> None:
             self.active = False
-            handle_event(on_end, JoystickEventArguments(sender=self,
-                                                        client=self.client,
-                                                        action='end'))
+            args = JoystickEventArguments(sender=self,
+                                          client=self.client,
+                                          action='end')
+            for handler in self._end_handlers:
+                handle_event(handler, args)
 
         self.on('start', handle_start, [])
         self.on('move', handle_move, ['data'], throttle=throttle)
         self.on('end', handle_end, [])
+
+    def on_start(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the user touches the joystick."""
+        self._start_handlers.append(callback)
+        return self
+
+    def on_move(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the user moves the joystick."""
+        self._move_handlers.append(callback)
+        return self
+
+    def on_end(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the user releases the joystick."""
+        self._end_handlers.append(callback)
+        return self

+ 21 - 9
nicegui/elements/json_editor.py

@@ -1,4 +1,6 @@
-from typing import Callable, Dict, Optional
+from typing import Any, Callable, Dict, Optional
+
+from typing_extensions import Self
 
 from ..awaitable_response import AwaitableResponse
 from ..element import Element
@@ -19,21 +21,31 @@ class JsonEditor(Element, component='json_editor.js', exposed_libraries=['lib/va
         After data has changed, call the `update` method to refresh the editor.
 
         :param properties: dictionary of JSONEditor properties
-        :param on_select: callback function that is called when some of the content has been selected
-        :param on_change: callback function that is called when the content has changed
+        :param on_select: callback which is invoked when some of the content has been selected
+        :param on_change: callback which is invoked when the content has changed
         """
         super().__init__()
         self._props['properties'] = properties
 
         if on_select:
-            def handle_on_select(e: GenericEventArguments) -> None:
-                handle_event(on_select, JsonEditorSelectEventArguments(sender=self, client=self.client, **e.args))
-            self.on('select', handle_on_select, ['selection'])
+            self.on_select(on_select)
 
         if on_change:
-            def handle_on_change(e: GenericEventArguments) -> None:
-                handle_event(on_change, JsonEditorChangeEventArguments(sender=self, client=self.client, **e.args))
-            self.on('change', handle_on_change, ['content', 'errors'])
+            self.on_change(on_change)
+
+    def on_change(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the content changes."""
+        def handle_on_change(e: GenericEventArguments) -> None:
+            handle_event(callback, JsonEditorChangeEventArguments(sender=self, client=self.client, **e.args))
+        self.on('change', handle_on_change, ['content', 'errors'])
+        return self
+
+    def on_select(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when some of the content has been selected."""
+        def handle_on_select(e: GenericEventArguments) -> None:
+            handle_event(callback, JsonEditorSelectEventArguments(sender=self, client=self.client, **e.args))
+        self.on('select', handle_on_select, ['selection'])
+        return self
 
     @property
     def properties(self) -> Dict:

+ 12 - 4
nicegui/elements/keyboard.py

@@ -1,4 +1,6 @@
-from typing import Any, Callable, List, Literal
+from typing import Any, Callable, List, Literal, Optional
+
+from typing_extensions import Self
 
 from ..binding import BindableProperty
 from ..element import Element
@@ -10,7 +12,7 @@ class Keyboard(Element, component='keyboard.js'):
     active = BindableProperty()
 
     def __init__(self,
-                 on_key: Callable[..., Any], *,
+                 on_key: Optional[Callable[..., Any]] = None, *,
                  active: bool = True,
                  repeating: bool = True,
                  ignore: List[Literal['input', 'select', 'button', 'textarea']] = [
@@ -26,7 +28,7 @@ class Keyboard(Element, component='keyboard.js'):
         :param ignore: ignore keys when one of these element types is focussed (default: `['input', 'select', 'button', 'textarea']`)
         """
         super().__init__()
-        self.key_handler = on_key
+        self._key_handlers = [on_key] if on_key else []
         self.active = active
         self._props['events'] = ['keydown', 'keyup']
         self._props['repeating'] = repeating
@@ -60,4 +62,10 @@ class Keyboard(Element, component='keyboard.js'):
             modifiers=modifiers,
             key=key,
         )
-        handle_event(self.key_handler, arguments)
+        for handler in self._key_handlers:
+            handle_event(handler, arguments)
+
+    def on_key(self, handler: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when keyboard events occur."""
+        self._key_handlers.append(handler)
+        return self

+ 9 - 2
nicegui/elements/list.py

@@ -1,5 +1,7 @@
 from typing import Any, Callable, Optional
 
+from typing_extensions import Self
+
 from ..element import Element
 from ..events import ClickEventArguments, handle_event
 from .mixins.disableable_element import DisableableElement
@@ -28,8 +30,13 @@ class Item(DisableableElement):
         super().__init__(tag='q-item')
 
         if on_click:
-            self._props['clickable'] = True
-            self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)))
+            self.on_click(on_click)
+
+    def on_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the List Item is clicked."""
+        self._props['clickable'] = True  # idempotent
+        self.on('click', lambda _: handle_event(callback, ClickEventArguments(sender=self, client=self.client)))
+        return self
 
 
 class ItemSection(Element):

+ 8 - 1
nicegui/elements/menu.py

@@ -63,10 +63,17 @@ class MenuItem(TextElement):
         super().__init__(tag='q-item', text=text)
         self.menu = context.get_slot().parent
         self._props['clickable'] = True
+        self._click_handlers = [on_click] if on_click else []
 
         def handle_click(_) -> None:
-            handle_event(on_click, ClickEventArguments(sender=self, client=self.client))
+            for handler in self._click_handlers:
+                handle_event(handler, ClickEventArguments(sender=self, client=self.client))
             if auto_close:
                 assert isinstance(self.menu, (Menu, ContextMenu))
                 self.menu.close()
         self.on('click', handle_click, [])
+
+    def on_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the menu item is clicked."""
+        self._click_handlers.append(callback)
+        return self

+ 8 - 2
nicegui/elements/mixins/value_element.py

@@ -24,7 +24,7 @@ class ValueElement(Element):
         self.set_value(value)
         self._props[self.VALUE_PROP] = self._value_to_model_value(value)
         self._props['loopback'] = self.LOOPBACK
-        self._change_handler = on_value_change
+        self._change_handlers: list[Callable[..., Any]] = [on_value_change] if on_value_change else []
 
         def handle_change(e: GenericEventArguments) -> None:
             self._send_update_on_value_change = self.LOOPBACK
@@ -32,6 +32,11 @@ class ValueElement(Element):
             self._send_update_on_value_change = True
         self.on(f'update:{self.VALUE_PROP}', handle_change, [None], throttle=throttle)
 
+    def on_value_change(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the value changes."""
+        self._change_handlers.append(callback)
+        return self
+
     def bind_value_to(self,
                       target_object: Any,
                       target_name: str = 'value',
@@ -98,7 +103,8 @@ class ValueElement(Element):
         if self._send_update_on_value_change:
             self.update()
         args = ValueChangeEventArguments(sender=self, client=self.client, value=self._value_to_event_value(value))
-        handle_event(self._change_handler, args)
+        for handler in self._change_handlers:
+            handle_event(handler, args)
 
     def _event_args_to_value(self, e: GenericEventArguments) -> Any:
         return e.args

+ 23 - 5
nicegui/elements/scene.py

@@ -93,9 +93,9 @@ class Scene(Element,
         self.objects: Dict[str, Object3D] = {}
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self.camera: SceneCamera = SceneCamera()
-        self._click_handler = on_click
-        self._drag_start_handler = on_drag_start
-        self._drag_end_handler = on_drag_end
+        self._click_handlers = [on_click] if on_click else []
+        self._drag_start_handlers = [on_drag_start] if on_drag_start else []
+        self._drag_end_handlers = [on_drag_end] if on_drag_end else []
         self.is_initialized = False
         self.on('init', self._handle_init)
         self.on('click3d', self._handle_click)
@@ -103,6 +103,21 @@ class Scene(Element,
         self.on('dragend', self._handle_drag)
         self._props['drag_constraints'] = drag_constraints
 
+    def on_click(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a 3D object is clicked."""
+        self._click_handlers.append(callback)
+        return self
+
+    def on_drag_start(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a 3D object is dragged."""
+        self._drag_start_handlers.append(callback)
+        return self
+
+    def on_drag_end(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a 3D object is dropped."""
+        self._drag_end_handlers.append(callback)
+        return self
+
     def __enter__(self) -> Self:
         Object3D.current_scene = self
         super().__enter__()
@@ -151,7 +166,8 @@ class Scene(Element,
                 z=hit['point']['z'],
             ) for hit in e.args['hits']],
         )
-        handle_event(self._click_handler, arguments)
+        for handler in self._click_handlers:
+            handle_event(handler, arguments)
 
     def _handle_drag(self, e: GenericEventArguments) -> None:
         arguments = SceneDragEventArguments(
@@ -166,7 +182,9 @@ class Scene(Element,
         )
         if arguments.type == 'dragend':
             self.objects[arguments.object_id].move(arguments.x, arguments.y, arguments.z)
-        handle_event(self._drag_start_handler if arguments.type == 'dragstart' else self._drag_end_handler, arguments)
+
+        for handler in (self._drag_start_handlers if arguments.type == 'dragstart' else self._drag_end_handlers):
+            handle_event(handler, arguments)
 
     def __len__(self) -> int:
         return len(self.objects)

+ 17 - 10
nicegui/elements/scroll_area.py

@@ -1,5 +1,7 @@
 from typing import Any, Callable, Literal, Optional
 
+from typing_extensions import Self
+
 from ..element import Element
 from ..events import GenericEventArguments, ScrollEventArguments, handle_event
 
@@ -18,16 +20,21 @@ class ScrollArea(Element):
         self._classes.append('nicegui-scroll-area')
 
         if on_scroll:
-            self.on('scroll', lambda e: self._handle_scroll(on_scroll, e), args=[
-                'verticalPosition',
-                'verticalPercentage',
-                'verticalSize',
-                'verticalContainerSize',
-                'horizontalPosition',
-                'horizontalPercentage',
-                'horizontalSize',
-                'horizontalContainerSize',
-            ])
+            self.on_scroll(on_scroll)
+
+    def on_scroll(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the scroll position changes."""
+        self.on('scroll', lambda e: self._handle_scroll(callback, e), args=[
+            'verticalPosition',
+            'verticalPercentage',
+            'verticalSize',
+            'verticalContainerSize',
+            'horizontalPosition',
+            'horizontalPercentage',
+            'horizontalSize',
+            'horizontalContainerSize',
+        ])
+        return self
 
     def _handle_scroll(self, handler: Optional[Callable[..., Any]], e: GenericEventArguments) -> None:
         handle_event(handler, ScrollEventArguments(

+ 16 - 2
nicegui/elements/table.py

@@ -52,6 +52,8 @@ class Table(FilterElement, component='table.js'):
         self._props['selection'] = selection or 'none'
         self._props['selected'] = []
         self._props['fullscreen'] = False
+        self._selection_handlers = [on_select] if on_select else []
+        self._pagination_change_handlers = [on_pagination_change] if on_pagination_change else []
 
         def handle_selection(e: GenericEventArguments) -> None:
             if e.args['added']:
@@ -62,16 +64,28 @@ class Table(FilterElement, component='table.js'):
                 self.selected = [row for row in self.selected if row[row_key] not in e.args['keys']]
             self.update()
             arguments = TableSelectionEventArguments(sender=self, client=self.client, selection=self.selected)
-            handle_event(on_select, arguments)
+            for handler in self._selection_handlers:
+                handle_event(handler, arguments)
         self.on('selection', handle_selection, ['added', 'rows', 'keys'])
 
         def handle_pagination_change(e: GenericEventArguments) -> None:
             self.pagination = e.args
             self.update()
             arguments = ValueChangeEventArguments(sender=self, client=self.client, value=self.pagination)
-            handle_event(on_pagination_change, arguments)
+            for handler in self._pagination_change_handlers:
+                handle_event(handler, arguments)
         self.on('update:pagination', handle_pagination_change)
 
+    def on_select(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the selection changes."""
+        self._selection_handlers.append(callback)
+        return self
+
+    def on_pagination_change(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the pagination changes."""
+        self._pagination_change_handlers.append(callback)
+        return self
+
     @classmethod
     def from_pandas(cls,
                     df: 'pd.DataFrame',

+ 24 - 3
nicegui/elements/tree.py

@@ -46,6 +46,9 @@ class Tree(Element):
         self._props['ticked'] = []
         if tick_strategy is not None:
             self._props['tick-strategy'] = tick_strategy
+        self._select_handlers = [on_select] if on_select else []
+        self._expand_handlers = [on_expand] if on_expand else []
+        self._tick_handlers = [on_tick] if on_tick else []
 
         def update_prop(name: str, value: Any) -> None:
             if self._props[name] != value:
@@ -54,19 +57,37 @@ class Tree(Element):
 
         def handle_selected(e: GenericEventArguments) -> None:
             update_prop('selected', e.args)
-            handle_event(on_select, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
+            for handler in self._select_handlers:
+                handle_event(handler, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
         self.on('update:selected', handle_selected)
 
         def handle_expanded(e: GenericEventArguments) -> None:
             update_prop('expanded', e.args)
-            handle_event(on_expand, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
+            for handler in self._expand_handlers:
+                handle_event(handler, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
         self.on('update:expanded', handle_expanded)
 
         def handle_ticked(e: GenericEventArguments) -> None:
             update_prop('ticked', e.args)
-            handle_event(on_tick, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
+            for handler in self._tick_handlers:
+                handle_event(handler, ValueChangeEventArguments(sender=self, client=self.client, value=e.args))
         self.on('update:ticked', handle_ticked)
 
+    def on_select(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the selection changes."""
+        self._select_handlers.append(callback)
+        return self
+
+    def on_expand(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the expansion changes."""
+        self._expand_handlers.append(callback)
+        return self
+
+    def on_tick(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a node is ticked or unticked."""
+        self._tick_handlers.append(callback)
+        return self
+
     def expand(self, node_keys: Optional[List[str]] = None) -> Self:
         """Expand the given nodes.
 

+ 16 - 3
nicegui/elements/upload.py

@@ -2,6 +2,7 @@ from typing import Any, Callable, Dict, Optional
 
 from fastapi import Request
 from starlette.datastructures import UploadFile
+from typing_extensions import Self
 
 from ..events import UiEventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
@@ -48,6 +49,8 @@ class Upload(DisableableElement, component='upload.js'):
         if max_files is not None:
             self._props['max-files'] = max_files
 
+        self._upload_handlers = [on_upload] if on_upload else []
+
         @app.post(self._props['url'])
         async def upload_route(request: Request) -> Dict[str, str]:
             for data in (await request.form()).values():
@@ -59,12 +62,22 @@ class Upload(DisableableElement, component='upload.js'):
                     name=data.filename or '',
                     type=data.content_type or '',
                 )
-                handle_event(on_upload, args)
+                for handler in self._upload_handlers:
+                    handle_event(handler, args)
             return {'upload': 'success'}
 
         if on_rejected:
-            self.on('rejected', lambda _: handle_event(on_rejected, UiEventArguments(sender=self, client=self.client)),
-                    args=[])
+            self.on_rejected(on_rejected)
+
+    def on_upload(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a file is uploaded."""
+        self._upload_handlers.append(callback)
+        return self
+
+    def on_rejected(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when a file is rejected."""
+        self.on('rejected', lambda: handle_event(callback, UiEventArguments(sender=self, client=self.client)), args=[])
+        return self
 
     def reset(self) -> None:
         """Clear the upload queue."""