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