diff --git a/solara/lab/components/__init__.py b/solara/lab/components/__init__.py
index 426522d11..767337abf 100644
--- a/solara/lab/components/__init__.py
+++ b/solara/lab/components/__init__.py
@@ -1,2 +1,3 @@
from .confirmation_dialog import ConfirmationDialog # noqa: F401
+from .menu import ClickMenu, ContextMenu, Menu # noqa: #F401 F403
from .tabs import Tab, Tabs # noqa: F401
diff --git a/solara/lab/components/menu.py b/solara/lab/components/menu.py
new file mode 100644
index 000000000..3b46d3950
--- /dev/null
+++ b/solara/lab/components/menu.py
@@ -0,0 +1,135 @@
+from typing import Dict, List, Optional, Union
+
+import solara
+from solara.components.component_vue import component_vue
+
+
+@component_vue("menu.vue")
+def MenuWidget(
+ activator: List[solara.Element],
+ children: List[solara.Element] = [],
+ show_menu: bool = False,
+ style: Optional[str] = None,
+ context: bool = False,
+ use_absolute: bool = True,
+):
+ pass
+
+
+@solara.component
+def ClickMenu(
+ activator: Union[solara.Element, List[solara.Element]],
+ children: List[solara.Element] = [],
+ style: Optional[Union[str, Dict[str, str]]] = None,
+):
+ """
+ Show a pop-up menu by clicking on the `activator` element. The menu appears at the cursor position.
+
+ ```solara
+ import solara
+
+
+ @solara.component
+ def Page():
+ image_url = "/static/public/beach.jpeg"
+ image = solara.Image(image=image_url)
+
+ with solara.lab.ClickMenu(activator=image):
+ with solara.Column(gap="0px"):
+ [solara.Button(f"Click me {i}!", text=True) for i in range(5)]
+
+ ```
+
+
+ ## Arguments
+
+ * activator: Clicking on this element will open the menu. Accepts either a `solara.Element`, or a list of elements.
+ * menu_contents: List of Elements to be contained in the menu.
+ * style: CSS style to apply. Applied directly onto the `v-menu` component.
+ """
+ show = solara.use_reactive(False)
+ style_flat = solara.util._flatten_style(style)
+
+ if not isinstance(activator, list):
+ activator = [activator]
+
+ return MenuWidget(activator=activator, children=children, show_menu=show.value, style=style_flat)
+
+
+@solara.component
+def ContextMenu(
+ activator: Union[solara.Element, List[solara.Element]],
+ children: List[solara.Element] = [],
+ style: Optional[Union[str, Dict[str, str]]] = None,
+):
+ """
+ Show a context menu by triggering the contextmenu event on the `activator` element. The menu appears at the cursor position.
+
+ A contextmenu event is typically triggered by clicking the right mouse button, or by pressing the context menu key.
+
+ ```solara
+ import solara
+
+
+ @solara.component
+ def Page():
+ image_url = "/static/public/beach.jpeg"
+ image = solara.Image(image=image_url)
+
+ with solara.lab.ContextMenu(activator=image):
+ with solara.Column(gap="0px"):
+ [solara.Button(f"Click me {i}!", text=True) for i in range(5)]
+
+ ```
+
+ ## Arguments
+
+ * activator: Clicking on this element will open the menu. Accepts either a `solara.Element`, or a list of elements.
+ * children: List of Elements to be contained in the menu
+ * style: CSS style to apply. Applied directly onto the `v-menu` component.
+ """
+ show = solara.use_reactive(False)
+ style_flat = solara.util._flatten_style(style)
+
+ if not isinstance(activator, list):
+ activator = [activator]
+
+ return MenuWidget(activator=activator, children=children, show_menu=show.value, style=style_flat, context=True)
+
+
+@solara.component
+def Menu(
+ activator: Union[solara.Element, List[solara.Element]],
+ children: List[solara.Element] = [],
+ style: Optional[Union[str, Dict[str, str]]] = None,
+):
+ """
+ Show a pop-up menu by clicking on the `activator` element. The menu appears below the `activator` element.
+
+ ```solara
+ import solara
+
+
+ @solara.component
+ def Page():
+ btn = solara.Button("Show suboptions")
+
+ with solara.lab.Menu(activator=btn):
+ with solara.Column(gap="0px"):
+ [solara.Button(f"Click me {str(i)}!", text=True) for i in range(5)]
+
+ ```
+
+ ## Arguments
+
+ * activator: Clicking on this element will open the menu. Accepts either a `solara.Element`, or a list of elements.
+ * children: List of Elements to be contained in the menu
+ * style: CSS style to apply. Applied directly onto the `v-menu` component.
+ """
+ show = solara.use_reactive(False)
+ style_flat = solara.util._flatten_style(style)
+
+ if not isinstance(activator, list):
+ activator = [activator]
+
+ return MenuWidget(activator=activator, children=children, show_menu=show.value, style=style_flat, use_absolute=False)
diff --git a/solara/lab/components/menu.vue b/solara/lab/components/menu.vue
new file mode 100644
index 000000000..4febd8e2c
--- /dev/null
+++ b/solara/lab/components/menu.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/solara/website/pages/api/__init__.py b/solara/website/pages/api/__init__.py
index 6e6a152b9..46d1da94a 100644
--- a/solara/website/pages/api/__init__.py
+++ b/solara/website/pages/api/__init__.py
@@ -116,9 +116,10 @@
"name": "Lab (experimental)",
"icon": "mdi-flask-outline",
"pages": [
+ "confirmation_dialog",
+ "menu",
"tab",
"tabs",
- "confirmation_dialog",
],
},
]
diff --git a/solara/website/pages/api/menu.py b/solara/website/pages/api/menu.py
new file mode 100644
index 000000000..7d53443ed
--- /dev/null
+++ b/solara/website/pages/api/menu.py
@@ -0,0 +1,22 @@
+"""
+# Menus
+
+This page contains the various kinds of menu elements available to use in solara
+
+# Menu
+"""
+import solara
+from solara.website.utils import apidoc
+
+from . import NoPage
+
+title = "Menus"
+
+
+__doc__ += apidoc(solara.lab.components.menu.Menu.f) # type: ignore
+__doc__ += "# ClickMenu"
+__doc__ += apidoc(solara.lab.components.menu.ClickMenu.f) # type: ignore
+__doc__ += "# ContextMenu"
+__doc__ += apidoc(solara.lab.components.menu.ContextMenu.f) # type: ignore
+
+Page = NoPage
diff --git a/tests/integration/menu_test.py b/tests/integration/menu_test.py
new file mode 100644
index 000000000..3d2224118
--- /dev/null
+++ b/tests/integration/menu_test.py
@@ -0,0 +1,90 @@
+import pytest
+from playwright.sync_api import Page, expect
+
+# Tests are only run in solara, since others don't support parametrization.
+
+
+# Tests rendering of menus, as well as click functionality on child element
+@pytest.mark.parametrize("test_type", ["Menu", "ClickMenu", "ContextMenu"])
+def test_menus_playwright(test_type, solara_test, page_session: Page, assert_solara_snapshot):
+ import solara
+ from solara.lab import ClickMenu, ContextMenu, Menu
+
+ @solara.component
+ def Page():
+ # without height dropdown menu won't be visible in screenshots
+ with solara.Column(classes=["test-class-container"], style="height:300px; width: 400px;"):
+ text = solara.use_reactive("pre-test")
+
+ def on_click():
+ text.set("Works!")
+
+ solara.Text(text.value, classes=["test-class-text"])
+
+ menu_activator = solara.Button("activator")
+ menu_child = solara.Button("child", on_click=on_click, classes=["test-class-child"])
+
+ if test_type == "Menu":
+ Menu(activator=menu_activator, children=[menu_child])
+ elif test_type == "ClickMenu":
+ ClickMenu(activator=menu_activator, children=[menu_child])
+ else:
+ ContextMenu(activator=menu_activator, children=[menu_child])
+
+ solara.display(Page())
+
+ text_el = page_session.locator(".test-class-text")
+ expect(text_el).to_contain_text("pre-test")
+ activator_button = page_session.locator("text=activator")
+ expect(page_session.locator(".test-class-child")).to_have_count(0)
+ if test_type == "ContextMenu":
+ activator_button.click(button="right")
+ else:
+ activator_button.click()
+
+ child_button = page_session.locator("text=child")
+ page_session.wait_for_timeout(350) # Wait for any animations after click
+ expect(page_session.locator(".test-class-child")).to_be_visible()
+ child_button.click()
+ page_session.wait_for_timeout(350)
+ expect(text_el).to_contain_text("Works!")
+
+
+# Tests successive click behaviour of menus
+@pytest.mark.parametrize("test_type", ["Menu", "ClickMenu", "ContextMenu"])
+def test_menus_successive(test_type, solara_test, page_session: Page):
+ import solara
+ from solara.lab import ClickMenu, ContextMenu, Menu
+
+ @solara.component
+ def Page():
+ menu_activator = solara.Div(style="height: 200px; width: 400px;", classes=["test-class-activator"])
+ menu_child = solara.Div(classes=["test-class-menu"], style="height: 100px; width: 50px;")
+
+ if test_type == "Menu":
+ Menu(activator=menu_activator, children=[menu_child])
+ elif test_type == "ClickMenu":
+ ClickMenu(activator=menu_activator, children=[menu_child])
+ else:
+ ContextMenu(activator=menu_activator, children=[menu_child])
+
+ solara.display(Page())
+
+ activator_el = page_session.locator(".test-class-activator")
+ expect(page_session.locator(".test-class-menu")).to_have_count(0) # Menu should not exist yet
+ if test_type == "ContextMenu":
+ activator_el.click(button="right")
+ else:
+ activator_el.click()
+
+ page_session.wait_for_timeout(350) # Wait for any animations after click
+ expect(page_session.locator(".test-class-menu")).to_be_in_viewport() # Menu should be visible now
+
+ if test_type == "ContextMenu":
+ activator_el.click(button="right")
+ page_session.wait_for_timeout(350)
+ expect(page_session.locator(".test-class-menu")).to_be_in_viewport()
+ else:
+ activator_el.click()
+ page_session.wait_for_timeout(350)
+ expect(page_session.locator(".test-class-menu")).not_to_be_in_viewport() # After second click, Menu and ClickMenu should disappear