1
0
Эх сурвалжийг харах

Merge commit '649d6f82da2df0bfb631e76b896a384e3ebae656' into vue_developer_mode

# Conflicts:
#	nicegui/run.py
Falko Schindler 1 жил өмнө
parent
commit
245860f437
44 өөрчлөгдсөн 386 нэмэгдсэн , 104 устгасан
  1. 1 1
      .github/workflows/test.yml
  2. 0 1
      .vscode/settings.json
  3. 3 3
      CITATION.cff
  4. 2 2
      deploy.sh
  5. 27 0
      examples/download_text_as_file/main.py
  6. 11 0
      fly.dockerfile
  7. 3 7
      main.py
  8. 3 1
      nicegui/binding.py
  9. 10 3
      nicegui/client.py
  10. 5 6
      nicegui/element.py
  11. 1 1
      nicegui/elements/icon.py
  12. 12 2
      nicegui/elements/plotly.py
  13. 11 2
      nicegui/elements/pyplot.py
  14. 1 1
      nicegui/elements/query.js
  15. 29 14
      nicegui/elements/scene.js
  16. 3 3
      nicegui/functions/open.py
  17. 5 1
      nicegui/functions/refreshable.py
  18. 3 1
      nicegui/globals.py
  19. 1 1
      nicegui/nicegui.py
  20. 3 0
      nicegui/page.py
  21. 9 0
      nicegui/run.py
  22. 13 4
      nicegui/templates/index.html
  23. 6 27
      nicegui/ui.py
  24. 7 2
      tests/conftest.py
  25. 3 5
      tests/screen.py
  26. 2 2
      tests/test_download.py
  27. 16 0
      tests/test_element.py
  28. 41 0
      tests/test_endpoint_docs.py
  29. 2 2
      tests/test_favicon.py
  30. 18 0
      tests/test_open.py
  31. 15 0
      tests/test_page.py
  32. 8 0
      tests/test_query.py
  33. 20 0
      tests/test_refreshable.py
  34. 3 3
      tests/test_serving_files.py
  35. 2 2
      tests/test_storage.py
  36. 1 1
      website/documentation.py
  37. 17 0
      website/more_documentation/aggrid_documentation.py
  38. 2 2
      website/more_documentation/color_picker_documentation.py
  39. 7 0
      website/more_documentation/image_documentation.py
  40. 9 0
      website/more_documentation/link_documentation.py
  41. 1 1
      website/more_documentation/scene_documentation.py
  42. 21 0
      website/more_documentation/tree_documentation.py
  43. 2 1
      website/search.py
  44. 27 2
      website/static/search_index.json

+ 1 - 1
.github/workflows/test.yml

@@ -32,7 +32,7 @@ jobs:
       - name: test startup
         run: ./test_startup.sh
       - name: setup chromedriver
-        uses: nanasess/setup-chromedriver@v1
+        uses: nanasess/setup-chromedriver@v2.1.1
       - name: pytest
         run: pytest
       - name: upload screenshots

+ 0 - 1
.vscode/settings.json

@@ -1,7 +1,6 @@
 {
   "editor.defaultFormatter": "esbenp.prettier-vscode",
   "editor.formatOnSave": true,
-  "editor.minimap.enabled": false,
   "isort.args": ["--line-length", "120"],
   "prettier.printWidth": 120,
   "python.formatting.provider": "autopep8",

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.3.4
-date-released: '2023-07-17'
+version: v1.3.6
+date-released: '2023-07-27'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.8156240
+doi: 10.5281/zenodo.8188287

+ 2 - 2
deploy.sh

@@ -19,7 +19,7 @@ async def transcribe(e: UploadEventArguments):
     transcription.text = 'Transcribing...'
     model = replicate.models.get('openai/whisper')
     version = model.versions.get('30414ee7c4fffc37e260fcab7842b5be470b9b840f2b608f5baa9bbef9a259ed')
-    prediction = await io_bound(version.predict, audio=io.BytesIO(e.content))
+    prediction = await io_bound(version.predict, audio=io.BytesIO(e.content.read()))
     text = prediction.get('transcription', 'no transcription')
     transcription.set_text(f'result: "{text}"')
 
@@ -35,7 +35,7 @@ async def generate_image():
 with ui.row().style('gap:10em'):
     with ui.column():
         ui.label('OpenAI Whisper (voice transcription)').classes('text-2xl')
-        ui.upload(on_upload=transcribe).style('width: 20em')
+        ui.upload(on_upload=transcribe, auto_upload=True).style('width: 20em')
         transcription = ui.label().classes('text-xl')
     with ui.column():
         ui.label('Stable Diffusion (image generator)').classes('text-2xl')

+ 27 - 0
examples/download_text_as_file/main.py

@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+import io
+import uuid
+
+from fastapi.responses import StreamingResponse
+
+from nicegui import Client, app, ui
+
+
+@ui.page('/')
+async def index(client: Client):
+    download_path = f'/download/{uuid.uuid4()}.txt'
+
+    @app.get(download_path)
+    def download():
+        string_io = io.StringIO(textarea.value)  # create a file-like object from the string
+        headers = {'Content-Disposition': 'attachment; filename=download.txt'}
+        return StreamingResponse(string_io, media_type='text/plain', headers=headers)
+
+    textarea = ui.textarea(value='Hello World!')
+    ui.button('Download', on_click=lambda: ui.download(download_path))
+
+    # cleanup the download route after the client disconnected
+    await client.disconnected()
+    app.routes[:] = [route for route in app.routes if route.path != download_path]
+
+ui.run()

+ 11 - 0
fly.dockerfile

@@ -4,8 +4,19 @@ LABEL maintainer="Zauberzeug GmbH <nicegui@zauberzeug.com>"
 
 RUN pip install itsdangerous prometheus_client isort docutils pandas plotly matplotlib requests
 
+RUN apt update && apt install curl -y
+
+RUN curl -sSL https://install.python-poetry.org | python3 - && \
+    cd /usr/local/bin && \
+    ln -s ~/.local/bin/poetry && \
+    poetry config virtualenvs.create false
+
 WORKDIR /app
 
+COPY pyproject.toml poetry.lock*  ./
+
+RUN poetry install --no-root --extras "plotly matplotlib"
+
 ADD . .
 
 # ensure unique version to not serve cached and hence potentially wrong static files

+ 3 - 7
main.py

@@ -1,13 +1,6 @@
 #!/usr/bin/env python3
 import importlib
 import inspect
-
-if True:
-    # increasing max decode packets to be able to transfer images
-    # see https://github.com/miguelgrinberg/python-engineio/issues/142
-    from engineio.payload import Payload
-    Payload.max_decode_packets = 500
-
 import os
 from pathlib import Path
 from typing import Awaitable, Callable, Optional
@@ -63,6 +56,8 @@ async def redirect_reference_to_documentation(request: Request,
 fly_instance_id = os.environ.get('FLY_ALLOC_ID', '').split('-')[0]
 if fly_instance_id:
     nicegui_globals.socket_io_js_extra_headers['fly-force-instance-id'] = fly_instance_id
+    # NOTE polling is required for fly.io to use the force-instance header
+    nicegui_globals.socket_io_js_transports = ['polling']
 
 
 def add_head_html() -> None:
@@ -310,6 +305,7 @@ async def index_page(client: Client) -> None:
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
             example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
             example_link('ROS2', 'Using NiceGUI as web interface for a ROS2 robot')
+            example_link('Download Text as File', 'providing in-memory data like strings as file download')
 
     with ui.row().classes('dark-box min-h-screen mt-16'):
         link_target('why')

+ 3 - 1
nicegui/binding.py

@@ -7,6 +7,8 @@ from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple,
 
 from . import globals
 
+MAX_PROPAGATION_TIME = 0.01
+
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], Any] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
@@ -45,7 +47,7 @@ async def loop() -> None:
                     set_attribute(target_obj, target_name, value)
                     propagate(target_obj, target_name, visited)
             del link, source_obj, target_obj
-        if time.time() - t > 0.01:
+        if time.time() - t > MAX_PROPAGATION_TIME:
             logging.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
         await asyncio.sleep(globals.binding_refresh_interval)
 

+ 10 - 3
nicegui/client.py

@@ -1,4 +1,5 @@
 import asyncio
+import html
 import time
 import uuid
 from pathlib import Path
@@ -76,7 +77,10 @@ class Client:
             'request': request,
             'version': __version__,
             'client_id': str(self.id),
-            'elements': elements,
+            'elements': elements.replace('&', '&amp;')
+                                .replace('<', '&lt;')
+                                .replace('>', '&gt;')
+                                .replace('`', '&#96;'),
             'head_html': self.head_html,
             'body_html': '<style>' + '\n'.join(vue_styles) + '</style>\n' + self.body_html + '\n' + '\n'.join(vue_html),
             'vue_scripts': '\n'.join(vue_scripts),
@@ -91,6 +95,7 @@ class Client:
             'tailwind': globals.tailwind,
             'prod_js': globals.prod_js,
             'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
+            'socket_io_js_transports': globals.socket_io_js_transports,
         }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
 
     async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
@@ -105,6 +110,8 @@ class Client:
 
     async def disconnected(self, check_interval: float = 0.1) -> None:
         """Block execution until the client disconnects."""
+        if not self.environ:
+            await self.connected()
         self.is_waiting_for_disconnect = True
         while self.id in globals.clients:
             await asyncio.sleep(check_interval)
@@ -133,10 +140,10 @@ class Client:
             await asyncio.sleep(check_interval)
         return self.waiting_javascript_commands.pop(request_id)
 
-    def open(self, target: Union[Callable[..., Any], str]) -> None:
+    def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
         """Open a new page in the client."""
         path = target if isinstance(target, str) else globals.page_routes[target]
-        outbox.enqueue_message('open', path, self.id)
+        outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id)
 
     def download(self, url: str, filename: Optional[str] = None) -> None:
         """Download a file from the given URL."""

+ 5 - 6
nicegui/element.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import inspect
 import re
-from copy import deepcopy
+from copy import copy, deepcopy
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union
 
@@ -79,11 +79,10 @@ class Element(Visibility):
                 path = base / path
             return sorted(path.parent.glob(path.name), key=lambda p: p.stem)
 
-        if cls.__base__ == Element:
-            cls.component = None
-            cls.libraries = []
-            cls.extra_libraries = []
-            cls.exposed_libraries = []
+        cls.component = copy(cls.component)
+        cls.libraries = copy(cls.libraries)
+        cls.extra_libraries = copy(cls.extra_libraries)
+        cls.exposed_libraries = copy(cls.exposed_libraries)
         if component:
             for path in glob_absolute_paths(component):
                 cls.component = register_vue_component(path)

+ 1 - 1
nicegui/elements/icon.py

@@ -15,7 +15,7 @@ class Icon(TextColorElement):
 
         This element is based on Quasar's `QIcon <https://quasar.dev/vue-components/icon>`_ component.
 
-        `Here <https://fonts.google.com/icons>`_ is a reference of possible names.
+        `Here <https://fonts.google.com/icons?icon.set=Material+Icons>`_ is a reference of possible names.
 
         :param name: name of the icon
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem

+ 12 - 2
nicegui/elements/plotly.py

@@ -1,9 +1,16 @@
-from typing import Dict, Union
+from __future__ import annotations
 
-import plotly.graph_objects as go
+from typing import Dict, Union
 
+from .. import globals
 from ..element import Element
 
+try:
+    import plotly.graph_objects as go
+    globals.optional_features.add('plotly')
+except ImportError:
+    pass
+
 
 class Plotly(Element, component='plotly.vue', libraries=['lib/plotly/plotly.min.js']):
 
@@ -22,6 +29,9 @@ class Plotly(Element, component='plotly.vue', libraries=['lib/plotly/plotly.min.
         :param figure: Plotly figure to be rendered. Can be either a `go.Figure` instance, or
                        a `dict` object with keys `data`, `layout`, `config` (optional).
         """
+        if not 'plotly' in globals.optional_features:
+            raise ImportError('Plotly is not installed. Please run "pip install nicegui[plotly]".')
+
         super().__init__()
 
         self.figure = figure

+ 11 - 2
nicegui/elements/pyplot.py

@@ -1,12 +1,18 @@
 import asyncio
 import io
+import os
 from typing import Any
 
-import matplotlib.pyplot as plt
-
 from .. import background_tasks, globals
 from ..element import Element
 
+try:
+    if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
+        import matplotlib.pyplot as plt
+        globals.optional_features.add('matplotlib')
+except ImportError:
+    pass
+
 
 class Pyplot(Element):
 
@@ -18,6 +24,9 @@ class Pyplot(Element):
         :param close: whether the figure should be closed after exiting the context; set to `False` if you want to update it later (default: `True`)
         :param kwargs: arguments like `figsize` which should be passed to `pyplot.figure <https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.figure.html>`_
         """
+        if 'matplotlib' not in globals.optional_features:
+            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
+
         super().__init__('div')
         self.close = close
         self.fig = plt.figure(**kwargs)

+ 1 - 1
nicegui/elements/query.js

@@ -13,7 +13,7 @@ export default {
     },
     add_style(style) {
       Object.entries(style).forEach(([key, val]) =>
-        document.querySelectorAll(this.selector).forEach((e) => (e.style[key] = val))
+        document.querySelectorAll(this.selector).forEach((e) => e.style.setProperty(key, val))
       );
     },
     remove_style(keys) {

+ 29 - 14
nicegui/elements/scene.js

@@ -73,9 +73,9 @@ export default {
     light.position.set(5, 10, 40);
     this.scene.add(light);
 
-    let renderer = undefined;
+    this.renderer = undefined;
     try {
-      renderer = new THREE.WebGLRenderer({
+      this.renderer = new THREE.WebGLRenderer({
         antialias: true,
         alpha: true,
         canvas: this.$el.children[0],
@@ -88,18 +88,21 @@ export default {
       this.$el.style.border = "1px solid silver";
       return;
     }
-    renderer.setClearColor("#eee");
-    renderer.setSize(this.width, this.height);
+    this.renderer.setClearColor("#eee");
+    this.renderer.setSize(this.width, this.height);
 
-    const text_renderer = new CSS2DRenderer({
+    this.text_renderer = new CSS2DRenderer({
       element: this.$el.children[1],
     });
-    text_renderer.setSize(this.width, this.height);
+    this.text_renderer.setSize(this.width, this.height);
 
-    const text3d_renderer = new CSS3DRenderer({
+    this.text3d_renderer = new CSS3DRenderer({
       element: this.$el.children[2],
     });
-    text3d_renderer.setSize(this.width, this.height);
+    this.text3d_renderer.setSize(this.width, this.height);
+
+    this.$nextTick(() => this.resize());
+    window.addEventListener("resize", this.resize, false);
 
     if (this.grid) {
       const ground = new THREE.Mesh(new THREE.PlaneGeometry(100, 100), new THREE.MeshPhongMaterial({ color: "#eee" }));
@@ -113,21 +116,21 @@ export default {
       grid.rotateX(Math.PI / 2);
       this.scene.add(grid);
     }
-    this.controls = new OrbitControls(this.camera, renderer.domElement);
+    this.controls = new OrbitControls(this.camera, this.renderer.domElement);
 
     const render = () => {
       requestAnimationFrame(() => setTimeout(() => render(), 1000 / 20));
       TWEEN.update();
-      renderer.render(this.scene, this.camera);
-      text_renderer.render(this.scene, this.camera);
-      text3d_renderer.render(this.scene, this.camera);
+      this.renderer.render(this.scene, this.camera);
+      this.text_renderer.render(this.scene, this.camera);
+      this.text3d_renderer.render(this.scene, this.camera);
     };
     render();
 
     const raycaster = new THREE.Raycaster();
     const click_handler = (mouseEvent) => {
-      let x = (mouseEvent.offsetX / renderer.domElement.width) * 2 - 1;
-      let y = -(mouseEvent.offsetY / renderer.domElement.height) * 2 + 1;
+      let x = (mouseEvent.offsetX / this.renderer.domElement.width) * 2 - 1;
+      let y = -(mouseEvent.offsetY / this.renderer.domElement.height) * 2 + 1;
       raycaster.setFromCamera({ x: x, y: y }, this.camera);
       this.$emit("click3d", {
         hits: raycaster
@@ -159,6 +162,10 @@ export default {
     }, 100);
   },
 
+  beforeDestroy() {
+    window.removeEventListener("resize", this.resize);
+  },
+
   methods: {
     create(type, id, parent_id, ...args) {
       let mesh;
@@ -350,6 +357,14 @@ export default {
         })
         .start();
     },
+    resize() {
+      const { clientWidth, clientHeight } = this.$el;
+      this.renderer.setSize(clientWidth, clientHeight);
+      this.text_renderer.setSize(clientWidth, clientHeight);
+      this.text3d_renderer.setSize(clientWidth, clientHeight);
+      this.camera.aspect = clientWidth / clientHeight;
+      this.camera.updateProjectionMatrix();
+    },
   },
 
   props: {

+ 3 - 3
nicegui/functions/open.py

@@ -3,7 +3,7 @@ from typing import Any, Callable, Union
 from .. import globals
 
 
-def open(target: Union[Callable[..., Any], str]) -> None:
+def open(target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
     """Open
 
     Can be used to programmatically trigger redirects for a specific client.
@@ -12,7 +12,7 @@ def open(target: Union[Callable[..., Any], str]) -> None:
     User events like button clicks provide such a socket.
 
     :param target: page function or string that is a an absolute URL or relative path from base URL
-    :param socket: optional WebSocket defining the target client
+    :param new_tab: whether to open the target in a new tab
     """
     path = target if isinstance(target, str) else globals.page_routes[target]
-    globals.get_client().open(path)
+    globals.get_client().open(path, new_tab)

+ 5 - 1
nicegui/functions/refreshable.py

@@ -76,4 +76,8 @@ class refreshable:
                     globals.app.on_startup(result)
 
     def prune(self) -> None:
-        self.targets = [target for target in self.targets if target.container.client.id in globals.clients]
+        self.targets = [
+            target
+            for target in self.targets
+            if target.container.client.id in globals.clients and target.container.id in target.container.client.elements
+        ]

+ 3 - 1
nicegui/globals.py

@@ -4,7 +4,7 @@ import logging
 from contextlib import contextmanager
 from enum import Enum
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, Optional, Set, Union
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, Literal, Optional, Set, Union
 
 from socketio import AsyncServer
 from uvicorn import Server
@@ -46,6 +46,8 @@ tailwind: bool
 prod_js: bool
 air: Optional['Air'] = None
 socket_io_js_extra_headers: Dict = {}
+endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none'
+socket_io_js_transports: List[Literal['websocket', 'polling']] = ['websocket', 'polling']
 
 _socket_id: Optional[str] = None
 slot_stacks: Dict[int, List['Slot']] = {}

+ 1 - 1
nicegui/nicegui.py

@@ -173,7 +173,7 @@ def handle_event(client: Client, msg: Dict) -> None:
     with client:
         sender = client.elements.get(msg['id'])
         if sender:
-            msg['args'] = [json.loads(arg) for arg in msg.get('args', [])]
+            msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
             if len(msg['args']) == 1:
                 msg['args'] = msg['args'][0]
             sender._handle_event(msg)

+ 3 - 0
nicegui/page.py

@@ -107,6 +107,9 @@ class page:
             parameters.insert(0, request)
         decorated.__signature__ = inspect.Signature(parameters)
 
+        if 'include_in_schema' not in self.kwargs:
+            self.kwargs['include_in_schema'] = globals.endpoint_documentation in {'page', 'all'}
+
         self.api_router.get(self._path, **self.kwargs)(decorated)
         globals.page_routes[func] = self.path
         return func

+ 9 - 0
nicegui/run.py

@@ -54,6 +54,7 @@ def run(*,
         uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
         tailwind: bool = True,
         prod_js: bool = True,
+        endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none',
         storage_secret: Optional[str] = None,
         **kwargs: Any,
         ) -> None:
@@ -81,6 +82,7 @@ def run(*,
     :param uvicorn_reload_excludes: string with comma-separated list of glob-patterns which should be ignored for reload (default: `'.*, .py[cod], .sw.*, ~*'`)
     :param tailwind: whether to use Tailwind (experimental, default: `True`)
     :param prod_js: whether to use the production version of Vue and Quasar dependencies (default: `True`)
+    :param endpoint_documentation: control what endpoints appear in the autogenerated OpenAPI docs (default: 'none', options: 'none', 'internal', 'page', 'all')
     :param storage_secret: secret key for browser based storage (default: `None`, a value is required to enable ui.storage.individual and ui.storage.browser)
     :param kwargs: additional keyword arguments are passed to `uvicorn.run`    
     '''
@@ -94,6 +96,13 @@ def run(*,
     globals.binding_refresh_interval = binding_refresh_interval
     globals.tailwind = tailwind
     globals.prod_js = prod_js
+    globals.endpoint_documentation = endpoint_documentation
+
+    for route in globals.app.routes:
+        if route.path.startswith('/_nicegui') and hasattr(route, 'methods'):
+            route.include_in_schema = endpoint_documentation in {'internal', 'all'}
+        if route.path == '/' or route.path in globals.page_routes.values():
+            route.include_in_schema = endpoint_documentation in {'page', 'all'}
 
     if on_air:
         globals.air = Air('' if on_air is True else on_air)

+ 13 - 4
nicegui/templates/index.html

@@ -53,7 +53,12 @@
 
       const loaded_libraries = new Set();
       const loaded_components = new Set();
-      const elements = {{ elements | safe }};
+
+      const raw_elements = String.raw`{{ elements | safe }}`;
+      const elements = JSON.parse(raw_elements.replace(/&#96;/g, '`')
+                                              .replace(/&gt;/g, '>')
+                                              .replace(/&lt;/g, '<')
+                                              .replace(/&amp;/g, '&'));
 
       function stringifyEventArgs(args, event_args) {
         const result = [];
@@ -228,7 +233,7 @@
           const query = { client_id: "{{ client_id }}" };
           const url = window.location.protocol === 'https:' ? 'wss://' : 'ws://' + window.location.host;
           const extraHeaders = {{ socket_io_js_extra_headers | safe }};
-          const transports = ['websocket', 'polling'];
+          const transports = {{ socket_io_js_transports | safe }};
           window.path_prefix = "{{ prefix | safe }}";
           window.socket = io(url, { path: "{{ prefix | safe }}/_nicegui_ws/socket.io", query, extraHeaders, transports });
           const messageHandlers = {
@@ -273,7 +278,11 @@
               }
             },
             run_javascript: (msg) => runJavascript(msg['code'], msg['request_id']),
-            open: (msg) => (location.href = msg.startsWith('/') ? "{{ prefix | safe }}" + msg : msg),
+            open: (msg) => {
+              const url = msg.path.startsWith('/') ? "{{ prefix | safe }}" + msg.path : msg.path;
+              const target = msg.new_tab ? '_blank' : '_self';
+              window.open(url, target);
+            },
             download: (msg) => download(msg.url, msg.filename),
             notify: (msg) => Quasar.Notify.create(msg),
           };
@@ -313,7 +322,7 @@
       {{ vue_scripts | safe }}
 
       const dark = {{ dark }};
-      Quasar.lang.set(Quasar.lang["{{ language }}"]);
+      Quasar.lang.set(Quasar.lang["{{ language }}".replace('-', '')]);
       Quasar.Dark.set(dark === None ? "auto" : dark);
       {% if tailwind %}
       if (dark !== None) tailwind.config.darkMode = "class";

+ 6 - 27
nicegui/ui.py

@@ -1,5 +1,3 @@
-import os
-
 __all__ = [
     'deprecated',
     'element',
@@ -34,6 +32,7 @@ __all__ = [
     'keyboard',
     'knob',
     'label',
+    'line_plot',
     'link',
     'link_target',
     'log',
@@ -42,8 +41,10 @@ __all__ = [
     'menu_item',
     'mermaid',
     'number',
+    'plotly',
     'circular_progress',
     'linear_progress',
+    'pyplot',
     'query',
     'radio',
     'row',
@@ -124,6 +125,7 @@ from .elements.joystick import Joystick as joystick
 from .elements.keyboard import Keyboard as keyboard
 from .elements.knob import Knob as knob
 from .elements.label import Label as label
+from .elements.line_plot import LinePlot as line_plot
 from .elements.link import Link as link
 from .elements.link import LinkTarget as link_target
 from .elements.log import Log as log
@@ -132,8 +134,10 @@ from .elements.menu import Menu as menu
 from .elements.menu import MenuItem as menu_item
 from .elements.mermaid import Mermaid as mermaid
 from .elements.number import Number as number
+from .elements.plotly import Plotly as plotly
 from .elements.progress import CircularProgress as circular_progress
 from .elements.progress import LinearProgress as linear_progress
+from .elements.pyplot import Pyplot as pyplot
 from .elements.query import query
 from .elements.radio import Radio as radio
 from .elements.row import Row as row
@@ -177,28 +181,3 @@ from .page_layout import PageSticky as page_sticky
 from .page_layout import RightDrawer as right_drawer
 from .run import run
 from .run_with import run_with
-
-try:
-    from .elements.plotly import Plotly as plotly
-    globals.optional_features.add('plotly')
-except ImportError:
-    def plotly(*args, **kwargs):
-        raise ImportError('Plotly is not installed. Please run "pip install plotly".')
-__all__.append('plotly')
-
-if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
-    try:
-        from .elements.line_plot import LinePlot as line_plot
-        from .elements.pyplot import Pyplot as pyplot
-        plot = deprecated(pyplot, 'ui.plot', 'ui.pyplot', 317)
-        globals.optional_features.add('matplotlib')
-    except ImportError:
-        def line_plot(*args, **kwargs):
-            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
-
-        def pyplot(*args, **kwargs):
-            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
-
-        def plot(*args, **kwargs):
-            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
-    __all__.extend(['line_plot', 'pyplot', 'plot'])

+ 7 - 2
tests/conftest.py

@@ -6,9 +6,9 @@ import icecream
 import pytest
 from selenium import webdriver
 from selenium.webdriver.chrome.service import Service
-from webdriver_manager.chrome import ChromeDriverManager
 
 from nicegui import Client, globals
+from nicegui.elements import plotly, pyplot
 from nicegui.page import page
 
 from .screen import Screen
@@ -32,13 +32,18 @@ def capabilities(capabilities: Dict) -> Dict:
 
 @pytest.fixture(autouse=True)
 def reset_globals() -> Generator[None, None, None]:
+    print('!!! resetting globals !!!')
     for path in {'/'}.union(globals.page_routes.values()):
         globals.app.remove_route(path)
+    globals.app.openapi_schema = None
     globals.app.middleware_stack = None
     globals.app.user_middleware.clear()
     # NOTE favicon routes must be removed separately because they are not "pages"
     [globals.app.routes.remove(r) for r in globals.app.routes if r.path.endswith('/favicon.ico')]
     importlib.reload(globals)
+    # repopulate globals.optional_features
+    importlib.reload(plotly)
+    importlib.reload(pyplot)
     globals.app.storage.clear()
     globals.index_client = Client(page('/'), shared=True).__enter__()
     globals.app.get('/')(globals.index_client.build_response)
@@ -53,7 +58,7 @@ def remove_all_screenshots() -> None:
 
 @pytest.fixture(scope='function')
 def driver(chrome_options: webdriver.ChromeOptions) -> webdriver.Chrome:
-    s = Service(ChromeDriverManager().install())
+    s = Service()
     driver = webdriver.Chrome(service=s, options=chrome_options)
     driver.implicitly_wait(Screen.IMPLICIT_WAIT)
     driver.set_page_load_timeout(4)

+ 3 - 5
tests/screen.py

@@ -16,11 +16,9 @@ from nicegui import globals, ui
 
 from .test_helpers import TEST_DIR
 
-PORT = 3392
-IGNORED_CLASSES = ['row', 'column', 'q-card', 'q-field', 'q-field__label', 'q-input']
-
 
 class Screen:
+    PORT = 3392
     IMPLICIT_WAIT = 4
     SCREENSHOT_DIR = TEST_DIR / 'screenshots'
 
@@ -28,7 +26,7 @@ class Screen:
         self.selenium = selenium
         self.caplog = caplog
         self.server_thread = None
-        self.ui_run_kwargs = {'port': PORT, 'show': False, 'reload': False}
+        self.ui_run_kwargs = {'port': self.PORT, 'show': False, 'reload': False}
 
     def start_server(self) -> None:
         """Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script."""
@@ -61,7 +59,7 @@ class Screen:
         deadline = time.time() + timeout
         while True:
             try:
-                self.selenium.get(f'http://localhost:{PORT}{path}')
+                self.selenium.get(f'http://localhost:{self.PORT}{path}')
                 self.selenium.find_element(By.XPATH, '//body')  # ensure page and JS are loaded
                 break
             except Exception as e:

+ 2 - 2
tests/test_download.py

@@ -2,7 +2,7 @@ from fastapi import HTTPException
 
 from nicegui import app, ui
 
-from .screen import PORT, Screen
+from .screen import Screen
 
 
 def test_download(screen: Screen):
@@ -20,4 +20,4 @@ def test_download(screen: Screen):
     screen.click('Download')
     screen.wait(0.5)
     assert success
-    screen.assert_py_logger('WARNING', f'http://localhost:{PORT}/static/test.py not found')
+    screen.assert_py_logger('WARNING', f'http://localhost:{Screen.PORT}/static/test.py not found')

+ 16 - 0
tests/test_element.py

@@ -161,3 +161,19 @@ def test_move(screen: Screen):
     screen.click('Move X to top')
     screen.wait(0.5)
     assert screen.find('X').location['y'] < screen.find('A').location['y'] < screen.find('B').location['y']
+
+
+def test_xss(screen: Screen):
+    ui.label('</script><script>alert(1)</script>')
+    ui.label('<b>Bold 1</b>, `code`, copy&paste, multi\nline')
+    ui.button('Button', on_click=lambda: (
+        ui.label('</script><script>alert(2)</script>'),
+        ui.label('<b>Bold 2</b>, `code`, copy&paste, multi\nline'),
+    ))
+
+    screen.open('/')
+    screen.click('Button')
+    screen.should_contain('</script><script>alert(1)</script>')
+    screen.should_contain('</script><script>alert(2)</script>')
+    screen.should_contain('<b>Bold 1</b>, `code`, copy&paste, multi\nline')
+    screen.should_contain('<b>Bold 2</b>, `code`, copy&paste, multi\nline')

+ 41 - 0
tests/test_endpoint_docs.py

@@ -0,0 +1,41 @@
+from typing import Set
+
+import requests
+
+from nicegui import __version__
+
+from .screen import Screen
+
+
+def get_openapi_paths() -> Set[str]:
+    return set(requests.get(f'http://localhost:{Screen.PORT}/openapi.json').json()['paths'])
+
+
+def test_endpoint_documentation_default(screen: Screen):
+    screen.open('/')
+    assert get_openapi_paths() == set()
+
+
+def test_endpoint_documentation_page_only(screen: Screen):
+    screen.ui_run_kwargs['endpoint_documentation'] = 'page'
+    screen.open('/')
+    assert get_openapi_paths() == {'/'}
+
+
+def test_endpoint_documentation_internal_only(screen: Screen):
+    screen.ui_run_kwargs['endpoint_documentation'] = 'internal'
+    screen.open('/')
+    assert get_openapi_paths() == {
+        f'/_nicegui/{__version__}/libraries/{{key}}',
+        f'/_nicegui/{__version__}/components/{{key}}',
+    }
+
+
+def test_endpoint_documentation_all(screen: Screen):
+    screen.ui_run_kwargs['endpoint_documentation'] = 'all'
+    screen.open('/')
+    assert get_openapi_paths() == {
+        '/',
+        f'/_nicegui/{__version__}/libraries/{{key}}',
+        f'/_nicegui/{__version__}/components/{{key}}',
+    }

+ 2 - 2
tests/test_favicon.py

@@ -6,7 +6,7 @@ from bs4 import BeautifulSoup
 
 from nicegui import favicon, ui
 
-from .screen import PORT, Screen
+from .screen import Screen
 
 DEFAULT_FAVICON_PATH = Path(__file__).parent.parent / 'nicegui' / 'static' / 'favicon.ico'
 LOGO_FAVICON_PATH = Path(__file__).parent.parent / 'website' / 'static' / 'logo_square.png'
@@ -19,7 +19,7 @@ def assert_favicon_url_starts_with(screen: Screen, content: str):
 
 
 def assert_favicon(content: Union[Path, str, bytes], url_path: str = '/favicon.ico'):
-    response = requests.get(f'http://localhost:{PORT}{url_path}')
+    response = requests.get(f'http://localhost:{Screen.PORT}{url_path}')
     assert response.status_code == 200
     if isinstance(content, Path):
         assert content.read_bytes() == response.content

+ 18 - 0
tests/test_open.py

@@ -0,0 +1,18 @@
+import pytest
+
+from nicegui import ui
+
+from .screen import Screen
+
+
+@pytest.mark.parametrize('new_tab', [False, True])
+def test_open_page(screen: Screen, new_tab: bool):
+    @ui.page('/test_page')
+    def page():
+        ui.label('Test page')
+    ui.button('Open test page', on_click=lambda: ui.open('/test_page', new_tab=new_tab))
+
+    screen.open('/')
+    screen.click('Open test page')
+    screen.switch_to(1 if new_tab else 0)
+    screen.should_contain('Test page')

+ 15 - 0
tests/test_page.py

@@ -138,6 +138,21 @@ def test_wait_for_disconnect(screen: Screen):
     assert events == ['connected', 'disconnected', 'connected']
 
 
+def test_wait_for_disconnect_without_awaiting_connected(screen: Screen):
+    events = []
+
+    @ui.page('/')
+    async def page(client: Client):
+        await client.disconnected()
+        events.append('disconnected')
+
+    screen.open('/')
+    screen.wait(0.5)
+    screen.open('/')
+    screen.wait(0.5)
+    assert events == ['disconnected']
+
+
 def test_adding_elements_after_connected(screen: Screen):
     @ui.page('/')
     async def page(client: Client):

+ 8 - 0
tests/test_query.py

@@ -52,3 +52,11 @@ def test_query_multiple_divs(screen: Screen):
     screen.wait(0.5)
     assert screen.find('A').value_of_css_property('border') == '1px solid rgb(0, 0, 0)'
     assert screen.find('B').value_of_css_property('border') == '1px solid rgb(0, 0, 0)'
+
+
+def test_query_with_css_variables(screen: Screen):
+    ui.add_body_html('<div id="element">Test</div>')
+    ui.query('#element').style('--color: red; color: var(--color)')
+
+    screen.open('/')
+    assert screen.find('Test').value_of_css_property('color') == 'rgba(255, 0, 0, 1)'

+ 20 - 0
tests/test_refreshable.py

@@ -126,3 +126,23 @@ def test_refresh_with_arguments(screen: Screen):
     a = 3
     screen.click('Refresh 3')
     screen.should_contain('a=3, b=1')
+
+
+def test_refresh_deleted_element(screen: Screen):
+    @ui.refreshable
+    def some_ui():
+        ui.label('some text')
+
+    with ui.card() as card:
+        some_ui()
+
+    ui.button('Refresh', on_click=some_ui.refresh)
+    ui.button('Clear', on_click=card.clear)
+
+    some_ui()
+
+    screen.open('/')
+    screen.should_contain('some text')
+
+    screen.click('Clear')
+    screen.click('Refresh')

+ 3 - 3
tests/test_serving_files.py

@@ -6,7 +6,7 @@ import pytest
 
 from nicegui import app, ui
 
-from .screen import PORT, Screen
+from .screen import Screen
 from .test_helpers import TEST_DIR
 
 IMAGE_FILE = Path(TEST_DIR).parent / 'examples' / 'slideshow' / 'slides' / 'slide1.jpg'
@@ -27,7 +27,7 @@ def provide_media_files():
 def assert_video_file_streaming(path: str) -> None:
     with httpx.Client() as http_client:
         r = http_client.get(
-            path if 'http' in path else f'http://localhost:{PORT}{path}',
+            path if 'http' in path else f'http://localhost:{Screen.PORT}{path}',
             headers={'Range': 'bytes=0-1000'},
         )
         assert r.status_code == 206
@@ -56,7 +56,7 @@ def test_adding_single_static_file(screen: Screen):
 
     screen.open('/')
     with httpx.Client() as http_client:
-        r = http_client.get(f'http://localhost:{PORT}{url_path}')
+        r = http_client.get(f'http://localhost:{Screen.PORT}{url_path}')
         assert r.status_code == 200
         assert 'max-age=' in r.headers['Cache-Control']
 

+ 2 - 2
tests/test_storage.py

@@ -6,7 +6,7 @@ import httpx
 
 from nicegui import Client, app, background_tasks, ui
 
-from .screen import PORT, Screen
+from .screen import Screen
 
 
 def test_browser_data_is_stored_in_the_browser(screen: Screen):
@@ -87,7 +87,7 @@ async def test_access_user_storage_from_fastapi(screen: Screen):
     screen.ui_run_kwargs['storage_secret'] = 'just a test'
     screen.open('/')
     async with httpx.AsyncClient() as http_client:
-        response = await http_client.get(f'http://localhost:{PORT}/api')
+        response = await http_client.get(f'http://localhost:{Screen.PORT}/api')
         assert response.status_code == 200
         assert response.text == '"OK"'
         await asyncio.sleep(0.5)  # wait for storage to be written

+ 1 - 1
website/documentation.py

@@ -164,7 +164,7 @@ def create_full() -> None:
         add_face()
 
         ui.button('Add', on_click=add_face)
-        ui.button('Remove', on_click=lambda: container.remove(0))
+        ui.button('Remove', on_click=lambda: container.remove(0) if list(container) else None)
         ui.button('Clear', on_click=container.clear)
 
     load_demo(ui.expansion)

+ 17 - 0
website/more_documentation/aggrid_documentation.py

@@ -153,3 +153,20 @@ def more() -> None:
                 {'name': 'Facebook', 'url': '<a href="https://facebook.com">https://facebook.com</a>'},
             ],
         }, html_columns=[1])
+
+    @text_demo('Respond to an AG Grid event', '''
+        All AG Grid events are passed through to NiceGUI via the AG Grid global listener.
+        These events can be subscribed to using the `.on()` method.
+    ''')
+    def aggrid_with_html_columns():
+        ui.aggrid({
+            'columnDefs': [
+                {'headerName': 'Name', 'field': 'name'},
+                {'headerName': 'Age', 'field': 'age'},
+            ],
+            'rowData': [
+                {'name': 'Alice', 'age': 18},
+                {'name': 'Bob', 'age': 21},
+                {'name': 'Carol', 'age': 42},
+            ],
+        }).on('cellClicked', lambda event: ui.notify(f'Cell value: {event.args["value"]}'))

+ 2 - 2
website/more_documentation/color_picker_documentation.py

@@ -2,5 +2,5 @@ from nicegui import ui
 
 
 def main_demo() -> None:
-    picker = ui.color_picker(on_pick=lambda e: button.style(f'background-color:{e.color}!important'))
-    button = ui.button(on_click=picker.open, icon='colorize')
+    with ui.button(icon='colorize') as button:
+        ui.color_picker(on_pick=lambda e: button.style(f'background-color:{e.color}!important'))

+ 7 - 0
website/more_documentation/image_documentation.py

@@ -31,3 +31,10 @@ def more() -> None:
 
         src = 'https://assets1.lottiefiles.com/datafiles/HN7OcWNnoqje6iXIiZdWzKxvLIbfeCGTmvXmEm1h/data.json'
         ui.html(f'<lottie-player src="{src}" loop autoplay />').classes('w-full')
+
+    @text_demo('Image link', '''
+        Images can link to another page by wrapping them in a [ui.link](https://nicegui.io/documentation/link).
+    ''')
+    def link():
+        with ui.link(target='https://github.com/zauberzeug/nicegui'):
+            ui.image('https://picsum.photos/id/41/640/360').classes('w-64')

+ 9 - 0
website/more_documentation/link_documentation.py

@@ -43,3 +43,12 @@ def more() -> None:
         ui.label('Go to other page')
         ui.link('... with path', '/some_other_page')
         ui.link('... with function reference', my_page)
+
+    @text_demo('Link from images and other elements', '''
+        By nesting elements inside a link you can make the whole element clickable.
+        This works with all elements but is most useful for non-interactive elements like 
+        [ui.image](/documentation/image), [ui.avatar](/documentation/image) etc.
+    ''')
+    def link_from_elements():
+        with ui.link(target='https://github.com/zauberzeug/nicegui'):
+            ui.image('https://picsum.photos/id/41/640/360').classes('w-64')

+ 1 - 1
website/more_documentation/scene_documentation.py

@@ -4,7 +4,7 @@ from ..documentation_tools import text_demo
 
 
 def main_demo() -> None:
-    with ui.scene(width=285, height=285) as scene:
+    with ui.scene().classes('w-full h-64') as scene:
         scene.sphere().material('#4488ff')
         scene.cylinder(1, 0.5, 2, 20).material('#ff8800', opacity=0.5).move(-2, 1)
         scene.extrusion([[0, 0], [0, 1], [1, 0.5]], 0.1).material('#ff8888').move(-2, -2)

+ 21 - 0
website/more_documentation/tree_documentation.py

@@ -33,3 +33,24 @@ def more() -> None:
         tree.add_slot('default-body', '''
             <span :props="props">Description: "{{ props.node.description }}"</span>
         ''')
+
+    @text_demo('Expand programmatically', '''
+        The tree can be expanded programmatically by modifying the "expanded" prop.
+    ''')
+    def expand_programmatically():
+        from typing import List
+
+        def expand(node_ids: List[str]) -> None:
+            t._props['expanded'] = node_ids
+            t.update()
+
+        with ui.row():
+            ui.button('all', on_click=lambda: expand(['A', 'B']))
+            ui.button('A', on_click=lambda: expand(['A']))
+            ui.button('B', on_click=lambda: expand(['B']))
+            ui.button('none', on_click=lambda: expand([]))
+
+        t = ui.tree([
+            {'id': 'A', 'children': [{'id': 'A1'}, {'id': 'A2'}]},
+            {'id': 'B', 'children': [{'id': 'B1'}, {'id': 'B2'}]},
+        ], label_key='id')

+ 2 - 1
website/search.py

@@ -32,7 +32,8 @@ class Search:
                 ui.icon('search', size='2em')
                 ui.input(placeholder='Search documentation', on_change=self.handle_input) \
                     .classes('flex-grow').props('borderless autofocus')
-                ui.button('ESC').props('padding="2px 8px" outline size=sm color=grey-5').classes('shadow')
+                ui.button('ESC', on_click=self.dialog.close) \
+                    .props('padding="2px 8px" outline size=sm color=grey-5').classes('shadow')
             ui.separator()
             self.results = ui.element('q-list').classes('w-full').props('separator')
         ui.keyboard(self.handle_keypress)

+ 27 - 2
website/static/search_index.json

@@ -144,6 +144,11 @@
     "content": "Using NiceGUI as web interface for a ROS2 robot",
     "url": "https://github.com/zauberzeug/nicegui/tree/main/examples/ros2/"
   },
+  {
+    "title": "Example: Download Text as File",
+    "content": "providing in-memory data like strings as file download",
+    "url": "https://github.com/zauberzeug/nicegui/tree/main/examples/download_text_as_file/main.py"
+  },
   {
     "title": "Basic Elements",
     "content": "This is **Markdown**.",
@@ -326,7 +331,7 @@
   },
   {
     "title": "Icon",
-    "content": "This element is based on Quasar's QIcon <https://quasar.dev/vue-components/icon>_ component.  Here <https://fonts.google.com/icons>_ is a reference of possible names.  :param name: name of the icon :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem :param color: icon color (either a Quasar, Tailwind, or CSS color or None, default: None)",
+    "content": "This element is based on Quasar's QIcon <https://quasar.dev/vue-components/icon>_ component.  Here <https://fonts.google.com/icons?icon.set=Material+Icons>_ is a reference of possible names.  :param name: name of the icon :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem :param color: icon color (either a Quasar, Tailwind, or CSS color or None, default: None)",
     "url": "/documentation/icon"
   },
   {
@@ -346,7 +351,7 @@
   },
   {
     "title": "Open",
-    "content": "Can be used to programmatically trigger redirects for a specific client.  Note that *all* clients (i.e. browsers) connected to the page will open the target URL *unless* a socket is specified. User events like button clicks provide such a socket.  :param target: page function or string that is a an absolute URL or relative path from base URL :param socket: optional WebSocket defining the target client",
+    "content": "Can be used to programmatically trigger redirects for a specific client.  Note that *all* clients (i.e. browsers) connected to the page will open the target URL *unless* a socket is specified. User events like button clicks provide such a socket.  :param target: page function or string that is a an absolute URL or relative path from base URL :param new_tab: whether to open the target in a new tab",
     "url": "/documentation/open"
   },
   {
@@ -469,6 +474,11 @@
     "content": "You can render columns as HTML by passing a list of column indices to the html_columns argument.",
     "url": "/documentation/aggrid#render_columns_as_html"
   },
+  {
+    "title": "Aggrid: Respond to an AG Grid event",
+    "content": "All AG Grid events are passed through to NiceGUI via the AG Grid global listener. These events can be subscribed to using the .on() method.",
+    "url": "/documentation/aggrid#respond_to_an_ag_grid_event"
+  },
   {
     "title": "Carousel",
     "content": "This element represents Quasar's QCarousel <https://quasar.dev/vue-components/carousel#qcarousel-api>_ component. It contains individual carousel slides.  :param value: ui.carousel_slide or name of the slide to be initially selected (default: None meaning the first slide) :param on_value_change: callback to be executed when the selected slide changes :param animated: whether to animate slide transitions (default: False) :param arrows: whether to show arrows for manual slide navigation (default: False) :param navigation: whether to show navigation dots for manual slide navigation (default: False) on_value_change next previous",
@@ -669,6 +679,11 @@
     "content": "Scoped slots can be used to insert custom content into the header and body of a tree node. See the Quasar documentation for more information.",
     "url": "/documentation/tree#tree_with_custom_header_and_body"
   },
+  {
+    "title": "Tree: Expand programmatically",
+    "content": "The tree can be expanded programmatically by modifying the \"expanded\" prop.",
+    "url": "/documentation/tree#expand_programmatically"
+  },
   {
     "title": "Switch",
     "content": ":param text: the label to display next to the switch :param value: whether it should be active initially (default: False) :param on_change: callback which is invoked when state is changed by the user",
@@ -864,6 +879,11 @@
     "content": "You can also use Lottie files with animations.",
     "url": "/documentation/image#lottie_files"
   },
+  {
+    "title": "Image: Image link",
+    "content": "Images can link to another page by wrapping them in a ui.link.",
+    "url": "/documentation/image#image_link"
+  },
   {
     "title": "Color Theming",
     "content": "Sets the main colors (primary, secondary, accent, ...) used by Quasar <https://quasar.dev/>_.",
@@ -1084,6 +1104,11 @@
     "content": "You can link to other pages by providing the link target as path or function reference.",
     "url": "/documentation/link#links_to_other_pages"
   },
+  {
+    "title": "Link: Link from images and other elements",
+    "content": "By nesting elements inside a link you can make the whole element clickable. This works with all elements but is most useful for non-interactive elements like ui.image, ui.avatar etc.",
+    "url": "/documentation/link#link_from_images_and_other_elements"
+  },
   {
     "title": "Log view",
     "content": "Create a log view that allows to add new lines without re-transmitting the whole history to the client.  :param max_lines: maximum number of lines before dropping oldest ones (default: None) push clear Clear the log",