Repository: zauberzeug/nicegui
Pull request #1801 (2023-10-14 19:58:37)

Introduce `ui.context_menu`
This PR introduces a dedicated context menu, since `ui.menu` doesn't work well with `.props('context-menu touch-position')` as discussed in #1738.

Open questions:
- [x] Should we add a warning if someone tries to use `ui.menu` as a context menu?
- [x] Should we move the context menu demo into a separate file context_menu_documentation.py, i.e. onto a separate page?


Patch:
diff --git a/nicegui/elements/context_menu.py b/nicegui/elements/context_menu.py
new file mode 100644
index 00000000..35ef98ca
--- /dev/null
+++ b/nicegui/elements/context_menu.py
@@ -0,0 +1,15 @@
+from ..element import Element
+
+
+class ContextMenu(Element):
+
+    def __init__(self) -> None:
+        """Context Menu
+
+        Creates a context menu based on Quasar's `QMenu <https://quasar.dev/vue-components/menu>`_ component.
+        The context menu should be placed inside the element where it should be shown.
+        It is automatically opened when the user right-clicks on the element and appears at the mouse position.
+        """
+        super().__init__('q-menu')
+        self._props['context-menu'] = True
+        self._props['touch-position'] = True
diff --git a/nicegui/elements/menu.py b/nicegui/elements/menu.py
index ad8d4c36..a136a8ef 100644
--- a/nicegui/elements/menu.py
+++ b/nicegui/elements/menu.py
@@ -1,5 +1,7 @@
 from typing import Any, Callable, Optional
 
+from typing_extensions import Self
+
 from .. import globals  # pylint: disable=redefined-builtin
 from ..events import ClickEventArguments, handle_event
 from .mixins.text_element import TextElement
@@ -30,6 +32,15 @@ class Menu(ValueElement):
         """Toggle the menu."""
         self.value = not self.value
 
+    def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
+        super().props(add, remove=remove)
+        if 'touch-position' in self._props:
+            # https://github.com/zauberzeug/nicegui/issues/1738
+            del self._props['touch-position']
+            globals.log.warning('The prop "touch-position" is not supported by `ui.menu`.\n'
+                                'Use "ui.context_menu()" instead.')
+        return self
+
 
 class MenuItem(TextElement):
 
diff --git a/nicegui/ui.py b/nicegui/ui.py
index d9ae9eaf..c311d7df 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -18,6 +18,7 @@ __all__ = [
     'color_picker',
     'colors',
     'column',
+    'context_menu',
     'dark_mode',
     'date',
     'dialog',
@@ -115,6 +116,7 @@ from .elements.color_input import ColorInput as color_input
 from .elements.color_picker import ColorPicker as color_picker
 from .elements.colors import Colors as colors
 from .elements.column import Column as column
+from .elements.context_menu import ContextMenu as context_menu
 from .elements.dark_mode import DarkMode as dark_mode
 from .elements.date import Date as date
 from .elements.dialog import Dialog as dialog
diff --git a/website/documentation.py b/website/documentation.py
index 47736754..38c6ccb2 100644
--- a/website/documentation.py
+++ b/website/documentation.py
@@ -187,6 +187,7 @@ def create_full() -> None:
     load_demo(ui.timeline)
     load_demo(ui.carousel)
     load_demo(ui.menu)
+    load_demo(ui.context_menu)
 
     @text_demo('Tooltips', '''
         Simply call the `tooltip(text:str)` method on UI elements to provide a tooltip.
diff --git a/website/more_documentation/context_menu_documentation.py b/website/more_documentation/context_menu_documentation.py
new file mode 100644
index 00000000..9aead2d4
--- /dev/null
+++ b/website/more_documentation/context_menu_documentation.py
@@ -0,0 +1,10 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    with ui.image('https://picsum.photos/id/377/640/360'):
+        with ui.context_menu():
+            ui.menu_item('Flip horizontally')
+            ui.menu_item('Flip vertically')
+            ui.separator()
+            ui.menu_item('Reset')
diff --git a/website/more_documentation/menu_documentation.py b/website/more_documentation/menu_documentation.py
index 23eb9e3a..87f96a8e 100644
--- a/website/more_documentation/menu_documentation.py
+++ b/website/more_documentation/menu_documentation.py
@@ -1,7 +1,5 @@
 from nicegui import ui
 
-from ..documentation_tools import text_demo
-
 
 def main_demo() -> None:
     with ui.row().classes('w-full items-center'):
@@ -14,18 +12,3 @@ def main_demo() -> None:
                              lambda: result.set_text('Selected item 3'), auto_close=False)
                 ui.separator()
                 ui.menu_item('Close', on_click=menu.close)
-
-
-def more() -> None:
-    @text_demo('Custom Context Menu', '''
-        Using [Quasar's `context-menu`](https://quasar.dev/vue-components/menu#context-menu) and `touch-position` props, 
-        you can create custom context menus. 
-        These open by right-clicking on the parent.
-    ''')
-    def custom_context_menu() -> None:
-        with ui.image('https://picsum.photos/id/377/640/360'):
-            with ui.menu().props('context-menu touch-position'):
-                ui.menu_item('Flip horizontally')
-                ui.menu_item('Flip vertically')
-                ui.separator()
-                ui.menu_item('Reset')


Test patch:
diff --git a/tests/screen.py b/tests/screen.py
index 8e4b566b..3546d32a 100644
--- a/tests/screen.py
+++ b/tests/screen.py
@@ -119,6 +119,12 @@ class Screen:
             raise AssertionError(f'Could not click on "{target_text}" on:\n{element.get_attribute("outerHTML")}') from e
         return element
 
+    def context_click(self, target_text: str) -> WebElement:
+        element = self.find(target_text)
+        action = ActionChains(self.selenium)
+        action.context_click(element).perform()
+        return element
+
     def click_at_position(self, element: WebElement, x: int, y: int) -> None:
         action = ActionChains(self.selenium)
         action.move_to_element_with_offset(element, x, y).click().perform()
diff --git a/tests/test_context_menu.py b/tests/test_context_menu.py
new file mode 100644
index 00000000..9ad936e7
--- /dev/null
+++ b/tests/test_context_menu.py
@@ -0,0 +1,14 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_context_menu(screen: Screen):
+    with ui.label('Right-click me'):
+        with ui.context_menu():
+            ui.menu_item('Item 1')
+            ui.menu_item('Item 2')
+
+    screen.open('/')
+    screen.context_click('Right-click me')
+    screen.should_contain('Item 1')
diff --git a/tests/test_menu.py b/tests/test_menu.py
new file mode 100644
index 00000000..649f5dec
--- /dev/null
+++ b/tests/test_menu.py
@@ -0,0 +1,15 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_menu(screen: Screen):
+    with ui.button('Menu'):
+        with ui.menu():
+            ui.menu_item('Item 1')
+            ui.menu_item('Item 2')
+            ui.menu_item('Item 3')
+
+    screen.open('/')
+    screen.click('Menu')
+    screen.should_contain('Item 1')