瀏覽代碼

feat: generalize js_handler (#4689)

Closes: #4611

Follow up of PR #4618.

## Context

An insight emerge from the discussions in #4618, that the `js_handler`
can be used to override the default event handler, and make much more
customizations possible.

## Design

This PR allows setting `js_handler` and `handler` at the same time, when
that happens, in `js_handler` you can call `defaultHandler` to recover
the default behaviour which emits the event to server side handler.

```javascript
let defaultHandler = function(args: object)
```

And now you have much more freedom to customize the actual event args
~~, or to override the options~~ .

## Use Cases

* delegated event handling, unify children events handling on parent
element:

  ```python
  data = [f"Item {i}" for i in range(10)]
snippet = "".join(f"<li data-index={i}>{item}</li>" for i, item in
enumerate(data))
  with ui.html(snippet, tag="ul") as c:
      c.on(
          "click",
          lambda e: ui.notify(f'Clicked on {e.args["index"]}'),
          js_handler="(e) => defaultHandler(e.target.dataset)",
      )
  ```

* [other use
cases](https://github.com/zauberzeug/nicegui/pull/4618#issuecomment-2845918657)

## Potential Next Step

Since we can even pass the
`args`/`throttle`/`leading_events`/`trailing_events` through `js_handle`
too, maybe we can even deprecate those python fields to make the api
more compact.

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
yihuang 6 天之前
父節點
當前提交
bd5fb00e38
共有 4 個文件被更改,包括 95 次插入45 次删除
  1. 28 28
      nicegui/element.py
  2. 19 17
      nicegui/static/nicegui.js
  3. 15 0
      tests/test_events.py
  4. 33 0
      website/documentation/content/element_documentation.py

+ 28 - 28
nicegui/element.py

@@ -4,7 +4,7 @@ import inspect
 import re
 from copy import copy
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, ClassVar, Dict, Iterator, List, Optional, Sequence, Union, cast, overload
+from typing import TYPE_CHECKING, Any, ClassVar, Dict, Iterator, List, Optional, Sequence, Union, cast
 
 from typing_extensions import Self
 
@@ -325,15 +325,6 @@ class Element(Visibility):
             Tooltip(text)
         return self
 
-    @overload
-    def on(self,
-           type: str,  # pylint: disable=redefined-builtin
-           *,
-           js_handler: Optional[str] = None,
-           ) -> Self:
-        ...
-
-    @overload
     def on(self,
            type: str,  # pylint: disable=redefined-builtin
            handler: Optional[events.Handler[events.GenericEventArguments]] = None,
@@ -342,31 +333,40 @@ class Element(Visibility):
            throttle: float = 0.0,
            leading_events: bool = True,
            trailing_events: bool = True,
-           ) -> Self:
-        ...
-
-    def on(self,
-           type: str,  # pylint: disable=redefined-builtin
-           handler: Optional[events.Handler[events.GenericEventArguments]] = None,
-           args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None,
-           *,
-           throttle: float = 0.0,
-           leading_events: bool = True,
-           trailing_events: bool = True,
-           js_handler: Optional[str] = None,
+           js_handler: Optional[str] = '(...args) => emit(...args)',  # DEPRECATED: None will be removed in version 3.0
            ) -> Self:
         """Subscribe to an event.
 
+        The event handler can be a Python function, a JavaScript function or a combination of both:
+
+        - If you want to handle the event on the server with all (serializable) event arguments,
+          use a Python ``handler``.
+        - If you want to handle the event on the client side without emitting anything to the server,
+          use ``js_handler`` with a JavaScript function handling the event.
+        - If you want to handle the event on the server with a subset or transformed version of the event arguments,
+          use ``js_handler`` with a JavaScript function emitting the transformed arguments using ``emit()``, and
+          use a Python ``handler`` to handle these arguments on the server side.
+          The ``js_handler`` can also decide to selectively emit arguments to the server,
+          in which case the Python ``handler`` will not always be called.
+
+        Note that the arguments ``throttle``, ``leading_events``, and ``trailing_events`` are only relevant
+        when emitting events to the server.
+
+        *Updated in version 2.18.0: Both handlers can be specified at the same time.*
+
         :param type: name of the event (e.g. "click", "mousedown", or "update:model-value")
         :param handler: callback that is called upon occurrence of the event
-        :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
+        :param args: arguments included in the event message sent to the event handler (default: ``None`` meaning all)
         :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
-        :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`)
-        :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
-        :param js_handler: JavaScript code that is executed upon occurrence of the event, e.g. `(evt) => alert(evt)` (default: `None`)
+        :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: ``True``)
+        :param trailing_events: whether to trigger the event handler after the last event occurrence (default: ``True`)
+        :param js_handler: JavaScript function that is handling the event on the client (default: "(...args) => emit(...args)")
         """
-        if handler and js_handler:
-            raise ValueError('Either handler or js_handler can be specified, but not both')
+        if js_handler is None:
+            helpers.warn_once('Passing `js_handler=None` to `on()` is deprecated. '
+                              'Use the default "(...args) => emit(...args)" instead or remove the parameter.')
+        if js_handler == '(...args) => emit(...args)':
+            js_handler = None
 
         if handler or js_handler:
             listener = EventListener(

+ 19 - 17
nicegui/static/nicegui.js

@@ -177,27 +177,29 @@ function renderRecursively(elements, id) {
     let event_name = "on" + event.type[0].toLocaleUpperCase() + event.type.substring(1);
     event.specials.forEach((s) => (event_name += s[0].toLocaleUpperCase() + s.substring(1)));
 
+    const emit = (...args) => {
+      const emitter = () =>
+        window.socket?.emit("event", {
+          id: id,
+          client_id: window.clientId,
+          listener_id: event.listener_id,
+          args: stringifyEventArgs(args, event.args),
+        });
+      const delayed_emitter = () => {
+        if (window.did_handshake) emitter();
+        else setTimeout(delayed_emitter, 10);
+      };
+      throttle(delayed_emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
+      if (element.props["loopback"] === False && event.type == "update:modelValue") {
+        element.props["model-value"] = args;
+      }
+    };
+
     let handler;
     if (event.js_handler) {
       handler = eval(event.js_handler);
     } else {
-      handler = (...args) => {
-        const emitter = () =>
-          window.socket?.emit("event", {
-            id: id,
-            client_id: window.clientId,
-            listener_id: event.listener_id,
-            args: stringifyEventArgs(args, event.args),
-          });
-        const delayed_emitter = () => {
-          if (window.did_handshake) emitter();
-          else setTimeout(delayed_emitter, 10);
-        };
-        throttle(delayed_emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
-        if (element.props["loopback"] === False && event.type == "update:modelValue") {
-          element.props["model-value"] = args;
-        }
-      };
+      handler = emit;
     }
 
     handler = Vue.withModifiers(handler, event.modifiers);

+ 15 - 0
tests/test_events.py

@@ -191,3 +191,18 @@ def test_js_handler(screen: Screen) -> None:
     screen.open('/')
     screen.click('Button')
     screen.should_contain('Click!')
+
+
+def test_delegated_event_with_argument_filtering(screen: Screen) -> None:
+    ids = []
+    ui.html('''
+        <p data-id="A">Item A</p>
+        <p data-id="B">Item B</p>
+        <p data-id="C">Item C</p>
+    ''').on('click', lambda e: ids.append(e.args), js_handler='(e) => emit(e.target.dataset.id)')
+
+    screen.open('/')
+    screen.click('Item A')
+    screen.click('Item B')
+    screen.click('Item C')
+    assert ids == ['A', 'B', 'C']

+ 33 - 0
website/documentation/content/element_documentation.py

@@ -9,6 +9,39 @@ def main_demo() -> None:
         ui.label('inside a colored div')
 
 
+@doc.demo('Register event handlers', '''
+    The event handler can be a Python function, a JavaScript function or a combination of both:
+
+    - If you want to handle the event on the server with all (serializable) event arguments,
+        use a Python ``handler``.
+
+    - If you want to handle the event on the client side without emitting anything to the server,
+        use ``js_handler`` with a JavaScript function handling the event.
+
+    - If you want to handle the event on the server with a subset or transformed version of the event arguments,
+        use ``js_handler`` with a JavaScript function emitting the transformed arguments using ``emit()``, and
+        use a Python ``handler`` to handle these arguments on the server side.
+
+        The ``js_handler`` can also decide to selectively emit arguments to the server,
+        in which case the Python ``handler`` will not always be called.
+
+    *Updated in version 2.18.0: Both handlers can be specified at the same time.*
+''')
+def register_event_handlers() -> None:
+    ui.button('Python handler') \
+        .on('click',
+            lambda e: ui.notify(f'click: ({e.args["clientX"]}, {e.args["clientY"]})'))
+
+    ui.button('JavaScript handler') \
+        .on('click',
+            js_handler='(e) => alert(`click: (${e.clientX}, ${e.clientY})`)')
+
+    ui.button('Combination') \
+        .on('click',
+            lambda e: ui.notify(f'click: {e.args}'),
+            js_handler='(e) => emit(e.clientX, e.clientY)')
+
+
 @doc.demo('Move elements', '''
     This demo shows how to move elements between or within containers.
 ''')