Repository: zauberzeug/nicegui
Pull request #3159 (2024-06-01 15:56:42)

Introduce `ui.teleport` element
This PR introduces a `ui.teleport` component to NiceGUI, mirroring the functionality of Vue's `Teleport`. It is the key for enabling the direct creation of any NiceGUI component within table cell slots(#3148).


Patch:
diff --git a/nicegui/elements/codemirror.js b/nicegui/elements/codemirror.js
index f6ed1483..4c8277bc 100644
--- a/nicegui/elements/codemirror.js
+++ b/nicegui/elements/codemirror.js
@@ -147,7 +147,8 @@ export default {
     },
   },
   async mounted() {
-    this.CM = await import(`${this.resource_path}/editor.js`);
+    await this.$nextTick(); // NOTE: wait for window.path_prefix to be set
+    this.CM = await import(window.path_prefix + `${this.resource_path}/editor.js`);
     const CM = this.CM;
 
     // This is used to prevent emitting the value we just received from the server.
diff --git a/nicegui/elements/mixins/source_element.py b/nicegui/elements/mixins/source_element.py
index 5d9add5c..3db8c368 100644
--- a/nicegui/elements/mixins/source_element.py
+++ b/nicegui/elements/mixins/source_element.py
@@ -1,3 +1,4 @@
+from pathlib import Path
 from typing import Any, Callable, Optional, cast
 
 from typing_extensions import Self
@@ -98,6 +99,8 @@ class SourceElement(Element):
             else:
                 source = core.app.add_static_file(local_file=source)
             self.auto_route = source
+        if isinstance(source, Path) and not source.exists():
+            raise FileNotFoundError(f'File not found: {source}')
         self._props['src'] = source
 
     def _handle_delete(self) -> None:
diff --git a/nicegui/elements/teleport.js b/nicegui/elements/teleport.js
new file mode 100644
index 00000000..e3bc9e8b
--- /dev/null
+++ b/nicegui/elements/teleport.js
@@ -0,0 +1,20 @@
+export default {
+  template: `<Teleport v-if="isLoaded" :to="to" :key="key"><slot></slot></Teleport>`,
+  props: {
+    to: String,
+  },
+  mounted() {
+    this.isLoaded = true;
+  },
+  data() {
+    return {
+      key: 0,
+      isLoaded: false,
+    };
+  },
+  methods: {
+    update() {
+      this.key++;
+    },
+  },
+};
diff --git a/nicegui/elements/teleport.py b/nicegui/elements/teleport.py
new file mode 100644
index 00000000..2a91f018
--- /dev/null
+++ b/nicegui/elements/teleport.py
@@ -0,0 +1,26 @@
+from typing import Union
+
+from nicegui.element import Element
+
+
+class Teleport(Element, component='teleport.js'):
+
+    def __init__(self, to: Union[str, Element]) -> None:
+        """Teleport
+
+        An element that allows us to transmit the content from within a component to any location on the page.
+
+        :param to: NiceGUI element or CSS selector of the target element for the teleported content
+        """
+        super().__init__()
+        if isinstance(to, Element):
+            to = f'#c{to.id}'
+        self._props['to'] = to
+
+    def update(self) -> None:
+        """Force the internal content to be retransmitted to the specified location.
+
+        This method is usually called after the target container is rebuilt.
+        """
+        super().update()
+        self.run_method('update')
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index 69172950..e4275465 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -240,7 +240,7 @@ function download(src, filename, mediaType, prefix) {
   anchor.click();
   document.body.removeChild(anchor);
   if (typeof src !== "string") {
-    URL.revokeObjectURL(url);
+    URL.revokeObjectURL(anchor.href);
   }
 }
 
diff --git a/nicegui/ui.py b/nicegui/ui.py
index 2b858279..01e67d7a 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -88,6 +88,7 @@ __all__ = [
     'tab_panel',
     'tab_panels',
     'tabs',
+    'teleport',
     'textarea',
     'time',
     'timer',
@@ -212,6 +213,7 @@ from .elements.tabs import Tab as tab
 from .elements.tabs import TabPanel as tab_panel
 from .elements.tabs import TabPanels as tab_panels
 from .elements.tabs import Tabs as tabs
+from .elements.teleport import Teleport as teleport
 from .elements.textarea import Textarea as textarea
 from .elements.time import Time as time
 from .elements.timeline import Timeline as timeline
diff --git a/website/documentation/content/section_page_layout.py b/website/documentation/content/section_page_layout.py
index 7f991b55..1353a0f6 100644
--- a/website/documentation/content/section_page_layout.py
+++ b/website/documentation/content/section_page_layout.py
@@ -21,6 +21,7 @@ from . import (
     splitter_documentation,
     stepper_documentation,
     tabs_documentation,
+    teleport_documentation,
     timeline_documentation,
     tooltip_documentation,
 )
@@ -80,6 +81,7 @@ def clear_containers_demo():
     ui.button('Clear', on_click=container.clear)
 
 
+doc.intro(teleport_documentation)
 doc.intro(expansion_documentation)
 doc.intro(scroll_area_documentation)
 doc.intro(separator_documentation)
diff --git a/website/documentation/content/teleport_documentation.py b/website/documentation/content/teleport_documentation.py
new file mode 100644
index 00000000..47376009
--- /dev/null
+++ b/website/documentation/content/teleport_documentation.py
@@ -0,0 +1,17 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.teleport)
+def main_demo() -> None:
+    markdown = ui.markdown('Enter your **name**!')
+
+    def inject_input():
+        with ui.teleport(f'#c{markdown.id} strong'):
+            ui.input('name').classes('inline-flex').props('dense outlined')
+
+    ui.button('inject input', on_click=inject_input)
+
+
+doc.reference(ui.teleport)


Test patch:
diff --git a/nicegui/testing/screen.py b/nicegui/testing/screen.py
index 2fbe8a18..415fc9d6 100644
--- a/nicegui/testing/screen.py
+++ b/nicegui/testing/screen.py
@@ -219,6 +219,10 @@ class Screen:
         """Find all elements with the given HTML tag."""
         return self.selenium.find_elements(By.TAG_NAME, name)
 
+    def find_by_css(self, selector: str) -> WebElement:
+        """Find the element with the given CSS selector."""
+        return self.selenium.find_element(By.CSS_SELECTOR, selector)
+
     def render_js_logs(self) -> str:
         """Render the browser console logs as a string."""
         console = '\n'.join(log['message'] for log in self.selenium.get_log('browser'))
diff --git a/tests/test_interactive_image.py b/tests/test_interactive_image.py
index de34f0a0..ef927227 100644
--- a/tests/test_interactive_image.py
+++ b/tests/test_interactive_image.py
@@ -1,5 +1,4 @@
 from pathlib import Path
-from typing import List
 
 import pytest
 from selenium.webdriver.common.action_chains import ActionChains
@@ -88,15 +87,11 @@ def test_mousemove_event(screen: Screen, cross: bool):
 
 
 def test_loaded_event(screen: Screen):
-    sources: List[str] = []
     ii = ui.interactive_image(URL_PATH1)
-    ii.on('loaded', lambda e: sources.append(e.args['source']))
+    ii.on('loaded', lambda: ui.label('loaded'))
     ui.button('Change Source', on_click=lambda: ii.set_source(URL_PATH2))
 
     screen.open('/')
-    with screen.implicitly_wait(10.0):
-        screen.wait_for(lambda: len(sources) == 1)
     screen.click('Change Source')
-    screen.wait_for(lambda: len(sources) == 2)
-    assert sources[1].endswith(URL_PATH2)
-    assert screen.find_by_tag('img').get_attribute('src') == sources[1]
+    screen.should_contain('loaded')
+    assert (screen.find_by_tag('img').get_attribute('src') or '').endswith(URL_PATH2)
diff --git a/tests/test_teleport.py b/tests/test_teleport.py
new file mode 100644
index 00000000..f4f03379
--- /dev/null
+++ b/tests/test_teleport.py
@@ -0,0 +1,58 @@
+from typing import Optional
+
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+def test_teleport(screen: Screen):
+    ui.card().classes('card')
+
+    def create_teleport():
+        with ui.teleport('.card'):
+            ui.label('Hello')
+
+    ui.button('create', on_click=create_teleport)
+
+    screen.open('/')
+    screen.click('create')
+    assert screen.find_by_css('.card > div').text == 'Hello'
+
+
+def test_teleport_with_element(screen: Screen):
+    card = ui.card().classes('card')
+
+    def create_teleport():
+        with ui.teleport(card):
+            ui.label('Hello')
+
+    ui.button('create', on_click=create_teleport)
+
+    screen.open('/')
+    screen.click('create')
+    assert screen.find_by_css('.card > div').text == 'Hello'
+
+
+def test_update(screen: Screen):
+    teleport: Optional[ui.teleport] = None
+
+    card = ui.card().classes('card')
+
+    def create_teleport():
+        nonlocal teleport
+        with ui.teleport('.card') as teleport:
+            ui.label('Hello')
+
+    ui.button('create', on_click=create_teleport)
+
+    def rebuild_card():
+        card.delete()
+        ui.card().classes('card')
+        teleport.update()  # type: ignore
+
+    ui.button('rebuild card', on_click=rebuild_card)
+
+    screen.open('/')
+    screen.click('create')
+    screen.should_contain('Hello')
+    screen.click('rebuild card')
+    assert screen.find_by_css('.card > div').text == 'Hello'