Repository: zauberzeug/nicegui
Pull request #4012 (2024-11-19 21:39:51)

JSON editor JSONSchema validation
Added JSONSchema validation to json_editor. 

New method `json_editor.add_validation(schema)` will enable validation on the json_editor component.

---

Feature request: #4003

falkoschindler: Thanks for the pull request, @jm-amt!
Can you give an example how to use it? It tried `ui.json_editor({'content': {'json': json}}, add_validation=schema)` with `json` and `schema` from https://json-schema.org/learn/getting-started-step-by-step, but there doesn't seem to be any validation happening.

Patch:
diff --git a/fetch_sponsors.py b/.github/workflows/fetch_sponsors.py
similarity index 100%
rename from fetch_sponsors.py
rename to .github/workflows/fetch_sponsors.py
diff --git a/CITATION.cff b/CITATION.cff
index 021c3360..3aa94bea 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.5.0
-date-released: '2024-10-25'
+version: 2.7.0
+date-released: '2024-11-22'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.13889918
+doi: 10.5281/zenodo.14206251
 identifiers:
   - description: DOI for this application's record on Zenodo
     type: doi
diff --git a/README.md b/README.md
index a843ac16..75aa07cc 100644
--- a/README.md
+++ b/README.md
@@ -116,6 +116,7 @@ If you would like to support this project and have your avatar or company logo a
   <a href="https://github.com/sereneturtlefox"><img src="https://github.com/sereneturtlefox.png" width="50px" alt="None" /></a>
   <a href="https://github.com/daviborges666"><img src="https://github.com/daviborges666.png" width="50px" alt="Davi Borges" /></a>
   <a href="https://github.com/whoulden"><img src="https://github.com/whoulden.png" width="50px" alt="Wayne Houlden" /></a>
+  <a href="https://github.com/digiquip"><img src="https://github.com/digiquip.png" width="50px" alt="DigiQuip AS" /></a>
 </p>
 <!-- SPONSORS -->
 
diff --git a/nicegui/app/app.py b/nicegui/app/app.py
index 3d8df3b5..a0d75c07 100644
--- a/nicegui/app/app.py
+++ b/nicegui/app/app.py
@@ -139,7 +139,8 @@ class App(FastAPI):
                          url_path: str,
                          local_directory: Union[str, Path],
                          *,
-                         follow_symlink: bool = False) -> None:
+                         follow_symlink: bool = False,
+                         max_cache_age: int = 3600) -> None:
         """Add a directory of static files.
 
         `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
@@ -153,11 +154,15 @@ class App(FastAPI):
         :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
         :param local_directory: local folder with files to serve as static content
         :param follow_symlink: whether to follow symlinks (default: False)
+        :param max_cache_age: value for max-age set in Cache-Control header
         """
         if url_path == '/':
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
+        if max_cache_age < 0:
+            raise ValueError('''Value of max_cache_age must be a positive integer or 0.''')
 
-        handler = CacheControlledStaticFiles(directory=local_directory, follow_symlink=follow_symlink)
+        handler = CacheControlledStaticFiles(
+            directory=local_directory, follow_symlink=follow_symlink, max_cache_age=max_cache_age)
 
         @self.get(url_path + '/{path:path}')
         async def static_file(request: Request, path: str = '') -> Response:
@@ -167,7 +172,7 @@ class App(FastAPI):
                         local_file: Union[str, Path],
                         url_path: Optional[str] = None,
                         single_use: bool = False,
-                        ) -> str:
+                        max_cache_age: int = 3600) -> str:
         """Add a single static file.
 
         Allows a local file to be accessed online with enabled caching.
@@ -179,8 +184,12 @@ class App(FastAPI):
         :param local_file: local file to serve as static content
         :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
         :param single_use: whether to remove the route after the file has been downloaded once (default: False)
+        :param max_cache_age: value for max-age set in Cache-Control header
         :return: encoded URL which can be used to access the file
         """
+        if max_cache_age < 0:
+            raise ValueError('''Value of max_cache_age must be a positive integer or 0.''')
+
         file = Path(local_file).resolve()
         if not file.is_file():
             raise ValueError(f'File not found: {file}')
@@ -190,7 +199,7 @@ class App(FastAPI):
         def read_item() -> FileResponse:
             if single_use:
                 self.remove_route(path)
-            return FileResponse(file, headers={'Cache-Control': 'public, max-age=3600'})
+            return FileResponse(file, headers={'Cache-Control': f'public, max-age={max_cache_age}'})
 
         return urllib.parse.quote(path)
 
diff --git a/nicegui/classes.py b/nicegui/classes.py
index 74f90c1e..062684b1 100644
--- a/nicegui/classes.py
+++ b/nicegui/classes.py
@@ -15,8 +15,9 @@ class Classes(list, Generic[T]):
     def __call__(self,
                  add: Optional[str] = None, *,
                  remove: Optional[str] = None,
+                 toggle: Optional[str] = None,
                  replace: Optional[str] = None) -> T:
-        """Apply, remove, or replace HTML classes.
+        """Apply, remove, toggle, or replace HTML classes.
 
         This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
 
@@ -24,9 +25,10 @@ class Classes(list, Generic[T]):
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        new_classes = self.update_list(self, add, remove, replace)
+        new_classes = self.update_list(self, add, remove, toggle, replace)
         if self != new_classes:
             self[:] = new_classes
             self.element.update()
@@ -36,10 +38,18 @@ class Classes(list, Generic[T]):
     def update_list(classes: List[str],
                     add: Optional[str] = None,
                     remove: Optional[str] = None,
+                    toggle: Optional[str] = None,
                     replace: Optional[str] = None) -> List[str]:
         """Update a list of classes."""
         class_list = classes if replace is None else []
         class_list = [c for c in class_list if c not in (remove or '').split()]
         class_list += (add or '').split()
         class_list += (replace or '').split()
-        return list(dict.fromkeys(class_list))  # NOTE: remove duplicates while preserving order
+        class_list = list(dict.fromkeys(class_list))  # NOTE: remove duplicates while preserving order
+        if toggle is not None:
+            for class_ in toggle.split():
+                if class_ in class_list:
+                    class_list.remove(class_)
+                else:
+                    class_list.append(class_)
+        return class_list
diff --git a/nicegui/element.py b/nicegui/element.py
index 29af8709..ffa8e13b 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -222,8 +222,9 @@ class Element(Visibility):
     def default_classes(cls,
                         add: Optional[str] = None, *,
                         remove: Optional[str] = None,
+                        toggle: Optional[str] = None,
                         replace: Optional[str] = None) -> type[Self]:
-        """Apply, remove, or replace default HTML classes.
+        """Apply, remove, toggle, or replace default HTML classes.
 
         This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
 
@@ -233,9 +234,10 @@ class Element(Visibility):
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        cls._default_classes = Classes.update_list(cls._default_classes, add, remove, replace)
+        cls._default_classes = Classes.update_list(cls._default_classes, add, remove, toggle, replace)
         return cls
 
     @property
diff --git a/nicegui/elements/input.py b/nicegui/elements/input.py
index 0105c0b0..ac487d3d 100644
--- a/nicegui/elements/input.py
+++ b/nicegui/elements/input.py
@@ -1,9 +1,9 @@
-from typing import Any, Callable, Dict, List, Optional, Union
+from typing import Any, List, Optional, Union
 
 from ..events import Handler, ValueChangeEventArguments
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
-from .mixins.validation_element import ValidationElement
+from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction
 
 
 class Input(ValidationElement, DisableableElement, component='input.js'):
@@ -18,7 +18,7 @@ class Input(ValidationElement, DisableableElement, component='input.js'):
                  password_toggle_button: bool = False,
                  on_change: Optional[Handler[ValueChangeEventArguments]] = None,
                  autocomplete: Optional[List[str]] = None,
-                 validation: Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]] = None,
+                 validation: Optional[Union[ValidationFunction, ValidationDict]] = None,
                  ) -> None:
         """Text Input
 
diff --git a/nicegui/elements/json_editor.js b/nicegui/elements/json_editor.js
index f6c0e6dc..efb3515e 100644
--- a/nicegui/elements/json_editor.js
+++ b/nicegui/elements/json_editor.js
@@ -1,4 +1,4 @@
-import { JSONEditor } from "standalone";
+import { JSONEditor, createAjvValidator } from "standalone";
 
 export default {
   template: "<div></div>",
@@ -9,6 +9,8 @@ export default {
     this.properties.onSelect = (selection) => {
       this.$emit("select", { selection: selection });
     };
+
+    this.checkValidation();
     this.editor = new JSONEditor({
       target: this.$el,
       props: this.properties,
@@ -21,8 +23,14 @@ export default {
     this.destroyEditor();
   },
   methods: {
+    checkValidation() {
+      if (this.schema !== undefined) {
+        this.properties.validator = createAjvValidator({ schema: this.schema, schemaDefinitions: {}, ajvOptions: {} });
+      }
+    },
     update_editor() {
       if (this.editor) {
+        this.checkValidation();
         this.editor.updateProps(this.properties);
       }
     },
@@ -43,5 +51,6 @@ export default {
   },
   props: {
     properties: Object,
+    schema: Object,
   },
 };
diff --git a/nicegui/elements/json_editor.py b/nicegui/elements/json_editor.py
index ed25927e..55373af3 100644
--- a/nicegui/elements/json_editor.py
+++ b/nicegui/elements/json_editor.py
@@ -19,6 +19,7 @@ class JsonEditor(Element, component='json_editor.js', dependencies=['lib/vanilla
                  properties: Dict, *,
                  on_select: Optional[Handler[JsonEditorSelectEventArguments]] = None,
                  on_change: Optional[Handler[JsonEditorChangeEventArguments]] = None,
+                 schema: Optional[Dict] = None,
                  ) -> None:
         """JSONEditor
 
@@ -29,10 +30,14 @@ class JsonEditor(Element, component='json_editor.js', dependencies=['lib/vanilla
         :param properties: dictionary of JSONEditor properties
         :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
+        :param schema: optional `JSON schema <https://json-schema.org/>`_ for validating the data being edited
         """
         super().__init__()
         self._props['properties'] = properties
 
+        if schema:
+            self._props['schema'] = schema
+
         if on_select:
             self.on_select(on_select)
 
diff --git a/nicegui/elements/mixins/validation_element.py b/nicegui/elements/mixins/validation_element.py
index 1f0e5ca1..18c6843e 100644
--- a/nicegui/elements/mixins/validation_element.py
+++ b/nicegui/elements/mixins/validation_element.py
@@ -1,13 +1,17 @@
-from typing import Any, Callable, Dict, Optional, Union
+from typing import Any, Awaitable, Callable, Dict, Optional, Union
 
 from typing_extensions import Self
 
+from ... import background_tasks, helpers
 from .value_element import ValueElement
 
+ValidationFunction = Callable[[Any], Union[Optional[str], Awaitable[Optional[str]]]]
+ValidationDict = Dict[str, Callable[[Any], bool]]
+
 
 class ValidationElement(ValueElement):
 
-    def __init__(self, validation: Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]], **kwargs: Any) -> None:
+    def __init__(self, validation: Optional[Union[ValidationFunction, ValidationDict]], **kwargs: Any) -> None:
         self._validation = validation
         self._auto_validation = True
         self._error: Optional[str] = None
@@ -15,18 +19,18 @@ class ValidationElement(ValueElement):
         self._props['error'] = None if validation is None else False  # NOTE: reserve bottom space for error message
 
     @property
-    def validation(self) -> Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]]:
+    def validation(self) -> Optional[Union[ValidationFunction, ValidationDict]]:
         """The validation function or dictionary of validation functions."""
         return self._validation
 
     @validation.setter
-    def validation(self, validation: Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]]) -> None:
+    def validation(self, validation: Optional[Union[ValidationFunction, ValidationDict]]) -> None:
         """Sets the validation function or dictionary of validation functions.
 
         :param validation: validation function or dictionary of validation functions (``None`` to disable validation)
         """
         self._validation = validation
-        self.validate()
+        self.validate(return_result=False)
 
     @property
     def error(self) -> Optional[str]:
@@ -47,13 +51,30 @@ class ValidationElement(ValueElement):
         self._props['error-message'] = error
         self.update()
 
-    def validate(self) -> bool:
+    def validate(self, *, return_result: bool = True) -> bool:
         """Validate the current value and set the error message if necessary.
 
-        :return: True if the value is valid, False otherwise
+        For async validation functions, ``return_result`` must be set to ``False`` and the return value will be ``True``,
+        independently of the validation result which is evaluated in the background.
+
+        :param return_result: whether to return the result of the validation (default: ``True``)
+        :return: whether the validation was successful (always ``True`` for async validation functions)
         """
+        if helpers.is_coroutine_function(self._validation):
+            async def await_error():
+                assert callable(self._validation)
+                result = self._validation(self.value)
+                assert isinstance(result, Awaitable)
+                self.error = await result
+            if return_result:
+                raise NotImplementedError('The validate method cannot return results for async validation functions.')
+            background_tasks.create(await_error())
+            return True
+
         if callable(self._validation):
-            self.error = self._validation(self.value)
+            result = self._validation(self.value)
+            assert not isinstance(result, Awaitable)
+            self.error = result
             return self.error is None
 
         if isinstance(self._validation, dict):
@@ -73,4 +94,4 @@ class ValidationElement(ValueElement):
     def _handle_value_change(self, value: Any) -> None:
         super()._handle_value_change(value)
         if self._auto_validation:
-            self.validate()
+            self.validate(return_result=False)
diff --git a/nicegui/elements/number.py b/nicegui/elements/number.py
index e81d09fa..39f3bccc 100644
--- a/nicegui/elements/number.py
+++ b/nicegui/elements/number.py
@@ -1,8 +1,8 @@
-from typing import Any, Callable, Dict, Optional, Union
+from typing import Any, Optional, Union
 
 from ..events import GenericEventArguments, Handler, ValueChangeEventArguments
 from .mixins.disableable_element import DisableableElement
-from .mixins.validation_element import ValidationElement
+from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction
 
 
 class Number(ValidationElement, DisableableElement):
@@ -20,14 +20,14 @@ class Number(ValidationElement, DisableableElement):
                  suffix: Optional[str] = None,
                  format: Optional[str] = None,  # pylint: disable=redefined-builtin
                  on_change: Optional[Handler[ValueChangeEventArguments]] = None,
-                 validation: Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]] = None,
+                 validation: Optional[Union[ValidationFunction, ValidationDict]] = None,
                  ) -> None:
         """Number Input
 
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
 
         You can use the `validation` parameter to define a dictionary of validation rules,
-        e.g. ``{'Too small!': lambda value: value < 3}``.
+        e.g. ``{'Too small!': lambda value: value > 3}``.
         The key of the first rule that fails will be displayed as an error message.
         Alternatively, you can pass a callable that returns an optional error message.
         To disable the automatic validation on every value change, you can use the `without_auto_validation` method.
diff --git a/nicegui/elements/query.py b/nicegui/elements/query.py
index 67d4a78b..7c603afd 100644
--- a/nicegui/elements/query.py
+++ b/nicegui/elements/query.py
@@ -37,9 +37,13 @@ class Query:
         else:
             self.element = QueryElement(selector)
 
-    def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
-            -> Self:
-        """Apply, remove, or replace HTML classes.
+    def classes(self,
+                add: Optional[str] = None, *,
+                remove: Optional[str] = None,
+                toggle: Optional[str] = None,
+                replace: Optional[str] = None,
+                ) -> Self:
+        """Apply, remove, toggle, or replace HTML classes.
 
         This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
 
@@ -47,9 +51,10 @@ class Query:
 
         :param add: whitespace-delimited string of classes
         :param remove: whitespace-delimited string of classes to remove from the element
+        :param toggle: whitespace-delimited string of classes to toggle
         :param replace: whitespace-delimited string of classes to use instead of existing ones
         """
-        classes = Classes.update_list(self.element.props['classes'], add, remove, replace)
+        classes = Classes.update_list(self.element.props['classes'], add, remove, toggle, replace)
         new_classes = [c for c in classes if c not in self.element.props['classes']]
         old_classes = [c for c in self.element.props['classes'] if c not in classes]
         if new_classes:
diff --git a/nicegui/elements/scene.js b/nicegui/elements/scene.js
index 659e9265..c72469a1 100644
--- a/nicegui/elements/scene.js
+++ b/nicegui/elements/scene.js
@@ -413,6 +413,22 @@ export default {
       const geometry = this.objects.get(object_id).geometry;
       set_point_cloud_data(position, color, geometry);
     },
+    attach(object_id, parent_id, x, y, z, R) {
+      if (!this.objects.has(object_id)) return;
+      const object = this.objects.get(object_id);
+      const parent = this.objects.get(parent_id);
+      parent.add(object);
+      this.move(object_id, x, y, z);
+      this.rotate(object_id, R);
+    },
+    detach(object_id, x, y, z, R) {
+      if (!this.objects.has(object_id)) return;
+      const object = this.objects.get(object_id);
+      object.removeFromParent();
+      this.scene.add(object);
+      this.move(object_id, x, y, z);
+      this.rotate(object_id, R);
+    },
     move_camera(x, y, z, look_at_x, look_at_y, look_at_z, up_x, up_y, up_z, duration) {
       if (this.camera_tween) this.camera_tween.stop();
       const camera_up_changed = up_x !== null || up_y !== null || up_z !== null;
diff --git a/nicegui/elements/scene_object3d.py b/nicegui/elements/scene_object3d.py
index 67a133ed..f30a608a 100644
--- a/nicegui/elements/scene_object3d.py
+++ b/nicegui/elements/scene_object3d.py
@@ -196,6 +196,138 @@ class Object3D:
             self._draggable()
         return self
 
+    def attach(self, parent: Object3D) -> None:
+        """Attach the object to a parent object.
+
+        The position and rotation of the object are preserved so that the object does not move in space.
+
+        But note that scaling is not preserved.
+        If either the parent or the object itself is scaled, the object shape and position can change.
+        """
+        self.detach()
+        self.parent = parent
+        self._move_into_parent(parent)
+        self.scene.run_method('attach', self.id, parent.id, self.x, self.y, self.z, self.R)
+
+    def _move_into_parent(self, parent: Union[Object3D, SceneObject]) -> None:
+        if not isinstance(parent, Object3D):
+            return
+        if isinstance(parent.parent, Object3D):
+            self._move_into_parent(parent.parent)
+        M1: List[List[float]] = [
+            [self.R[0][0], self.R[0][1], self.R[0][2], self.x],
+            [self.R[1][0], self.R[1][1], self.R[1][2], self.y],
+            [self.R[2][0], self.R[2][1], self.R[2][2], self.z],
+            [0, 0, 0, 1],
+        ]
+        M2_inv: List[List[float]] = [
+            [parent.R[0][0], parent.R[1][0], parent.R[2][0],
+             - parent.R[0][0] * parent.x
+             - parent.R[1][0] * parent.y
+             - parent.R[2][0] * parent.z],
+            [parent.R[0][1], parent.R[1][1], parent.R[2][1],
+             - parent.R[0][1] * parent.x
+             - parent.R[1][1] * parent.y
+             - parent.R[2][1] * parent.z],
+            [parent.R[0][2], parent.R[1][2], parent.R[2][2],
+             - parent.R[0][2] * parent.x
+             - parent.R[1][2] * parent.y
+             - parent.R[2][2] * parent.z],
+            [0, 0, 0, 1],
+        ]
+        M: List[List[float]] = [
+            [
+                M2_inv[0][0] * M1[0][0] + M2_inv[0][1] * M1[1][0] + M2_inv[0][2] * M1[2][0],
+                M2_inv[0][0] * M1[0][1] + M2_inv[0][1] * M1[1][1] + M2_inv[0][2] * M1[2][1],
+                M2_inv[0][0] * M1[0][2] + M2_inv[0][1] * M1[1][2] + M2_inv[0][2] * M1[2][2],
+                M2_inv[0][0] * M1[0][3] + M2_inv[0][1] * M1[1][3] + M2_inv[0][2] * M1[2][3] + M2_inv[0][3],
+            ],
+            [
+                M2_inv[1][0] * M1[0][0] + M2_inv[1][1] * M1[1][0] + M2_inv[1][2] * M1[2][0],
+                M2_inv[1][0] * M1[0][1] + M2_inv[1][1] * M1[1][1] + M2_inv[1][2] * M1[2][1],
+                M2_inv[1][0] * M1[0][2] + M2_inv[1][1] * M1[1][2] + M2_inv[1][2] * M1[2][2],
+                M2_inv[1][0] * M1[0][3] + M2_inv[1][1] * M1[1][3] + M2_inv[1][2] * M1[2][3] + M2_inv[1][3],
+            ],
+            [
+                M2_inv[2][0] * M1[0][0] + M2_inv[2][1] * M1[1][0] + M2_inv[2][2] * M1[2][0],
+                M2_inv[2][0] * M1[0][1] + M2_inv[2][1] * M1[1][1] + M2_inv[2][2] * M1[2][1],
+                M2_inv[2][0] * M1[0][2] + M2_inv[2][1] * M1[1][2] + M2_inv[2][2] * M1[2][2],
+                M2_inv[2][0] * M1[0][3] + M2_inv[2][1] * M1[1][3] + M2_inv[2][2] * M1[2][3] + M2_inv[2][3],
+            ],
+            [
+                0, 0, 0, 1,
+            ],
+        ]
+        self.x = M[0][3]
+        self.y = M[1][3]
+        self.z = M[2][3]
+        self.R = [
+            [M[0][0], M[0][1], M[0][2]],
+            [M[1][0], M[1][1], M[1][2]],
+            [M[2][0], M[2][1], M[2][2]],
+        ]
+
+    def detach(self) -> None:
+        """Remove the object from its parent group object.
+
+        The position and rotation of the object are preserved so that the object does not move in space.
+
+        But note that scaling is not preserved.
+        If either the parent or the object itself is scaled, the object shape and position can change.
+        """
+        self._move_out_of_parent(self.parent)
+        self.parent = self.scene.stack[0]
+        self.scene.run_method('detach', self.id, self.x, self.y, self.z, self.R)
+
+    def _move_out_of_parent(self, parent: Union[Object3D, SceneObject]) -> None:
+        if not isinstance(parent, Object3D):
+            return
+        M1: List[List[float]] = [
+            [self.R[0][0], self.R[0][1], self.R[0][2], self.x],
+            [self.R[1][0], self.R[1][1], self.R[1][2], self.y],
+            [self.R[2][0], self.R[2][1], self.R[2][2], self.z],
+            [0, 0, 0, 1],
+        ]
+        M2: List[List[float]] = [
+            [parent.R[0][0], parent.R[0][1], parent.R[0][2], parent.x],
+            [parent.R[1][0], parent.R[1][1], parent.R[1][2], parent.y],
+            [parent.R[2][0], parent.R[2][1], parent.R[2][2], parent.z],
+            [0, 0, 0, 1],
+        ]
+        M: List[List[float]] = [
+            [
+                M2[0][0] * M1[0][0] + M2[0][1] * M1[1][0] + M2[0][2] * M1[2][0],
+                M2[0][0] * M1[0][1] + M2[0][1] * M1[1][1] + M2[0][2] * M1[2][1],
+                M2[0][0] * M1[0][2] + M2[0][1] * M1[1][2] + M2[0][2] * M1[2][2],
+                M2[0][0] * M1[0][3] + M2[0][1] * M1[1][3] + M2[0][2] * M1[2][3] + M2[0][3],
+            ],
+            [
+                M2[1][0] * M1[0][0] + M2[1][1] * M1[1][0] + M2[1][2] * M1[2][0],
+                M2[1][0] * M1[0][1] + M2[1][1] * M1[1][1] + M2[1][2] * M1[2][1],
+                M2[1][0] * M1[0][2] + M2[1][1] * M1[1][2] + M2[1][2] * M1[2][2],
+                M2[1][0] * M1[0][3] + M2[1][1] * M1[1][3] + M2[1][2] * M1[2][3] + M2[1][3],
+            ],
+            [
+                M2[2][0] * M1[0][0] + M2[2][1] * M1[1][0] + M2[2][2] * M1[2][0],
+                M2[2][0] * M1[0][1] + M2[2][1] * M1[1][1] + M2[2][2] * M1[2][1],
+                M2[2][0] * M1[0][2] + M2[2][1] * M1[1][2] + M2[2][2] * M1[2][2],
+                M2[2][0] * M1[0][3] + M2[2][1] * M1[1][3] + M2[2][2] * M1[2][3] + M2[2][3],
+            ],
+            [
+                0, 0, 0, 1,
+            ],
+        ]
+        self.x = M[0][3]
+        self.y = M[1][3]
+        self.z = M[2][3]
+        self.R = [
+            [M[0][0], M[0][1], M[0][2]],
+            [M[1][0], M[1][1], M[1][2]],
+            [M[2][0], M[2][1], M[2][2]],
+        ]
+        if isinstance(parent.parent, Object3D):
+            self._move_out_of_parent(parent.parent)
+
     @property
     def children(self) -> List[Object3D]:
         """List of children of the object."""
diff --git a/nicegui/elements/select.py b/nicegui/elements/select.py
index b5266f93..c008fe77 100644
--- a/nicegui/elements/select.py
+++ b/nicegui/elements/select.py
@@ -5,7 +5,7 @@ from typing import Any, Callable, Dict, Iterator, List, Literal, Optional, Union
 from ..events import GenericEventArguments, Handler, ValueChangeEventArguments
 from .choice_element import ChoiceElement
 from .mixins.disableable_element import DisableableElement
-from .mixins.validation_element import ValidationElement
+from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction
 
 
 class Select(ValidationElement, ChoiceElement, DisableableElement, component='select.js'):
@@ -19,7 +19,7 @@ class Select(ValidationElement, ChoiceElement, DisableableElement, component='se
                  new_value_mode: Optional[Literal['add', 'add-unique', 'toggle']] = None,
                  multiple: bool = False,
                  clearable: bool = False,
-                 validation: Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]] = None,
+                 validation: Optional[Union[ValidationFunction, ValidationDict]] = None,
                  key_generator: Optional[Union[Callable[[Any], Any], Iterator[Any]]] = None,
                  ) -> None:
         """Dropdown Selection
diff --git a/nicegui/page.py b/nicegui/page.py
index 7f692d40..f6493ba8 100644
--- a/nicegui/page.py
+++ b/nicegui/page.py
@@ -40,9 +40,14 @@ class page:
         This means it is private to the user and not shared with others
         (as it is done `when placing elements outside of a page decorator <https://nicegui.io/documentation/section_pages_routing#auto-index_page>`_).
 
-        Note:
-        The name of the decorated function is unused and can be anything.
-        The page route is determined by the `path` argument and registered globally.
+        Notes:
+
+        - The name of the decorated function is unused and can be anything.
+        - The page route is determined by the `path` argument and registered globally.
+        - The decorator does only work for free functions and static methods.
+          Instance methods or initializers would require a `self` argument, which the router cannot associate.
+          See `our modularization example <https://github.com/zauberzeug/nicegui/tree/main/examples/modularization/>`_
+          for strategies to structure your code.
 
         :param path: route of the new page (path must start with '/')
         :param title: optional page title
diff --git a/nicegui/staticfiles.py b/nicegui/staticfiles.py
index 731beaba..7713af38 100644
--- a/nicegui/staticfiles.py
+++ b/nicegui/staticfiles.py
@@ -5,7 +5,11 @@ from starlette.types import Scope
 
 class CacheControlledStaticFiles(StaticFiles):
 
+    def __init__(self, *args, max_cache_age: int = 3600, **kwargs) -> None:
+        self.max_cache_age = max_cache_age
+        super().__init__(*args, **kwargs)
+
     async def get_response(self, path: str, scope: Scope) -> Response:
         response = await super().get_response(path, scope)
-        response.headers['Cache-Control'] = 'public, max-age=3600'
+        response.headers['Cache-Control'] = f'public, max-age={self.max_cache_age}'
         return response
diff --git a/poetry.lock b/poetry.lock
index 9122eb98..14a9964d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -3161,29 +3161,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 [[package]]
 name = "ruff"
-version = "0.7.4"
+version = "0.8.0"
 description = "An extremely fast Python linter and code formatter, written in Rust."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478"},
-    {file = "ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63"},
-    {file = "ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06"},
-    {file = "ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc"},
-    {file = "ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172"},
-    {file = "ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a"},
-    {file = "ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd"},
-    {file = "ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a"},
-    {file = "ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac"},
-    {file = "ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6"},
-    {file = "ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f"},
-    {file = "ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2"},
+    {file = "ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"},
+    {file = "ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b"},
+    {file = "ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3"},
+    {file = "ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c"},
+    {file = "ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2"},
+    {file = "ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70"},
+    {file = "ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd"},
+    {file = "ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426"},
+    {file = "ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468"},
+    {file = "ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f"},
+    {file = "ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6"},
+    {file = "ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44"},
 ]
 
 [[package]]
@@ -3199,13 +3199,13 @@ files = [
 
 [[package]]
 name = "selenium"
-version = "4.26.1"
+version = "4.27.0"
 description = "Official Python bindings for Selenium WebDriver"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "selenium-4.26.1-py3-none-any.whl", hash = "sha256:1db3f3a0cd5bb07624fa8a3905a6fdde1595a42185a0617077c361dc53d104fb"},
-    {file = "selenium-4.26.1.tar.gz", hash = "sha256:7640f3f08ae7f4e450f895678e8a10a55eb4e4ca18311ed675ecc4684b96b683"},
+    {file = "selenium-4.27.0-py3-none-any.whl", hash = "sha256:70ff70cc8d08f19bda0e89d6a521c0f1c3ae648088c34dcf9fa177f1374c3b5f"},
+    {file = "selenium-4.27.0.tar.gz", hash = "sha256:e8850834c482dc93d92060586139cf8ff8be6c6f0e93db640fa65358548f426b"},
 ]
 
 [package.dependencies]
diff --git a/pyproject.toml b/pyproject.toml
index 5e46396a..3f7c7d0e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "2.5.0-dev"
+version = "2.7.0-dev"
 description = "Create web-based user interfaces with Python. The nice way."
 authors = ["Zauberzeug GmbH <info@zauberzeug.com>"]
 license = "MIT"
@@ -81,7 +81,7 @@ nicegui-pack = "nicegui.scripts.pack:main"
 
 [build-system]
 requires = [
-    "setuptools>=30.3.0,<50",
+    "setuptools>=30.3.0",
     "poetry-core>=1.0.0"
 ]
 build-backend = "poetry.core.masonry.api"
diff --git a/website/documentation/content/html_documentation.py b/website/documentation/content/html_documentation.py
index 9047724f..cd1bf4af 100644
--- a/website/documentation/content/html_documentation.py
+++ b/website/documentation/content/html_documentation.py
@@ -15,4 +15,25 @@ def demo_inline() -> None:
     ui.html('This is <u>emphasized</u>.', tag='em')
 
 
+@doc.demo('Other HTML Elements', '''
+    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.
+
+    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.
+''')
+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.reference(ui.html)
diff --git a/website/documentation/content/input_documentation.py b/website/documentation/content/input_documentation.py
index b16cedf0..ad2377a7 100644
--- a/website/documentation/content/input_documentation.py
+++ b/website/documentation/content/input_documentation.py
@@ -47,6 +47,13 @@ def styling():
 
     - by passing a callable that returns an error message or `None`, or
     - by passing a dictionary that maps error messages to callables that return `True` if the input is valid.
+
+    The callable validation function can also be an async coroutine.
+    In this case, the validation is performed asynchronously in the background.
+
+    You can use the `validate` method of the input element to trigger the validation manually.
+    It returns `True` if the input is valid, and an error message otherwise.
+    For async validation functions, the return value must be explicitly disabled by setting `return_result=False`.
 ''')
 def validation():
     ui.input('Name', validation=lambda value: 'Too short' if len(value) < 5 else None)
diff --git a/website/documentation/content/json_editor_documentation.py b/website/documentation/content/json_editor_documentation.py
index 4cb62848..0fcb6cf0 100644
--- a/website/documentation/content/json_editor_documentation.py
+++ b/website/documentation/content/json_editor_documentation.py
@@ -23,6 +23,39 @@ def main_demo() -> None:
                    on_change=lambda e: ui.notify(f'Change: {e}'))
 
 
+@doc.demo('Validation', '''
+    You can use the `schema` parameter to define a [JSON schema](https://json-schema.org/) for validating the data being edited.
+    In this demo, the editor will warn if the data does not match the schema:
+
+    - `id` must be an integer
+    - `name` must be a string
+    - `price` must be a number greater than 0
+''')
+def schema_demo() -> None:
+    schema = {
+        'type': 'object',
+        'properties': {
+            'id': {
+                'type': 'integer',
+            },
+            'name': {
+                'type': 'string',
+            },
+            'price': {
+                'type': 'number',
+                'exclusiveMinimum': 0,
+            },
+        },
+        'required': ['id', 'name', 'price'],
+    }
+    data = {
+        'id': 42,
+        'name': 'Banana',
+        'price': 15.0,
+    }
+    ui.json_editor({'content': {'json': data}}, schema=schema)
+
+
 @doc.demo('Run methods', '''
     You can run methods of the JSONEditor instance using the `run_editor_method` method.
     This demo shows how to expand and collapse all nodes and how to get the current data.
diff --git a/website/documentation/content/scene_documentation.py b/website/documentation/content/scene_documentation.py
index 8634c10e..744be6ca 100644
--- a/website/documentation/content/scene_documentation.py
+++ b/website/documentation/content/scene_documentation.py
@@ -268,4 +268,25 @@ def custom_composed_objects() -> None:
         CoordinateSystem('custom frame').move(-2, -2, 1).rotate(0.1, 0.2, 0.3)
 
 
+@doc.demo('Attaching/detaching objects', '''
+    To add or remove objects from groups you can use the `attach` and `detach` methods.
+    The position and rotation of the object are preserved so that the object does not move in space.
+    But note that scaling is not preserved.
+    If either the parent or the object itself is scaled, the object shape and position can change.
+''')
+def attach_detach() -> None:
+    import math
+    import time
+
+    with ui.scene().classes('w-full h-64') as scene:
+        with scene.group() as group:
+            a = scene.box().move(-2)
+            b = scene.box().move(0)
+            c = scene.box().move(2)
+
+    ui.timer(0.1, lambda: group.move(y=math.sin(time.time())).rotate(0, 0, time.time()))
+    ui.button('Detach', on_click=a.detach)
+    ui.button('Attach', on_click=lambda: a.attach(group))
+
+
 doc.reference(ui.scene)
diff --git a/website/documentation/content/user_documentation.py b/website/documentation/content/user_documentation.py
index 00da5e45..991fc496 100644
--- a/website/documentation/content/user_documentation.py
+++ b/website/documentation/content/user_documentation.py
@@ -156,6 +156,32 @@ def upload_table():
             ''')
 
 
+doc.text('Autocomplete', '''
+    The `UserInteraction` object returned by `user.find(...)` provides methods to trigger events on the found elements.
+    This demo shows how to trigger a "keydown.tab" event to autocomplete an input field.
+''')
+
+
+@doc.ui
+def trigger_events():
+    with ui.row().classes('gap-4 items-stretch'):
+        with python_window(classes='w-[500px]', title='some UI code'):
+            ui.markdown('''
+                ```python
+                fruits = ['apple', 'banana', 'cherry']
+                ui.input(label='fruit', autocomplete=fruits)
+                ```
+            ''')
+        with python_window(classes='w-[500px]', title='user assertions'):
+            ui.markdown('''
+                ```python
+                await user.open('/')
+                user.find('fruit').type('a').trigger('keydown.tab')
+                await user.should_see('apple')
+                ```
+            ''')
+
+
 doc.text('Test Downloads', '''
     You can verify that a download was triggered by checking `user.downloads.http_responses`.
     By awaiting `user.downloads.next()` you can get the next download response.
diff --git a/website/sponsors.json b/website/sponsors.json
index 1af209ce..1454d751 100644
--- a/website/sponsors.json
+++ b/website/sponsors.json
@@ -2,6 +2,6 @@
   "top": [
     "daviborges666"
   ],
-  "total": 21,
-  "contributors": 125
+  "total": 25,
+  "contributors": 129
 }


Test patch:
diff --git a/nicegui/testing/user_interaction.py b/nicegui/testing/user_interaction.py
index 2801cd7e..469011e3 100644
--- a/nicegui/testing/user_interaction.py
+++ b/nicegui/testing/user_interaction.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Generic, Set, Type, TypeVar, Union
+from typing import TYPE_CHECKING, Generic, List, Set, Type, TypeVar, Union
 
 from typing_extensions import Self
 
@@ -36,6 +36,13 @@ class UserInteraction(Generic[T]):
         assert self.user.client
         with self.user.client:
             for element in self.elements:
+                if isinstance(element, ui.input) and event == 'keydown.tab':
+                    autocomplete: List[str] = element.props['_autocomplete']
+                    for option in autocomplete:
+                        if option.startswith(element.value):
+                            element.value = option
+                            break
+
                 for listener in element._event_listeners.values():  # pylint: disable=protected-access
                     if listener.type != event:
                         continue
diff --git a/tests/test_binding.py b/tests/test_binding.py
index 5df28117..0aa015db 100644
--- a/tests/test_binding.py
+++ b/tests/test_binding.py
@@ -74,6 +74,7 @@ def test_binding_to_input(screen: Screen):
     screen.type(Keys.TAB)
     screen.type('two')
     screen.should_contain_input('two')
+    screen.wait(0.1)
     assert data.text == 'two'
     data.text = 'three'
     screen.should_contain_input('three')
diff --git a/tests/test_element.py b/tests/test_element.py
index 66cce207..e7592e0a 100644
--- a/tests/test_element.py
+++ b/tests/test_element.py
@@ -35,6 +35,12 @@ def test_classes(screen: Screen):
     label.classes(replace='four')
     assert_classes('four')
 
+    label.classes(toggle='bg-red-500')
+    assert_classes('four bg-red-500')
+
+    label.classes(toggle='bg-red-500')
+    assert_classes('four')
+
 
 @pytest.mark.parametrize('value,expected', [
     (None, {}),
diff --git a/tests/test_input.py b/tests/test_input.py
index c54db2d3..94cabbc9 100644
--- a/tests/test_input.py
+++ b/tests/test_input.py
@@ -1,3 +1,6 @@
+import asyncio
+from typing import Literal, Optional
+
 import pytest
 from selenium.webdriver.common.by import By
 from selenium.webdriver.common.keys import Keys
@@ -54,12 +57,25 @@ def test_toggle_button(screen: Screen):
     assert element.get_attribute('type') == 'password'
 
 
-@pytest.mark.parametrize('use_callable', [False, True])
-def test_input_validation(use_callable: bool, screen: Screen):
-    if use_callable:
+@pytest.mark.parametrize('method', ['dict', 'sync', 'async'])
+def test_input_validation(method: Literal['dict', 'sync', 'async'], screen: Screen):
+    if method == 'sync':
         input_ = ui.input('Name', validation=lambda x: 'Short' if len(x) < 3 else 'Still short' if len(x) < 5 else None)
-    else:
+    elif method == 'dict':
         input_ = ui.input('Name', validation={'Short': lambda x: len(x) >= 3, 'Still short': lambda x: len(x) >= 5})
+    else:
+        async def validate(x: str) -> Optional[str]:
+            await asyncio.sleep(0.1)
+            return 'Short' if len(x) < 3 else 'Still short' if len(x) < 5 else None
+        input_ = ui.input('Name', validation=validate)
+
+    def assert_validation(expected: bool):
+        if method == 'async':
+            with pytest.raises(NotImplementedError):
+                input_.validate()
+            assert input_.validate(return_result=False)
+        else:
+            assert input_.validate() == expected
 
     screen.open('/')
     screen.should_contain('Name')
@@ -68,19 +84,19 @@ def test_input_validation(use_callable: bool, screen: Screen):
     element.send_keys('Jo')
     screen.should_contain('Short')
     assert input_.error == 'Short'
-    assert not input_.validate()
+    assert_validation(False)
 
     element.send_keys('hn')
     screen.should_contain('Still short')
     assert input_.error == 'Still short'
-    assert not input_.validate()
+    assert_validation(False)
 
     element.send_keys(' Doe')
     screen.wait(1.0)
     screen.should_not_contain('Short')
     screen.should_not_contain('Still short')
     assert input_.error is None
-    assert input_.validate()
+    assert_validation(True)
 
 
 def test_input_with_multi_word_error_message(screen: Screen):
diff --git a/tests/test_json_editor.py b/tests/test_json_editor.py
index 1be6f538..b3d07500 100644
--- a/tests/test_json_editor.py
+++ b/tests/test_json_editor.py
@@ -19,3 +19,10 @@ def test_json_editor_methods(screen: Screen):
 
     screen.click('Get Data')
     screen.should_contain("Data: {'json': {'a': 1, 'b': 2}}")
+
+
+def test_json_editor_validation(screen: Screen):
+    ui.json_editor({'content': {'json': {'x': 0}}}, schema={'type': 'object', 'properties': {'x': {'type': 'string'}}})
+
+    screen.open('/')
+    screen.should_contain('must be string')
diff --git a/tests/test_user_simulation.py b/tests/test_user_simulation.py
index e79569a0..5fbc30a8 100644
--- a/tests/test_user_simulation.py
+++ b/tests/test_user_simulation.py
@@ -410,3 +410,12 @@ async def test_validation(user: User) -> None:
     await user.should_not_see('Not a number')
     user.find(ui.input).type('some invalid entry')
     await user.should_see('Not a number')
+
+
+async def test_trigger_autocomplete(user: User) -> None:
+    ui.input(label='fruit', autocomplete=['apple', 'banana', 'cherry'])
+
+    await user.open('/')
+    await user.should_not_see('apple')
+    user.find('fruit').type('a').trigger('keydown.tab')
+    await user.should_see('apple')