Interactions & Selection
Clicks, context menus, connection validation and limits, and multi-select.
Overview
dash-flows surfaces user interaction as callback props: clickedNode, doubleClickedNode, hoveredNode, contextMenuNode, selectedNodes, selectedEdges, and lastConnection. You can validate or cap connections before they are committed.
Live demo
Drag the nodes, pan, and zoom — the canvas below is a real, running DashFlows component (no callbacks, just the rendered graph):
# File: docs/interactions/demo.py
"""Live, callback-free demo for the docs page. Rendered via `.. exec::docs.interactions.demo`."""
import dash_flows
nodes = [{'id': 'q', 'type': 'input', 'data': {'label': 'Request'}, 'position': {'x': 60, 'y': 40}},
{'id': 'v', 'type': 'default', 'data': {'label': 'Validate'}, 'position': {'x': 60, 'y': 170}},
{'id': 'p', 'type': 'default', 'data': {'label': 'Persist'}, 'position': {'x': 240, 'y': 170}},
{'id': 'r', 'type': 'output', 'data': {'label': 'Respond'}, 'position': {'x': 150, 'y': 300}}]
edges = [{'id': 'qv', 'source': 'q', 'target': 'v'},
{'id': 'vp', 'source': 'v', 'target': 'p'},
{'id': 'pr', 'source': 'p', 'target': 'r'},
{'id': 'vr', 'source': 'v', 'target': 'r'}]
component = dash_flows.DashFlows(
id="interactions-demo",
nodes=nodes,
edges=edges,
style={'border': '1px solid var(--mantine-color-default-border)',
'borderRadius': '8px',
'height': '440px'},
showControls=True,
showMiniMap=True,
fitView=True,
colorMode='system',
)
:defaultExpanded: false :withExpandedButton: true
Examples
Each example below is a complete, runnable Dash app from the examples/ folder. Run any of them with python examples/<file>.
Node Interactions
Read click, drag, selection, and right-click (context-menu) events on nodes in real time — useful whenever the rest of your UI needs to react to what the user is doing on the canvas.
# File: examples/07_node_interactions.py
"""
Example 07: Node Interactions
=============================
This example demonstrates node interaction callbacks:
- Node click events
- Node drag events
- Node hover events (via CSS)
- Context menu (right-click) events
- Selection change events
"""
import dash
from dash import html, Input, Output, State, callback
import dash_flows
import dash_mantine_components as dmc
import json
app = dash.Dash(__name__, suppress_callback_exceptions=True)
initial_nodes = [
{"id": "1", "type": "default", "data": {"label": "Click Me"}, "position": {"x": 100, "y": 50}},
{"id": "2", "type": "default", "data": {"label": "Drag Me"}, "position": {"x": 300, "y": 50}},
{"id": "3", "type": "default", "data": {"label": "Right-Click Me"}, "position": {"x": 500, "y": 50}},
{"id": "4", "type": "default", "data": {"label": "Node 4"}, "position": {"x": 100, "y": 200}},
{"id": "5", "type": "default", "data": {"label": "Node 5"}, "position": {"x": 300, "y": 200}},
{"id": "6", "type": "default", "data": {"label": "Node 6"}, "position": {"x": 500, "y": 200}},
]
initial_edges = [
{"id": "e1-4", "source": "1", "target": "4"},
{"id": "e2-5", "source": "2", "target": "5"},
{"id": "e3-6", "source": "3", "target": "6"},
]
app.layout = dmc.MantineProvider([
html.H1("Node Interactions Example"),
html.P("Interact with nodes and observe the callbacks."),
dmc.Grid([
dmc.GridCol([
dash_flows.DashFlows(
id="interaction-flow",
nodes=initial_nodes,
edges=initial_edges,
style={"height": "400px", "border": "1px solid #ddd"},
fitView=True,
showControls=True,
# Enable interactions
nodesDraggable=True,
nodesConnectable=True,
elementsSelectable=True,
# Enable multi-select with Shift key
multiSelectionKeyCode="Shift",
),
], span=8),
dmc.GridCol([
dmc.Stack([
dmc.Paper([
dmc.Text("Last Click Event:", fw=600),
html.Pre(id="click-event", children="Click a node...",
style={"fontSize": "11px", "maxHeight": "100px", "overflow": "auto"}),
], p="sm", withBorder=True),
dmc.Paper([
dmc.Text("Node Positions:", fw=600),
html.Pre(id="position-info", children="Drag a node...",
style={"fontSize": "11px", "maxHeight": "150px", "overflow": "auto"}),
], p="sm", withBorder=True),
dmc.Paper([
dmc.Text("Selection Info:", fw=600),
html.Pre(id="selection-info", children="Select nodes...",
style={"fontSize": "11px", "maxHeight": "100px", "overflow": "auto"}),
], p="sm", withBorder=True),
dmc.Paper([
dmc.Text("Context Menu (Right-Click):", fw=600),
html.Pre(id="context-menu-info", children="Right-click a node...",
style={"fontSize": "11px", "maxHeight": "100px", "overflow": "auto"}),
], p="sm", withBorder=True),
], gap="sm"),
], span=4),
]),
dmc.Space(h=20),
dmc.Text("Tips:", fw=600),
dmc.List([
dmc.ListItem("Click on any node to see click event data"),
dmc.ListItem("Drag nodes to see position updates"),
dmc.ListItem("Hold Shift and click to multi-select nodes"),
dmc.ListItem("Use the selection box (drag on canvas) to select multiple nodes"),
dmc.ListItem("Right-click a node to see context menu event data"),
]),
])
@callback(
Output("click-event", "children"),
Input("interaction-flow", "selectedNodes"),
prevent_initial_call=True,
)
def on_node_click(selected_nodes):
if not selected_nodes:
return "No nodes selected"
# Show the last selected node
return json.dumps(selected_nodes, indent=2)
@callback(
Output("position-info", "children"),
Input("interaction-flow", "nodes"),
prevent_initial_call=True,
)
def on_node_drag(nodes):
if not nodes:
return "No nodes"
positions = {n["id"]: n["position"] for n in nodes}
return json.dumps(positions, indent=2)
def extract_ids(items):
"""Extract IDs from selection data, handling different formats."""
if not items:
return []
result = []
for item in items:
if isinstance(item, dict) and "id" in item:
result.append(item["id"])
elif isinstance(item, str):
result.append(item)
return result
@callback(
Output("selection-info", "children"),
Input("interaction-flow", "selectedNodes"),
Input("interaction-flow", "selectedEdges"),
)
def on_selection_change(selected_nodes, selected_edges):
info = {
"selectedNodes": extract_ids(selected_nodes),
"selectedEdges": extract_ids(selected_edges),
}
return json.dumps(info, indent=2)
@callback(
Output("context-menu-info", "children"),
Input("interaction-flow", "contextMenuNode"),
prevent_initial_call=True,
)
def on_context_menu(context_menu_node):
if not context_menu_node:
return "Right-click a node..."
# Show context menu event data
return json.dumps(context_menu_node, indent=2)
if __name__ == "__main__":
app.run(debug=True, port=8066)
:defaultExpanded: false :withExpandedButton: true
How it works
Input("ex07-flow", "selectedNodes")— fires whenever the node selection changes; the payload is the list of currently-selected node objects.Input("ex07-flow", "nodes")— fires on every node change, including drags, so you can read livepositiondata.Input("ex07-flow", "selectedEdges")— pairs withselectedNodesto report the full current selection (nodes and edges together).Input("ex07-flow", "contextMenuNode")— fires when a node is right-clicked, with the node'sidanddatain the payload.multiSelectionKeyCode="Shift"— lets Shift+click add nodes to the current selection instead of replacing it.
Connection Validation
Reject invalid connections before they are created — self-connections and duplicate edges are blocked automatically, and source/target-only node types (input / output) restrict which end of a connection they can be.
# File: examples/08_connection_validation.py
"""
Example 08: Connection Validation and Rules
===========================================
This example demonstrates connection validation:
- Preventing self-connections
- Limiting connections per handle
- Custom connection rules
- Visual feedback during connection
"""
import dash
from dash import html, Input, Output, callback, clientside_callback
import dash_flows
import dash_mantine_components as dmc
import json
app = dash.Dash(__name__)
# Define nodes with specific connection rules
nodes = [
# Input nodes (sources only)
{"id": "input-1", "type": "input", "data": {"label": "Data Source A"}, "position": {"x": 50, "y": 50}},
{"id": "input-2", "type": "input", "data": {"label": "Data Source B"}, "position": {"x": 50, "y": 150}},
# Processing nodes (can connect to each other)
{"id": "process-1", "type": "default", "data": {"label": "Filter"}, "position": {"x": 250, "y": 50}},
{"id": "process-2", "type": "default", "data": {"label": "Transform"}, "position": {"x": 250, "y": 150}},
{"id": "process-3", "type": "default", "data": {"label": "Aggregate"}, "position": {"x": 450, "y": 100}},
# Output nodes (targets only)
{"id": "output-1", "type": "output", "data": {"label": "Database"}, "position": {"x": 650, "y": 50}},
{"id": "output-2", "type": "output", "data": {"label": "API"}, "position": {"x": 650, "y": 150}},
]
initial_edges = [
{"id": "e1", "source": "input-1", "target": "process-1"},
{"id": "e2", "source": "input-2", "target": "process-2"},
]
app.layout = dmc.MantineProvider([
html.H1("Connection Validation Example"),
html.P("Try connecting nodes and observe the validation rules in action."),
dmc.Alert([
dmc.Text("Connection Rules:", fw=600),
dmc.List([
dmc.ListItem("Input nodes can only be sources (green)"),
dmc.ListItem("Output nodes can only be targets (purple)"),
dmc.ListItem("Self-connections are not allowed"),
dmc.ListItem("Duplicate connections are prevented"),
], size="sm"),
], color="blue", variant="light", style={"marginBottom": 20}),
dash_flows.DashFlows(
id="validation-flow",
nodes=nodes,
edges=initial_edges,
style={"height": "400px", "border": "1px solid #ddd"},
fitView=True,
showControls=True,
# Connection settings
nodesConnectable=True,
connectionMode="loose", # Allow connections to any point on node
# Visual feedback
connectionLineStyle={"stroke": "#3b82f6", "strokeWidth": 2},
),
dmc.Space(h=20),
dmc.Paper([
dmc.Text("Current Edges:", fw=600),
html.Pre(id="edges-display", style={"fontSize": "11px", "maxHeight": "200px", "overflow": "auto"}),
], p="md", withBorder=True),
dmc.Space(h=10),
dmc.Group([
dmc.Button("Add Random Connection", id="add-edge-btn", variant="outline"),
dmc.Button("Clear All Edges", id="clear-edges-btn", variant="outline", color="red"),
]),
])
@callback(
Output("edges-display", "children"),
Input("validation-flow", "edges"),
)
def display_edges(edges):
if not edges:
return "No edges"
edge_info = [{"id": e["id"], "source": e["source"], "target": e["target"]} for e in edges]
return json.dumps(edge_info, indent=2)
@callback(
Output("validation-flow", "edges", allow_duplicate=True),
Input("clear-edges-btn", "n_clicks"),
prevent_initial_call=True,
)
def clear_edges(n):
return []
@callback(
Output("validation-flow", "edges", allow_duplicate=True),
Input("add-edge-btn", "n_clicks"),
Input("validation-flow", "edges"),
prevent_initial_call=True,
)
def add_random_edge(n_clicks, current_edges):
import random
from dash import ctx
if ctx.triggered_id != "add-edge-btn":
return current_edges
# Try to create a valid random connection
sources = ["input-1", "input-2", "process-1", "process-2", "process-3"]
targets = ["process-1", "process-2", "process-3", "output-1", "output-2"]
for _ in range(10): # Try up to 10 times to find a valid connection
source = random.choice(sources)
target = random.choice(targets)
if source == target:
continue
# Check if edge already exists
edge_id = f"e-{source}-{target}"
if any(e["id"] == edge_id or (e["source"] == source and e["target"] == target) for e in current_edges):
continue
# Valid new edge
new_edge = {"id": edge_id, "source": source, "target": target}
return current_edges + [new_edge]
return current_edges # No valid connection found
if __name__ == "__main__":
app.run(debug=True, port=8057)
:defaultExpanded: false :withExpandedButton: true
How it works
type="input"/type="output"on nodes — input nodes expose only a source handle, output nodes only a target handle, so invalid directions are impossible to draw.connectionMode="loose"— allows dragging a connection from or to any point on a node, not just a designated handle.connectionLineStyle— customizes the dashed preview line shown while a connection is being dragged.- The "Add Random Connection" button retries up to 10 random source/target pairs and skips any that would be a self-connection or a duplicate of an existing edge id.
Input("ex08-flow", "edges")— reads back the current edge list after every add/remove so the JSON panel always reflects the live graph.
Selection and Multi-select
Select nodes and edges by click, Shift+click, or drag-box, and drive selection programmatically from buttons — handy for bulk actions like "select all" or "delete selected".
# File: examples/10_selection_multiselect.py
"""
Example 10: Selection and Multi-Select
======================================
This example demonstrates selection features:
- Single node selection
- Multi-node selection (Shift+click or drag selection)
- Edge selection
- Selection callbacks
- Programmatic selection
"""
import dash
from dash import html, Input, Output, State, callback, ctx
import dash_flows
import dash_mantine_components as dmc
import json
app = dash.Dash(__name__, suppress_callback_exceptions=True)
def extract_id(item):
"""Extract ID from an item, handling different formats."""
if isinstance(item, dict) and "id" in item:
return item["id"]
elif isinstance(item, str):
return item
return None
def extract_ids(items):
"""Extract IDs from selection data, handling different formats."""
if not items:
return []
return [id for id in (extract_id(item) for item in items) if id is not None]
initial_nodes = [
{"id": "a", "type": "default", "data": {"label": "Node A"}, "position": {"x": 100, "y": 50}},
{"id": "b", "type": "default", "data": {"label": "Node B"}, "position": {"x": 300, "y": 50}},
{"id": "c", "type": "default", "data": {"label": "Node C"}, "position": {"x": 500, "y": 50}},
{"id": "d", "type": "default", "data": {"label": "Node D"}, "position": {"x": 100, "y": 200}},
{"id": "e", "type": "default", "data": {"label": "Node E"}, "position": {"x": 300, "y": 200}},
{"id": "f", "type": "default", "data": {"label": "Node F"}, "position": {"x": 500, "y": 200}},
]
initial_edges = [
{"id": "e-ab", "source": "a", "target": "b"},
{"id": "e-bc", "source": "b", "target": "c"},
{"id": "e-ad", "source": "a", "target": "d"},
{"id": "e-be", "source": "b", "target": "e"},
{"id": "e-cf", "source": "c", "target": "f"},
{"id": "e-de", "source": "d", "target": "e"},
{"id": "e-ef", "source": "e", "target": "f"},
]
app.layout = dmc.MantineProvider([
html.H1("Selection and Multi-Select Example"),
html.P("Select nodes and edges using various methods."),
dmc.Alert([
dmc.Text("Selection Tips:", fw=600),
dmc.List([
dmc.ListItem("Click a node/edge to select it"),
dmc.ListItem("Shift+click to add to selection"),
dmc.ListItem("Drag on canvas to create selection box"),
dmc.ListItem("Ctrl+A to select all"),
dmc.ListItem("Escape to deselect all"),
], size="sm"),
], color="blue", variant="light", style={"marginBottom": 20}),
dmc.Grid([
dmc.GridCol([
dash_flows.DashFlows(
id="selection-flow",
nodes=initial_nodes,
edges=initial_edges,
style={"height": "400px", "border": "1px solid #ddd"},
fitView=True,
showControls=True,
# Selection settings
elementsSelectable=True,
selectNodesOnDrag=False, # Only select on click, not drag
selectionOnDrag=True, # Allow box selection
selectionMode="partial", # Select nodes partially in box
multiSelectionKeyCode="Shift",
),
], span=8),
dmc.GridCol([
dmc.Stack([
dmc.Paper([
dmc.Text("Selected Nodes:", fw=600),
html.Div(id="selected-nodes-display"),
], p="sm", withBorder=True),
dmc.Paper([
dmc.Text("Selected Edges:", fw=600),
html.Div(id="selected-edges-display"),
], p="sm", withBorder=True),
dmc.Paper([
dmc.Text("Selection Actions:", fw=600),
dmc.Stack([
dmc.Button("Select All Nodes", id="btn-select-all", fullWidth=True, size="sm"),
dmc.Button("Select Odd Nodes", id="btn-select-odd", fullWidth=True, size="sm", variant="outline"),
dmc.Button("Clear Selection", id="btn-clear-selection", fullWidth=True, size="sm", variant="outline", color="red"),
dmc.Button("Delete Selected", id="btn-delete-selected", fullWidth=True, size="sm", color="red"),
], gap="xs"),
], p="sm", withBorder=True),
], gap="sm"),
], span=4),
]),
])
@callback(
Output("selected-nodes-display", "children"),
Input("selection-flow", "selectedNodes"),
)
def display_selected_nodes(selected_nodes):
if not selected_nodes:
return dmc.Text("No nodes selected", c="dimmed", size="sm")
node_ids = extract_ids(selected_nodes)
return dmc.Group([
dmc.Badge(nid, variant="filled", size="sm") for nid in node_ids
], gap="xs")
@callback(
Output("selected-edges-display", "children"),
Input("selection-flow", "selectedEdges"),
)
def display_selected_edges(selected_edges):
if not selected_edges:
return dmc.Text("No edges selected", c="dimmed", size="sm")
edge_ids = extract_ids(selected_edges)
return dmc.Group([
dmc.Badge(eid, variant="outline", size="sm") for eid in edge_ids
], gap="xs")
@callback(
Output("selection-flow", "nodes"),
Output("selection-flow", "edges"),
Input("btn-select-all", "n_clicks"),
Input("btn-select-odd", "n_clicks"),
Input("btn-clear-selection", "n_clicks"),
Input("btn-delete-selected", "n_clicks"),
State("selection-flow", "nodes"),
State("selection-flow", "edges"),
State("selection-flow", "selectedNodes"),
State("selection-flow", "selectedEdges"),
prevent_initial_call=True,
)
def handle_selection_actions(all_clicks, odd_clicks, clear_clicks, delete_clicks,
nodes, edges, selected_nodes, selected_edges):
if not nodes:
return initial_nodes, initial_edges
triggered = ctx.triggered_id
if triggered == "btn-select-all":
# Mark all nodes as selected
updated_nodes = [{**n, "selected": True} for n in nodes]
return updated_nodes, edges
elif triggered == "btn-select-odd":
# Select nodes with odd indices
updated_nodes = []
for i, n in enumerate(nodes):
updated_nodes.append({**n, "selected": (i % 2 == 0)})
return updated_nodes, edges
elif triggered == "btn-clear-selection":
# Clear all selections
updated_nodes = [{**n, "selected": False} for n in nodes]
updated_edges = [{**e, "selected": False} for e in edges]
return updated_nodes, updated_edges
elif triggered == "btn-delete-selected":
# Remove selected nodes and their connected edges
if not selected_nodes:
return nodes, edges
selected_ids = set(extract_ids(selected_nodes))
remaining_nodes = [n for n in nodes if n["id"] not in selected_ids]
remaining_edges = [e for e in edges if e["source"] not in selected_ids and e["target"] not in selected_ids]
return remaining_nodes, remaining_edges
return nodes, edges
if __name__ == "__main__":
app.run(debug=True, port=8059)
:defaultExpanded: false :withExpandedButton: true
How it works
selectionOnDrag=TruewithselectNodesOnDrag=False— dragging on empty canvas draws a selection box instead of moving nodes;selectionMode="partial"selects any node the box merely touches.Input("ex10-flow", "selectedNodes")/Input("ex10-flow", "selectedEdges")— read the live selection to render badges for each selected id.- Setting
"selected": Trueon node/edge dicts and writing them back toOutput("ex10-flow", "nodes")/"edges"— the supported way to drive selection programmatically (e.g. "Select All", "Select Odd Nodes"). - "Delete Selected" filters
selectedNodesout of the node list, then also drops any edge whosesourceortargetpointed at a deleted node. multiSelectionKeyCode="Shift"— Shift+click adds to the selection rather than replacing it.
Connection Limits
Cap how many edges a node will accept, either per-direction or in total, so a flow can enforce structural rules like "this input only takes one wire".
# File: examples/16_connection_limits.py
"""
Example 16: Connection Limits
=============================
This example demonstrates how to limit the number of connections:
- maxConnections: Total connections (source + target) for a node
- maxSourceConnections: Max outgoing connections from a node
- maxTargetConnections: Max incoming connections to a node
"""
import dash
from dash import html, callback, Input, Output
import dash_flows
app = dash.Dash(__name__)
# Nodes with connection limits
nodes = [
# This node can only have 1 outgoing connection
{
"id": "single-out",
"type": "input",
"data": {
"label": "Single Output (max 1)",
"maxSourceConnections": 1,
},
"position": {"x": 50, "y": 50},
},
# This node can have unlimited outgoing connections
{
"id": "multi-out",
"type": "input",
"data": {
"label": "Multi Output (unlimited)",
},
"position": {"x": 300, "y": 50},
},
# This node can only receive 1 incoming connection
{
"id": "single-in",
"type": "default",
"data": {
"label": "Single Input (max 1)",
"maxTargetConnections": 1,
},
"position": {"x": 100, "y": 200},
},
# This node can receive 2 incoming connections
{
"id": "dual-in",
"type": "default",
"data": {
"label": "Dual Input (max 2)",
"maxTargetConnections": 2,
},
"position": {"x": 350, "y": 200},
},
# This node has a total connection limit (in + out)
{
"id": "limited-total",
"type": "default",
"data": {
"label": "Total Limit (max 2 total)",
"maxConnections": 2,
},
"position": {"x": 225, "y": 350},
},
# Output nodes with no limits
{
"id": "output-1",
"type": "output",
"data": {"label": "Output 1"},
"position": {"x": 100, "y": 500},
},
{
"id": "output-2",
"type": "output",
"data": {"label": "Output 2"},
"position": {"x": 350, "y": 500},
},
]
# Initial edges showing some connections already made
edges = [
# Single-out already has its one allowed connection
{"id": "e1", "source": "single-out", "target": "single-in"},
]
app.layout = html.Div([
html.H1("Connection Limits Example"),
html.P([
"Try to create connections between nodes. Notice that:",
html.Ul([
html.Li("'Single Output' can only connect to one target (already connected)"),
html.Li("'Single Input' can only receive one connection (already has one)"),
html.Li("'Dual Input' can receive up to 2 connections"),
html.Li("'Total Limit' can have max 2 connections total (in + out combined)"),
html.Li("'Multi Output' has no limits"),
]),
]),
# Status display
html.Div(id="connection-status", style={
"padding": "10px",
"marginBottom": "10px",
"background": "#e8f4e8",
"borderRadius": "5px",
}),
dash_flows.DashFlows(
id="flow",
nodes=nodes,
edges=edges,
style={"height": "600px", "border": "1px solid #ddd"},
fitView=True,
showControls=True,
showMiniMap=True,
showBackground=True,
connectionRules={
"allowSelfConnection": False,
"allowDuplicateConnections": False,
},
),
])
@callback(
Output("connection-status", "children"),
[Input("flow", "edges"),
Input("flow", "lastConnection")],
)
def update_status(current_edges, last_connection):
edge_count = len(current_edges) if current_edges else 0
if last_connection:
return f"Total connections: {edge_count} | Last connection: {last_connection.get('source', '')} -> {last_connection.get('target', '')}"
return f"Total connections: {edge_count} | Drag from a handle to create a connection"
if __name__ == "__main__":
app.run(debug=True, port=8086)
:defaultExpanded: false :withExpandedButton: true
How it works
data.maxSourceConnections— caps how many outgoing edges a node's source handle(s) will allow.data.maxTargetConnections— caps how many incoming edges a node's target handle(s) will allow.data.maxConnections— caps the combined total of incoming and outgoing edges on a node.connectionRules={"allowSelfConnection": False, "allowDuplicateConnections": False}— global rules applied on top of the per-node limits above.Input("ex16-flow", "lastConnection")— reports the most recently completed connection so the status bar can showsource -> targetas connections are made.
Context Menu
A fully custom right-click context menu built with dmc.Menu and paneContextMenu, with submenus for adding nodes, switching edge types, and changing the theme preset, color mode, and color scheme live.
# File: examples/20_context_menu.py
"""
Example 20: Context Menu with Liquid Glass Styling
===================================================
This example demonstrates:
- Right-click context menu on the canvas
- Submenus for adding nodes (Input, Process, Output, Resizable)
- Submenus for edge types (Bezier, Straight, Step, SmoothStep)
- Theme preset selection (Glass, Solid, Minimal)
- Color mode toggle (Light, Dark, System)
- Color scheme selection (Default, Ocean, Forest, Sunset, Midnight, Rose)
- Dynamic node/edge creation
- Liquid glass morphism styling for menus
"""
import dash
from dash import html, callback, Input, Output, State, ctx, dcc
from dash.exceptions import PreventUpdate
import dash_flows
import dash_mantine_components as dmc
from dash_iconify import DashIconify
import uuid
app = dash.Dash(
__name__,
assets_folder="assets",
suppress_callback_exceptions=True
)
# Initial empty state
initial_nodes = []
initial_edges = []
def create_node_submenu():
"""Create the nodes submenu with node type options."""
return dmc.SubMenu([
dmc.SubMenuTarget(
dmc.SubMenuItem(
"Add Node",
leftSection=DashIconify(icon="tabler:square-plus", width=18),
)
),
dmc.SubMenuDropdown(
className="glass-morphism-menu",
children=[
dmc.MenuItem(
"Input Node",
id="add-input-node",
leftSection=html.Div("+", className="node-type-icon node-input-icon"),
rightSection=dmc.Text("Source", size="xs", c="dimmed"),
),
dmc.MenuItem(
"Process Node",
id="add-process-node",
leftSection=html.Div("⚙", className="node-type-icon node-process-icon"),
rightSection=dmc.Text("Transform", size="xs", c="dimmed"),
),
dmc.MenuItem(
"Output Node",
id="add-output-node",
leftSection=html.Div("→", className="node-type-icon node-output-icon"),
rightSection=dmc.Text("Sink", size="xs", c="dimmed"),
),
dmc.MenuDivider(),
dmc.MenuItem(
"Resizable Node",
id="add-resizable-node",
leftSection=html.Div("⤢", className="node-type-icon node-resizable-icon"),
rightSection=dmc.Text("Custom", size="xs", c="dimmed"),
),
]
),
])
def create_edge_submenu():
"""Create the edges submenu with edge type options."""
return dmc.SubMenu([
dmc.SubMenuTarget(
dmc.SubMenuItem(
"Edge Type",
leftSection=DashIconify(icon="tabler:line", width=18),
)
),
dmc.SubMenuDropdown(
className="glass-morphism-menu",
children=[
dmc.MenuItem(
"Bezier",
id="edge-bezier",
leftSection=DashIconify(icon="tabler:vector-bezier-2", width=18),
),
dmc.MenuItem(
"Straight",
id="edge-straight",
leftSection=DashIconify(icon="tabler:line", width=18),
),
dmc.MenuItem(
"Step",
id="edge-step",
leftSection=DashIconify(icon="tabler:stairs", width=18),
),
dmc.MenuItem(
"Smooth Step",
id="edge-smoothstep",
leftSection=DashIconify(icon="tabler:corner-down-right", width=18),
),
dmc.MenuDivider(),
dmc.MenuItem(
"Animated",
id="edge-animated",
leftSection=DashIconify(icon="tabler:bolt", width=18, color="orange"),
),
]
),
])
def create_theme_preset_submenu():
"""Create the theme preset submenu."""
return dmc.SubMenu([
dmc.SubMenuTarget(
dmc.SubMenuItem(
"Theme Preset",
leftSection=DashIconify(icon="tabler:palette", width=18),
)
),
dmc.SubMenuDropdown(
className="glass-morphism-menu",
children=[
dmc.MenuItem(
"Glass",
id="preset-glass",
leftSection=html.Div(className="preset-indicator preset-glass"),
rightSection=dmc.Text("Blur effect", size="xs", c="dimmed"),
),
dmc.MenuItem(
"Solid",
id="preset-solid",
leftSection=html.Div(className="preset-indicator preset-solid"),
rightSection=dmc.Text("Opaque", size="xs", c="dimmed"),
),
dmc.MenuItem(
"Minimal",
id="preset-minimal",
leftSection=html.Div(className="preset-indicator preset-minimal"),
rightSection=dmc.Text("Clean", size="xs", c="dimmed"),
),
]
),
])
def create_color_mode_submenu():
"""Create the color mode submenu."""
return dmc.SubMenu([
dmc.SubMenuTarget(
dmc.SubMenuItem(
"Color Mode",
leftSection=DashIconify(icon="tabler:sun-moon", width=18),
)
),
dmc.SubMenuDropdown(
className="glass-morphism-menu",
children=[
dmc.MenuItem(
"Light",
id="mode-light",
leftSection=DashIconify(icon="tabler:sun", width=18, color="#f59e0b"),
),
dmc.MenuItem(
"Dark",
id="mode-dark",
leftSection=DashIconify(icon="tabler:moon", width=18, color="#6366f1"),
),
dmc.MenuItem(
"System",
id="mode-system",
leftSection=DashIconify(icon="tabler:device-desktop", width=18),
),
]
),
])
def create_color_scheme_submenu():
"""Create the color scheme submenu."""
return dmc.SubMenu([
dmc.SubMenuTarget(
dmc.SubMenuItem(
"Color Scheme",
leftSection=DashIconify(icon="tabler:color-swatch", width=18),
)
),
dmc.SubMenuDropdown(
className="glass-morphism-menu",
children=[
dmc.MenuItem(
"Default",
id="scheme-default",
leftSection=html.Div(className="color-scheme-swatch swatch-default"),
),
dmc.MenuItem(
"Ocean",
id="scheme-ocean",
leftSection=html.Div(className="color-scheme-swatch swatch-ocean"),
),
dmc.MenuItem(
"Forest",
id="scheme-forest",
leftSection=html.Div(className="color-scheme-swatch swatch-forest"),
),
dmc.MenuItem(
"Sunset",
id="scheme-sunset",
leftSection=html.Div(className="color-scheme-swatch swatch-sunset"),
),
dmc.MenuItem(
"Midnight",
id="scheme-midnight",
leftSection=html.Div(className="color-scheme-swatch swatch-midnight"),
),
dmc.MenuItem(
"Rose",
id="scheme-rose",
leftSection=html.Div(className="color-scheme-swatch swatch-rose"),
),
]
),
])
def create_context_menu():
"""Create the full context menu structure."""
return dmc.Menu(
id="context-menu",
opened=False,
position="bottom-start",
offset=0,
withArrow=False,
shadow="lg",
width=220,
zIndex=9999,
closeOnClickOutside=True,
closeOnEscape=True,
children=[
dmc.MenuTarget(
html.Div(
id="context-menu-trigger",
className="context-menu-trigger",
)
),
dmc.MenuDropdown(
className="glass-morphism-menu",
children=[
dmc.MenuLabel("Nodes"),
create_node_submenu(),
dmc.MenuDivider(),
dmc.MenuLabel("Connections"),
create_edge_submenu(),
dmc.MenuDivider(),
dmc.MenuLabel("Appearance"),
create_theme_preset_submenu(),
create_color_mode_submenu(),
create_color_scheme_submenu(),
dmc.MenuDivider(),
dmc.MenuItem(
"Fit View",
id="action-fit-view",
leftSection=DashIconify(icon="tabler:arrows-maximize", width=18),
),
dmc.MenuItem(
"Clear Canvas",
id="action-clear",
leftSection=DashIconify(icon="tabler:trash", width=18),
color="red",
),
]
),
],
)
def create_status_bar():
"""Create a status bar showing current settings."""
return dmc.Group(
className="status-bar",
p="xs",
gap="md",
style={
"position": "absolute",
"bottom": 16,
"left": 16,
"zIndex": 100,
},
children=[
dmc.Group(gap="xs", children=[
DashIconify(icon="tabler:box", width=14),
dmc.Text(id="node-count", size="sm", children="Nodes: 0"),
]),
dmc.Divider(orientation="vertical", size="sm"),
dmc.Group(gap="xs", children=[
DashIconify(icon="tabler:line", width=14),
dmc.Text(id="edge-count", size="sm", children="Edges: 0"),
]),
dmc.Divider(orientation="vertical", size="sm"),
dmc.Group(gap="xs", children=[
DashIconify(icon="tabler:palette", width=14),
dmc.Text(id="current-preset", size="sm", children="glass"),
]),
dmc.Divider(orientation="vertical", size="sm"),
dmc.Group(gap="xs", children=[
DashIconify(icon="tabler:color-swatch", width=14),
dmc.Text(id="current-scheme", size="sm", children="default"),
]),
]
)
def create_help_tooltip():
"""Create a help tooltip."""
return dmc.Paper(
className="status-bar",
p="sm",
style={
"position": "absolute",
"top": 16,
"left": 16,
"zIndex": 100,
"maxWidth": 280,
},
children=[
dmc.Group(gap="xs", mb="xs", children=[
DashIconify(icon="tabler:info-circle", width=18, color="blue"),
dmc.Text("Right-Click Context Menu", fw=600, size="sm"),
]),
dmc.Text(
"Right-click anywhere on the canvas to open the context menu. "
"Add nodes, change themes, and customize your flow.",
size="xs",
c="dimmed",
),
]
)
app.layout = dmc.MantineProvider(
id="mantine-provider",
forceColorScheme="light",
children=[
# Stores for state management
dcc.Store(id="menu-position", data={"x": 0, "y": 0}),
dcc.Store(id="current-edge-type", data="smoothstep"),
dcc.Store(id="edge-animated-state", data=False),
dcc.Store(id="last-node-clicks", data={"input": 0, "process": 0, "output": 0, "resizable": 0}),
html.Div(
style={"position": "relative", "height": "100vh", "width": "100%"},
children=[
# Context menu (positioned via callback)
html.Div(
id="context-menu-container",
className="context-menu-container",
style={"position": "fixed", "left": 0, "top": 0},
children=[create_context_menu()],
),
# Help tooltip
create_help_tooltip(),
# Status bar
create_status_bar(),
# DashFlows component
dash_flows.DashFlows(
id="flow",
nodes=initial_nodes,
edges=initial_edges,
colorMode="light",
themePreset="glass",
colorScheme="default",
theme={
"glassBlur": 12,
"borderRadius": 14,
"edgeStrokeWidth": 2,
},
showMiniMap=True,
showControls=True,
showBackground=True,
fitView=True,
fitViewOptions={"padding": 0.2},
defaultEdgeOptions={
"type": "smoothstep",
"animated": False,
},
style={"height": "100vh", "width": "100%"},
),
],
),
],
)
# Callback to handle right-click and show context menu
@callback(
[Output("context-menu", "opened"),
Output("context-menu-container", "style"),
Output("menu-position", "data")],
[Input("flow", "paneContextMenu")],
prevent_initial_call=True,
)
def show_context_menu(context_menu):
"""Show context menu at right-click position."""
if not context_menu:
raise PreventUpdate
x = context_menu.get("clientX", 0)
y = context_menu.get("clientY", 0)
return (
True,
{"position": "fixed", "left": x, "top": y, "zIndex": 9999},
{"x": x, "y": y}
)
# Callback to close menu on pane click
@callback(
Output("context-menu", "opened", allow_duplicate=True),
Input("flow", "paneClickPosition"),
prevent_initial_call=True,
)
def close_menu_on_click(pane_click):
"""Close context menu when clicking on the pane."""
return False
# Callback to add nodes
@callback(
[Output("flow", "nodes"),
Output("context-menu", "opened", allow_duplicate=True),
Output("last-node-clicks", "data")],
[Input("add-input-node", "n_clicks"),
Input("add-process-node", "n_clicks"),
Input("add-output-node", "n_clicks"),
Input("add-resizable-node", "n_clicks")],
[State("flow", "nodes"),
State("menu-position", "data"),
State("flow", "viewport"),
State("last-node-clicks", "data")],
prevent_initial_call=True,
)
def add_node(n1, n2, n3, n4, current_nodes, menu_pos, viewport, last_clicks):
"""Add a new node at the context menu position."""
# Check which input actually triggered the callback
if not ctx.triggered:
raise PreventUpdate
# Get the prop_id that triggered this callback
triggered_prop = ctx.triggered[0]["prop_id"]
triggered_value = ctx.triggered[0]["value"]
# Only proceed if there was an actual click (value > 0)
if not triggered_value:
raise PreventUpdate
# Extract the component id from prop_id (format: "component-id.property")
triggered = triggered_prop.split(".")[0]
# Map trigger to node type and last_clicks key
node_types = {
"add-input-node": ("input", "Input", "input"),
"add-process-node": ("default", "Process", "process"),
"add-output-node": ("output", "Output", "output"),
"add-resizable-node": ("resizable", "Resizable", "resizable"),
}
if triggered not in node_types:
raise PreventUpdate
node_type, label, click_key = node_types[triggered]
# Check if this is a new click by comparing with last stored value
current_click = triggered_value or 0
last_click = last_clicks.get(click_key, 0) if last_clicks else 0
if current_click <= last_click:
raise PreventUpdate
# Update the last clicks store
new_last_clicks = last_clicks.copy() if last_clicks else {"input": 0, "process": 0, "output": 0, "resizable": 0}
new_last_clicks[click_key] = current_click
# Calculate position in flow coordinates
# Account for viewport transform
zoom = viewport.get("zoom", 1) if viewport else 1
vp_x = viewport.get("x", 0) if viewport else 0
vp_y = viewport.get("y", 0) if viewport else 0
x = (menu_pos["x"] - vp_x) / zoom
y = (menu_pos["y"] - vp_y) / zoom
# Generate unique ID
node_id = f"{node_type}-{str(uuid.uuid4())[:8]}"
# Create new node
new_node = {
"id": node_id,
"type": node_type,
"data": {
"label": f"{label} {len(current_nodes) + 1 if current_nodes else 1}",
"sublabel": f"Created at ({int(x)}, {int(y)})",
},
"position": {"x": x, "y": y},
}
# Add resizable-specific properties
if node_type == "resizable":
new_node["data"]["handles"] = [
{"id": "top", "type": "target", "position": "top"},
{"id": "bottom", "type": "source", "position": "bottom"},
]
new_node["style"] = {"width": 180, "height": 100}
updated_nodes = (current_nodes or []) + [new_node]
return updated_nodes, False, new_last_clicks
# Callback to handle edge type selection
@callback(
[Output("current-edge-type", "data"),
Output("edge-animated-state", "data"),
Output("flow", "defaultEdgeOptions"),
Output("context-menu", "opened", allow_duplicate=True)],
[Input("edge-bezier", "n_clicks"),
Input("edge-straight", "n_clicks"),
Input("edge-step", "n_clicks"),
Input("edge-smoothstep", "n_clicks"),
Input("edge-animated", "n_clicks")],
[State("current-edge-type", "data"),
State("edge-animated-state", "data")],
prevent_initial_call=True,
)
def set_edge_type(n1, n2, n3, n4, n5, current_type, is_animated):
"""Set the default edge type for new connections."""
if not ctx.triggered_id:
raise PreventUpdate
triggered = ctx.triggered_id
edge_types = {
"edge-bezier": "default",
"edge-straight": "straight",
"edge-step": "step",
"edge-smoothstep": "smoothstep",
}
new_type = current_type
new_animated = is_animated
if triggered == "edge-animated":
new_animated = not is_animated
elif triggered in edge_types:
new_type = edge_types[triggered]
default_options = {
"type": new_type,
"animated": new_animated,
}
return new_type, new_animated, default_options, False
# Callback to handle theme preset selection
@callback(
[Output("flow", "themePreset"),
Output("current-preset", "children"),
Output("context-menu", "opened", allow_duplicate=True)],
[Input("preset-glass", "n_clicks"),
Input("preset-solid", "n_clicks"),
Input("preset-minimal", "n_clicks")],
prevent_initial_call=True,
)
def set_theme_preset(n1, n2, n3):
"""Set the theme preset."""
if not ctx.triggered_id:
raise PreventUpdate
presets = {
"preset-glass": "glass",
"preset-solid": "solid",
"preset-minimal": "minimal",
}
preset = presets.get(ctx.triggered_id, "glass")
return preset, preset, False
# Callback to handle color mode selection
@callback(
[Output("flow", "colorMode"),
Output("mantine-provider", "forceColorScheme"),
Output("context-menu", "opened", allow_duplicate=True)],
[Input("mode-light", "n_clicks"),
Input("mode-dark", "n_clicks"),
Input("mode-system", "n_clicks")],
prevent_initial_call=True,
)
def set_color_mode(n1, n2, n3):
"""Set the color mode."""
if not ctx.triggered_id:
raise PreventUpdate
modes = {
"mode-light": "light",
"mode-dark": "dark",
"mode-system": "system",
}
mode = modes.get(ctx.triggered_id, "light")
mantine_scheme = None if mode == "system" else mode
return mode, mantine_scheme, False
# Callback to handle color scheme selection
@callback(
[Output("flow", "colorScheme"),
Output("current-scheme", "children"),
Output("context-menu", "opened", allow_duplicate=True)],
[Input("scheme-default", "n_clicks"),
Input("scheme-ocean", "n_clicks"),
Input("scheme-forest", "n_clicks"),
Input("scheme-sunset", "n_clicks"),
Input("scheme-midnight", "n_clicks"),
Input("scheme-rose", "n_clicks")],
prevent_initial_call=True,
)
def set_color_scheme(n1, n2, n3, n4, n5, n6):
"""Set the color scheme."""
if not ctx.triggered_id:
raise PreventUpdate
schemes = {
"scheme-default": "default",
"scheme-ocean": "ocean",
"scheme-forest": "forest",
"scheme-sunset": "sunset",
"scheme-midnight": "midnight",
"scheme-rose": "rose",
}
scheme = schemes.get(ctx.triggered_id, "default")
return scheme, scheme, False
# Callback to handle fit view action
@callback(
[Output("flow", "viewportAction"),
Output("context-menu", "opened", allow_duplicate=True)],
Input("action-fit-view", "n_clicks"),
prevent_initial_call=True,
)
def fit_view(n_clicks):
"""Trigger fit view action."""
if not n_clicks:
raise PreventUpdate
# Use viewportAction to trigger fitView
return {"action": "fitView", "options": {"padding": 0.2, "duration": 500}}, False
# Callback to clear all nodes and edges
@callback(
[Output("flow", "nodes", allow_duplicate=True),
Output("flow", "edges"),
Output("context-menu", "opened", allow_duplicate=True)],
Input("action-clear", "n_clicks"),
prevent_initial_call=True,
)
def clear_canvas(n_clicks):
"""Clear all nodes and edges from the canvas."""
if not n_clicks:
raise PreventUpdate
return [], [], False
# Callback to update status bar counts
@callback(
[Output("node-count", "children"),
Output("edge-count", "children")],
[Input("flow", "nodes"),
Input("flow", "edges")],
)
def update_counts(nodes, edges):
"""Update the node and edge counts in the status bar."""
node_count = len(nodes) if nodes else 0
edge_count = len(edges) if edges else 0
return f"Nodes: {node_count}", f"Edges: {edge_count}"
if __name__ == "__main__":
app.run(debug=True, port=8020)
:defaultExpanded: false :withExpandedButton: true
The embedded demo above is trimmed to a fixed-height canvas that only themes itself (the full app in examples/20_context_menu.py runs at 100vh and also syncs the page's overall Mantine color scheme from the same menu).
How it works
Input("ex20-flow", "paneContextMenu")— fires on right-click over the canvas withclientX/clientY, which the callback uses to position thedmc.Menuand open it.Input("ex20-flow", "paneClickPosition")— fires on a plain left-click on the pane, used here to close the menu.State("ex20-flow", "viewport")— converts the screen-space menu position back into flow coordinates (accounting for pan/zoom) when placing a newly added node.Output("ex20-flow", "themePreset")/"colorScheme"/"colorMode"— the Appearance submenus write directly to these props to re-theme the canvas without a page reload.Output("ex20-flow", "viewportAction")with{"action": "fitView", ...}— the "Fit View" menu item triggers a programmatic viewport action.assets/context_menu.css(loaded automatically from the project'sassets/folder) supplies theglass-morphism-menuandstatus-barglassmorphism styling used by the menu, submenus, and overlays.
Source: /interactions
Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs:
- /interactions/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt