Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Menu, ClickMenu, and ContextMenu components for solara.lab #295

Merged
merged 10 commits into from
Oct 10, 2023
1 change: 1 addition & 0 deletions solara/lab/components/__init__.py
Original file line number Diff line number Diff line change
@@ -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
135 changes: 135 additions & 0 deletions solara/lab/components/menu.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 42 additions & 0 deletions solara/lab/components/menu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<template>
<v-menu
v-model="show_menu"
:absolute="use_absolute"
offset-y
>
<template v-if="context" v-slot:activator="{ on }">
<div v-for="(element, index) in activator"
:key="index"
@contextmenu.prevent="show($event, on)">
<jupyter-widget :widget="element"></jupyter-widget>
</div>
</template>
<template v-else v-slot:activator="{ on }">
<div v-for="(element, index) in activator"
:key="index"
v-on="on"
style="width: fit-content;"
>
<jupyter-widget :widget="element"></jupyter-widget>
</div>
</template>
<v-list v-for="(element, index) in children" :key="index" style="padding: 0;">
<jupyter-widget :widget="element" :style="style" ></jupyter-widget>
</v-list>
</v-menu>
</template>

<script>
module.exports = {
methods: {
show(e, on) {
// hide menu, and trigger the event on the next tick, otherwise vue does not see
// `show_menu` changing and will no do any animation
this.show_menu = false;
iisakkirotko marked this conversation as resolved.
Show resolved Hide resolved
this.$nextTick(() => {
on.click(e)
})
}
}
}
</script>
3 changes: 2 additions & 1 deletion solara/website/pages/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,10 @@
"name": "Lab (experimental)",
"icon": "mdi-flask-outline",
"pages": [
"confirmation_dialog",
"menu",
"tab",
"tabs",
"confirmation_dialog",
],
},
]
Expand Down
22 changes: 22 additions & 0 deletions solara/website/pages/api/menu.py
Original file line number Diff line number Diff line change
@@ -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
90 changes: 90 additions & 0 deletions tests/integration/menu_test.py
Original file line number Diff line number Diff line change
@@ -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
Loading