Custom context menu

Hey all,

Someone asked on the DMC Discord whether it was possible to do actions on right click which made me think about how we could do a custom context menu with Dash.

So I built this POC with dash-mantine-components:
dmc-context-menu-2

The context menu:

  • Opens when right-clicking on given elements (this leverages the excellent dash_clientside.set_props improvement)
  • Stays in the viewport even when right-clicking towards the extremes of the window
  • Allows to keep track of the clicked element

Hope this can help the community!

And here is the code for it:

app.py

import uuid

import dash_mantine_components as dmc
from dash import (
    ALL,
    MATCH,
    ClientsideFunction,
    Dash,
    Input,
    Output,
    State,
    _dash_renderer,
    callback,
    clientside_callback,
    ctx,
    html,
)
from dash_iconify import DashIconify

_dash_renderer._set_react_version("18.2.0")


app = Dash(__name__, external_stylesheets=dmc.styles.ALL)

class ids:
    contextmenu_div = lambda idx: {"type": "contextmenu-div", "idx": idx}
    menu = "context-menu"
    menu_button = "context-menu-trigger"
    menu_label = "context-menu-label"
    menu_action = lambda action: {"type": "context-menu-action", "action": action}
    output = "output"

ACTION_ICONS = {
    "open": "carbon:arrow-up-right",
    "copy": "carbon:copy",
    "share": "carbon:share",
    "delete": "carbon:trash-can",
}

app.layout = dmc.MantineProvider(
    [
        dmc.NotificationProvider(containerWidth=200, position="top-right"),
        html.Div(id=ids.output),
        dmc.Menu(
            [
                dmc.MenuTarget(
                    html.Div(id=ids.menu_button),
                    boxWrapperProps={
                        "style": {
                            "position": "fixed",
                            "zIndex": -1,
                            "inset": 0,
                        },
                    }
                ),
                dmc.MenuDropdown(
                    [
                        dmc.MenuLabel("", id=ids.menu_label),
                        dmc.MenuItem(
                            "Open",
                            leftSection=DashIconify(icon=ACTION_ICONS["open"], height=16),
                            id=ids.menu_action("open"),
                        ),
                        dmc.MenuItem(
                            "Copy",
                            leftSection=DashIconify(icon=ACTION_ICONS["copy"], height=16),
                            id=ids.menu_action("copy"),
                        ),
                        dmc.MenuItem(
                            "Share",
                            leftSection=DashIconify(icon=ACTION_ICONS["share"], height=16),
                            id=ids.menu_action("share"),
                        ),
                        dmc.MenuDivider(),
                        dmc.MenuItem(
                            "Delete",
                            leftSection=DashIconify(icon=ACTION_ICONS["delete"], height=16),
                            id=ids.menu_action("delete"),
                        ),
                    ],
                ),
            ],
            id=ids.menu,
            position="left-start",
            shadow="sm",
            trapFocus=False,
            transitionProps={"transition": "pop-top-left"},
            width=150,
        ),
        dmc.SimpleGrid(
            [
                dmc.Paper(
                    children=dmc.Text(i + 1, fw=600, size="3rem", c="dimmed", opacity=0.5),
                    id=ids.contextmenu_div(i + 1),
                    h=250,
                    withBorder=True,
                    bg="var(--mantine-color-default-hover)",
                    style={"display": "grid", "placeItems": "center"},
                )
                for i in range(6)
            ],
            cols=3,
            spacing="1.5rem",
            p="1.5rem",
        ),
    ],
)

clientside_callback(
    ClientsideFunction(namespace="contextmenu", function_name="cardContextMenu"),
    Output(ids.contextmenu_div(MATCH), "id"),
    Input(ids.contextmenu_div(MATCH), "id"),
    State(ids.menu_button, "id"),
    State(ids.menu, "id"),
    State(ids.menu, "width"),
    State(ids.menu_label, "id"),
)

@callback(
    Output(ids.output, "children"),
    Input(ids.menu_action(ALL), "n_clicks"),
    State(ids.menu, "data-item"),
)
def update_output(_trigger, item_id):
    if not any(_trigger):
        return None

    return dmc.Notification(
        message=f"{ctx.triggered_id['action'].title()} item {item_id['idx']}",
        action="show",
        id=uuid.uuid4().hex,
        withBorder=True,
        icon=DashIconify(icon=ACTION_ICONS[ctx.triggered_id["action"]], height=16),
    )


if __name__ == "__main__":
    app.run_server(debug=True, host="localhost")

assets/scripts.js

if (!window.dash_clientside) {
    window.dash_clientside = {};
}

dash_clientside.contextmenu = {
    cardContextMenu: (divId, menuBtnId, menuId, menuWidth, menuLabelId) => {
        const stringify = (id) => typeof id === "string" ? id : JSON.stringify(id, Object.keys(id).sort())
        const el = document.getElementById(stringify(divId))
        const btn = document.getElementById(stringify(menuBtnId))
        el.oncontextmenu = (e) => {
            e.preventDefault()
            const overflowsRight = e.pageX + menuWidth + 8 >= window.innerWidth
            const props = {
                offset: {
                    mainAxis: e.pageX + (overflowsRight ? -menuWidth - 8 : 8),
                    crossAxis: e.pageY - 8,
                },
                transitionProps: {
                    transition: overflowsRight ? "pop-top-right" : "pop-top-left",
                },
                "data-item": divId,
            }
            dash_clientside.set_props(menuId, props)
            dash_clientside.set_props(menuLabelId, {children: `Item ${divId.idx}`})
            btn.click()
        }
        return dash_clientside.no_update
    }
}
8 Likes

@RenaudLN
This is awesome! Thanks so much for sharing the example. :tada:

I :heart: the way you manage the ids as well!

@RenaudLN This is cool!

1 Like

Brilliant RenauldLN, Thank you so much for sharing