Skip to content

Commit

Permalink
feat: Menu, ClickMenu, and ContextMenu components for solara.lab (#295)
Browse files Browse the repository at this point in the history
Allows popup menu's for buttons, context menus on right click, or normal click

Co-authored-by: Maarten A. Breddels <[email protected]>
  • Loading branch information
iisakkirotko and maartenbreddels committed Oct 10, 2023
1 parent 805da82 commit 90e766d
Show file tree
Hide file tree
Showing 6 changed files with 292 additions and 1 deletion.
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;
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

0 comments on commit 90e766d

Please sign in to comment.