Repository: zauberzeug/nicegui
Pull request #3175 (2024-06-06 05:28:44)

Introduce `align_items` parameter for `ui.row`, `ui.column` and `ui.card`
Inspired by question #3174, this PR introduces a parameter to control how items are aligned within common layout elements. It is based on Tailwind's ["Align Items"](https://tailwindcss.com/docs/align-items) classes.

```py
with ui.row(align_items='center'):
    ui.button('Button')
    ui.label('Label')

with ui.column(align_items='center'):
    ui.button('Button')
    ui.label('Label')

with ui.card(align_items='center'):
    ui.button('Button')
    ui.label('Label')
```


Patch:
diff --git a/nicegui/elements/card.py b/nicegui/elements/card.py
index 4f87dc22..02a5fde9 100644
--- a/nicegui/elements/card.py
+++ b/nicegui/elements/card.py
@@ -1,3 +1,5 @@
+from typing import Literal, Optional
+
 from typing_extensions import Self
 
 from ..element import Element
@@ -5,7 +7,9 @@ from ..element import Element
 
 class Card(Element):
 
-    def __init__(self) -> None:
+    def __init__(self, *,
+                 align_items: Optional[Literal['start', 'end', 'center', 'baseline', 'stretch']] = None,
+                 ) -> None:
         """Card
 
         This element is based on Quasar's `QCard <https://quasar.dev/vue-components/card>`_ component.
@@ -16,9 +20,13 @@ class Card(Element):
         In contrast to this element, the original QCard has no padding by default and hides outer borders of nested elements.
         If you want the original behavior, use the `tight` method.
         If you want the padding and borders for nested children, move the children into another container.
+
+        :param align_items: alignment of the items in the card (default: `None`)
         """
         super().__init__('q-card')
         self._classes.append('nicegui-card')
+        if align_items:
+            self._classes.append(f'items-{align_items}')
 
     def tight(self) -> Self:
         """Remove padding and gaps between nested elements."""
diff --git a/nicegui/elements/colors.py b/nicegui/elements/colors.py
index fff7d2d7..fd351889 100644
--- a/nicegui/elements/colors.py
+++ b/nicegui/elements/colors.py
@@ -14,7 +14,7 @@ class Colors(Element, component='colors.js'):
                  warning='#f2c037') -> None:
         """Color Theming
 
-        Sets the main colors (primary, secondary, accent, ...) used by `Quasar <https://quasar.dev/>`_.
+        Sets the main colors (primary, secondary, accent, ...) used by `Quasar <https://quasar.dev/style/theme-builder>`_.
         """
         super().__init__()
         self._props['primary'] = primary
diff --git a/nicegui/elements/column.py b/nicegui/elements/column.py
index 67b4daae..c67ac473 100644
--- a/nicegui/elements/column.py
+++ b/nicegui/elements/column.py
@@ -1,17 +1,25 @@
+from typing import Literal, Optional
+
 from ..element import Element
 
 
 class Column(Element):
 
-    def __init__(self, *, wrap: bool = False) -> None:
+    def __init__(self, *,
+                 wrap: bool = False,
+                 align_items: Optional[Literal['start', 'end', 'center', 'baseline', 'stretch']] = None,
+                 ) -> None:
         """Column Element
 
         Provides a container which arranges its child in a column.
 
         :param wrap: whether to wrap the content (default: `False`)
+        :param align_items: alignment of the items in the column (default: `None`)
         """
         super().__init__('div')
         self._classes.append('nicegui-column')
+        if align_items:
+            self._classes.append(f'items-{align_items}')
 
         if wrap:
             self._style['flex-wrap'] = 'wrap'
diff --git a/nicegui/elements/mermaid.js b/nicegui/elements/mermaid.js
index 35adcc74..3773a45b 100644
--- a/nicegui/elements/mermaid.js
+++ b/nicegui/elements/mermaid.js
@@ -9,9 +9,18 @@ export default {
     last_content: "",
   }),
   mounted() {
+    this.initialize();
     this.update(this.content);
   },
   methods: {
+    initialize() {
+      try {
+        mermaid.initialize(this.config || {});
+      } catch (error) {
+        console.error(error);
+        this.$emit("error", error);
+      }
+    },
     async update(content) {
       if (this.last_content === content) return;
       this.last_content = content;
@@ -32,6 +41,7 @@ export default {
     },
   },
   props: {
+    config: Object,
     content: String,
   },
 };
diff --git a/nicegui/elements/mermaid.py b/nicegui/elements/mermaid.py
index 6123abbf..3af67ce0 100644
--- a/nicegui/elements/mermaid.py
+++ b/nicegui/elements/mermaid.py
@@ -1,3 +1,5 @@
+from typing import Dict, Optional
+
 from .mixins.content_element import ContentElement
 
 
@@ -7,15 +9,25 @@ class Mermaid(ContentElement,
               extra_libraries=['lib/mermaid/*.js']):
     CONTENT_PROP = 'content'
 
-    def __init__(self, content: str) -> None:
+    def __init__(self, content: str, config: Optional[Dict] = None) -> None:
         """Mermaid Diagrams
 
         Renders diagrams and charts written in the Markdown-inspired `Mermaid <https://mermaid.js.org/>`_ language.
         The mermaid syntax can also be used inside Markdown elements by providing the extension string 'mermaid' to the ``ui.markdown`` element.
 
+        The optional configuration dictionary is passed directly to mermaid before the first diagram is rendered.
+        This can be used to set such options as
+
+            ``{'securityLevel': 'loose', ...}`` - allow running JavaScript when a node is clicked
+            ``{'logLevel': 'info', ...}`` - log debug info to the console
+
+        Refer to the Mermaid documentation for the ``mermaid.initialize()`` method for a full list of options.
+
         :param content: the Mermaid content to be displayed
+        :param config: configuration dictionary to be passed to ``mermaid.initialize()``
         """
         super().__init__(content=content)
+        self._props['config'] = config
 
     def _handle_content_change(self, content: str) -> None:
         self._props[self.CONTENT_PROP] = content.strip()
diff --git a/nicegui/elements/row.py b/nicegui/elements/row.py
index d2c524ca..f138a476 100644
--- a/nicegui/elements/row.py
+++ b/nicegui/elements/row.py
@@ -1,17 +1,26 @@
+from typing import Literal, Optional
+
 from ..element import Element
 
 
 class Row(Element):
 
-    def __init__(self, *, wrap: bool = True) -> None:
+    def __init__(self, *,
+                 wrap: bool = True,
+                 align_items: Optional[Literal['start', 'end', 'center', 'baseline', 'stretch']] = None,
+                 ) -> None:
         """Row Element
 
         Provides a container which arranges its child in a row.
 
         :param wrap: whether to wrap the content (default: `True`)
+        :param align_items: alignment of the items in the row (default: `None`)
         """
         super().__init__('div')
-        self._classes.append('nicegui-row row')  # NOTE: 'row' class for compatibility with Quasar's col-* classes
+        self._classes.append('nicegui-row')
+        self._classes.append('row')  # NOTE: for compatibility with Quasar's col-* classes
+        if align_items:
+            self._classes.append(f'items-{align_items}')
 
         if not wrap:
             self._style['flex-wrap'] = 'nowrap'
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/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/mermaid_documentation.py b/website/documentation/content/mermaid_documentation.py
index 1920c77f..6a1f84bc 100644
--- a/website/documentation/content/mermaid_documentation.py
+++ b/website/documentation/content/mermaid_documentation.py
@@ -10,6 +10,21 @@ def main_demo() -> None:
         A --> B;
         A --> C;
     ''')
+    # END OF DEMO
+    list(ui.context.client.elements.values())[-1]._props['config'] = {'securityLevel': 'loose'}  # HACK: for click_demo
+
+
+@doc.demo('Handle click events', '''
+    You can register to click events by adding a `click` directive to a node and emitting a custom event.
+    Make sure to set the `securityLevel` to `loose` in the `config` parameter to allow JavaScript execution.
+''')
+def click_demo() -> None:
+    ui.mermaid('''
+    graph LR;
+        A((Click me!));
+        click A call emitEvent("mermaid_click", "You clicked me!")
+    ''', config={'securityLevel': 'loose'})
+    ui.on('mermaid_click', lambda e: ui.notify(e.args))
 
 
 @doc.demo('Handle errors', '''
@@ -22,6 +37,8 @@ def error_demo() -> None:
         A --> B;
         A -> C;
     ''').on('error', lambda e: print(e.args['message']))
+    # END OF DEMO
+    list(ui.context.client.elements.values())[-1]._props['config'] = {'securityLevel': 'loose'}  # HACK: for click_demo
 
 
 doc.reference(ui.mermaid)
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_mermaid.py b/tests/test_mermaid.py
index 850d9359..e21aaa59 100644
--- a/tests/test_mermaid.py
+++ b/tests/test_mermaid.py
@@ -1,3 +1,5 @@
+import pytest
+
 from nicegui import ui
 from nicegui.testing import Screen
 
@@ -75,3 +77,20 @@ def test_error(screen: Screen):
     screen.open('/')
     screen.should_contain('Syntax error in text')
     screen.should_contain('Parse error on line 3')
+
+
+@pytest.mark.parametrize('security_level', ['loose', 'strict'])
+def test_click_mermaid_node(security_level: str, screen: Screen):
+    ui.mermaid('''
+        flowchart TD;
+            A;
+            click A call document.write("Success")
+    ''', config={'securityLevel': security_level})
+
+    screen.open('/')
+    screen.click('A')
+    screen.wait(0.5)
+    if security_level == 'loose':
+        screen.should_contain('Success')
+    else:
+        screen.should_not_contain('Success')
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'