diff --git a/docs/how-to/declare-types.md b/docs/how-to/declare-types.md
index 371750c..ac4a45e 100644
--- a/docs/how-to/declare-types.md
+++ b/docs/how-to/declare-types.md
@@ -6,6 +6,7 @@ kind of node/edge carries**. A type can provide:
- a type name (`type`)
- a display label (`label`)
- node handles (`inputs` / `outputs`)
+- handle connectivity controls (`input_connectable*` / `output_connectable*`)
- a schema for the `data` payload (`schema`)
Types are separate from editors. A type defines structure; an editor defines
@@ -237,3 +238,159 @@ flow = ReactFlow(
Types without a schema still work; they just do not get schema-driven
validation or auto-generated forms.
+
+---
+
+## Handle tooltips
+
+By default, handles are plain connection points. You can add a tooltip (shown
+on hover) by passing a dict with `"id"` and `"label"` instead of a plain string:
+
+```python
+from panel_reactflow import NodeType
+
+node_types = {
+ "transform": NodeType(
+ type="transform",
+ label="Transform",
+ inputs=[{"id": "in", "label": "Data Input"}],
+ outputs=[
+ {"id": "success", "label": "Successful results"},
+ {"id": "error", "label": "Failed records"},
+ ],
+ ),
+}
+```
+
+Plain strings and dicts can be mixed freely in the same list:
+
+```python
+inputs=["simple_port", {"id": "documented_port", "label": "Hover to see this"}]
+```
+
+---
+
+## Control handle connectivity
+
+By default, all handles (inputs and outputs) are fully connectable — users can
+drag edges from or to any handle. Use the `*_connectable*` flags to restrict
+which connections are allowed.
+
+### Common patterns
+
+#### Data source (output only)
+
+A node that produces data but cannot accept incoming connections to its output:
+
+```python
+from panel_reactflow import NodeType
+
+source_type = NodeType(
+ type="data_source",
+ label="Data Source",
+ outputs=["data"],
+ output_connectable_start=True, # Can drag FROM output
+ output_connectable_end=False, # Cannot drag TO output
+)
+```
+
+#### Data sink (input only)
+
+A node that consumes data but cannot produce outgoing connections from its input:
+
+```python
+sink_type = NodeType(
+ type="data_sink",
+ label="Data Sink",
+ inputs=["data"],
+ input_connectable_start=False, # Cannot drag FROM input
+ input_connectable_end=True, # Can drag TO input
+)
+```
+
+#### Monitor node
+
+A node that accepts input but whose output is status-only (one direction):
+
+```python
+monitor_type = NodeType(
+ type="monitor",
+ label="Monitor",
+ inputs=["in"],
+ outputs=["status"],
+ input_connectable_start=False, # Cannot start edges from input
+ output_connectable_end=False, # Cannot end edges at output
+)
+```
+
+### All connectivity flags
+
+| Flag | Default | Controls |
+|------|---------|----------|
+| `input_connectable` | `True` | Whether input handles are connectable at all |
+| `input_connectable_start` | `True` | Whether edges can start from input handles |
+| `input_connectable_end` | `True` | Whether edges can end at input handles |
+| `output_connectable` | `True` | Whether output handles are connectable at all |
+| `output_connectable_start` | `True` | Whether edges can start from output handles |
+| `output_connectable_end` | `True` | Whether edges can end at output handles |
+
+### Complete example
+
+```python
+import panel as pn
+from panel_reactflow import NodeType, NodeSpec, EdgeSpec, ReactFlow
+
+pn.extension("jsoneditor")
+
+# Define node types with different connectivity patterns
+node_types = {
+ "source": NodeType(
+ type="source",
+ label="Data Source",
+ outputs=["data"],
+ output_connectable_start=True,
+ output_connectable_end=False,
+ ),
+ "transform": NodeType(
+ type="transform",
+ label="Transform",
+ inputs=["in"],
+ outputs=["out"],
+ # All connectable flags default to True
+ ),
+ "sink": NodeType(
+ type="sink",
+ label="Data Sink",
+ inputs=["data"],
+ input_connectable_start=False,
+ input_connectable_end=True,
+ ),
+}
+
+# Create a data pipeline
+flow = ReactFlow(
+ nodes=[
+ NodeSpec(id="src", type="source", position={"x": 0, "y": 100}, data={}).to_dict(),
+ NodeSpec(id="tx", type="transform", position={"x": 250, "y": 100}, data={}).to_dict(),
+ NodeSpec(id="snk", type="sink", position={"x": 500, "y": 100}, data={}).to_dict(),
+ ],
+ edges=[
+ EdgeSpec(id="e1", source="src", target="tx").to_dict(),
+ EdgeSpec(id="e2", source="tx", target="snk").to_dict(),
+ ],
+ node_types=node_types,
+ sizing_mode="stretch_both",
+)
+
+flow.servable()
+```
+
+In this example:
+
+- Users can drag from the **source** output to the **transform** input ✓
+- Users cannot drag to the **source** output ✗
+- Users can drag from the **transform** output to the **sink** input ✓
+- Users cannot drag from the **sink** input ✗
+
+The UI prevents invalid connections automatically — non-connectable handles
+show different cursor behavior and won't accept drag operations.
diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py
index a451434..926c1ad 100644
--- a/src/panel_reactflow/base.py
+++ b/src/panel_reactflow/base.py
@@ -250,12 +250,14 @@ class NodeType:
- A :class:`SchemaSource` wrapper for explicit schema types
The schema is normalized to JSON Schema format internally.
- inputs : list of str, optional
- List of input port names. If provided, these ports will be rendered
- on the node for incoming connections.
- outputs : list of str, optional
- List of output port names. If provided, these ports will be rendered
- on the node for outgoing connections.
+ inputs : list of str or dict, optional
+ List of input port definitions. Each entry can be a plain string
+ (the handle ID) or a dict with ``"id"`` and optional ``"label"``
+ keys. When a label is provided it renders as a tooltip on hover.
+ outputs : list of str or dict, optional
+ List of output port definitions. Each entry can be a plain string
+ (the handle ID) or a dict with ``"id"`` and optional ``"label"``
+ keys. When a label is provided it renders as a tooltip on hover.
input_connectable : bool, default True
Whether input handles are connectable. When False, users cannot create
connections to or from input handles.
@@ -341,8 +343,8 @@ class NodeType:
type: str
label: str | None = None
schema: Any = None
- inputs: list[str] | None = None
- outputs: list[str] | None = None
+ inputs: list[str | dict[str, str]] | None = None
+ outputs: list[str | dict[str, str]] | None = None
input_connectable: bool = True
input_connectable_start: bool = True
input_connectable_end: bool = True
@@ -1464,7 +1466,6 @@ class ReactFlow(ReactComponent):
_node_editors = param.Dict(default={}, doc="Per-node editors.", precedence=-1)
_node_editor_views = Children(default=[], doc="Node editor views (one per node, same order).")
_edge_editors = param.Dict(default={}, doc="Per-edge editors.", precedence=-1)
- _edge_editor_views = Children(default=[], doc="Edge editor views (one per edge, same order).")
_selected_editor = Child(doc="Active editor for the selected node/edge in side mode.")
_context_menu = Child(doc="Context menu component rendered on node right-click.")
_context_menu_position = param.Dict(default=None, allow_None=True, doc="Screen position for the context menu overlay.")
@@ -1992,7 +1993,6 @@ def _update_edge_editors(self, *events: tuple[param.parameterized.Event]) -> Non
editor = editor_factory
editors[edge_id] = editor
self._edge_editors = editors
- self.param.trigger("_edge_editor_views")
def _update_selected_editor(self, *events: tuple[param.parameterized.Event]) -> None:
selected_nodes = self.selection.get("nodes", [])
@@ -2033,13 +2033,11 @@ def _get_children(self, data_model, doc, root, parent, comm) -> tuple[dict[str,
if self.editor_mode == "side":
children["_node_editor_views"] = []
- children["_edge_editor_views"] = []
else:
node_editors = [self._resolve_editor_view(self._node_editors.get(self._node_id(node))) for node in self.nodes]
editor_models, editor_old = self._get_child_model(node_editors, doc, root, parent, comm)
children["_node_editor_views"] = editor_models
old_models += editor_old
- children["_edge_editor_views"] = []
for name in ("top_panel", "bottom_panel", "left_panel", "right_panel", "_context_menu", "_selected_editor"):
panels = getattr(self, name, None)
diff --git a/src/panel_reactflow/dist/css/reactflow.css b/src/panel_reactflow/dist/css/reactflow.css
index 7d146a5..0cfd649 100644
--- a/src/panel_reactflow/dist/css/reactflow.css
+++ b/src/panel_reactflow/dist/css/reactflow.css
@@ -80,6 +80,33 @@
transform: none;
}
+.react-flow__handle[data-tooltip]::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ background: rgba(0, 0, 0, 0.8);
+ color: #fff;
+ font-size: 11px;
+ line-height: 1.3;
+ padding: 4px 8px;
+ border-radius: 4px;
+ white-space: nowrap;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.1s;
+ z-index: 10;
+}
+.react-flow__handle[data-tooltip-pos="left"]::after {
+ right: calc(100% + 6px);
+}
+.react-flow__handle[data-tooltip-pos="right"]::after {
+ left: calc(100% + 6px);
+}
+.react-flow__handle[data-tooltip]:hover::after {
+ opacity: 1;
+}
+
.rf-context-menu {
background: var(--xy-node-background-color, var(--panel-background-color));
border: 1px solid var(--panel-border-color);
diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx
index cd38852..e302d0e 100644
--- a/src/panel_reactflow/models/reactflow.jsx
+++ b/src/panel_reactflow/models/reactflow.jsx
@@ -46,16 +46,21 @@ function renderHandles(direction, handles, opts = {}) {
return ;
}
const spacing = 100 / (handles.length + 1);
- return handles.map((handle, index) => (
-
- ));
+ return handles.map((handle, index) => {
+ const id = typeof handle === "string" ? handle : handle.id;
+ const label = typeof handle === "object" ? handle.label : undefined;
+ return (
+
+ );
+ });
}
function makeNodeComponent(typeName, typeSpec, editorMode) {
diff --git a/tests/test_core.py b/tests/test_core.py
index eff4981..7c53c67 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -68,7 +68,6 @@ def test_reactflow_add_node_dynamically_creates_views(document, comm):
assert model.children == [
"_views",
"_node_editor_views",
- "_edge_editor_views",
"top_panel",
"bottom_panel",
"left_panel",
@@ -107,7 +106,6 @@ def editor(self, data, schema, *, id, type, on_patch):
assert model.children == [
"_views",
"_node_editor_views",
- "_edge_editor_views",
"top_panel",
"bottom_panel",
"left_panel",
@@ -117,7 +115,6 @@ def editor(self, data, schema, *, id, type, on_patch):
]
assert len(model.data._views) == 1
assert len(model.data._node_editor_views) == 2
- assert len(model.data._edge_editor_views) == 0
by_id = {node["id"]: node for node in model.data.nodes}
assert by_id["n1"]["data"]["view_idx"] == 0