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 +1,2 @@
from .menu import ClickMenu, ContextMenu, Menu # noqa: #F401 F403
from .tabs import Tab, Tabs # noqa: F401
143 changes: 143 additions & 0 deletions solara/lab/components/menu.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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,
):
"""
A pop-up menu activated by clicking on an element. `ClickMenu` appears at the cursor position.
iisakkirotko marked this conversation as resolved.
Show resolved Hide resolved

```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,
style="row-gap: 0;",
):
with solara.Column():
[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,
):
"""
Opens a context menu when the contextmenu event is triggered on the element `activator`.
`ContextMenu` also renders the activator element, so rendering it is not necessary separately.
iisakkirotko marked this conversation as resolved.
Show resolved Hide resolved

```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,
style="row-gap: 0;",
):
with solara.Column():
[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,
):
"""
Opens a menu when the `activator` element is clicked. Is attached to the bottom of the `activator` component.
iisakkirotko marked this conversation as resolved.
Show resolved Hide resolved

```solara
import solara


@solara.component
def Page():
btn = solara.Button("Show suboptions")

with solara.lab.Menu(
activator=btn,
style="row-gap: 0;",
):
with solara.Column():
[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)
40 changes: 40 additions & 0 deletions solara/lab/components/menu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<template>
<div style="width: fit-content;">
<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">
<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>
</div>
</template>

<script>
module.exports = {
methods: {
show(e, on) {
this.show_menu = false;
iisakkirotko marked this conversation as resolved.
Show resolved Hide resolved
this.$nextTick(() => {
on.click(e)
})
}
}
}
</script>
1 change: 1 addition & 0 deletions solara/website/pages/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"pages": [
"tab",
"tabs",
"menu",
],
},
]
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