Skip to content

Commit f3101ae

Browse files
authored
Add Clickable wrapper component (#698)
1 parent c30dca9 commit f3101ae

4 files changed

Lines changed: 398 additions & 0 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": null,
6+
"id": "a1",
7+
"metadata": {},
8+
"outputs": [],
9+
"source": [
10+
"import panel as pn\n",
11+
"import panel_material_ui as pmui\n",
12+
"\n",
13+
"pn.extension()"
14+
]
15+
},
16+
{
17+
"cell_type": "markdown",
18+
"id": "a2",
19+
"metadata": {},
20+
"source": [
21+
"The `Clickable` wrapper adds click interaction to any child component. It wraps a single child element with a clickable area, providing a `clicks` counter and an `on_click` callback mechanism. Optionally renders a Material UI ripple effect on click.\n",
22+
"\n",
23+
"#### Parameters:\n",
24+
"\n",
25+
"For details on other options for customizing the component see the [customization guides](https://panel-material-ui.holoviz.org/customization/index.html).\n",
26+
"\n",
27+
"##### Core\n",
28+
"\n",
29+
"* **`object`** (Viewable): The child component to wrap.\n",
30+
"* **`clicks`** (int): Number of clicks. Increments each time the component is clicked.\n",
31+
"* **`disabled`** (bool): Whether the clickable area is disabled. Default is False.\n",
32+
"\n",
33+
"##### Display\n",
34+
"\n",
35+
"* **`disable_ripple`** (bool): Whether to disable the ripple effect on click. Default is False.\n",
36+
"\n",
37+
"##### Styling\n",
38+
"\n",
39+
"- **`sx`** (dict): Component level styling API for advanced customization.\n",
40+
"- **`theme_config`** (dict): Theming API for consistent design system integration.\n",
41+
"\n",
42+
"___"
43+
]
44+
},
45+
{
46+
"cell_type": "markdown",
47+
"id": "b1",
48+
"metadata": {},
49+
"source": [
50+
"### Basic Usage\n",
51+
"\n",
52+
"Wrap any component to make it clickable:"
53+
]
54+
},
55+
{
56+
"cell_type": "code",
57+
"execution_count": null,
58+
"id": "b2",
59+
"metadata": {},
60+
"outputs": [],
61+
"source": [
62+
"clickable = pmui.Clickable(\n",
63+
" pmui.Card(\n",
64+
" title=\"Click me!\", elevation=3, width=200, height=100, collapsible=False\n",
65+
" )\n",
66+
")\n",
67+
"\n",
68+
"clickable"
69+
]
70+
},
71+
{
72+
"cell_type": "markdown",
73+
"id": "c1",
74+
"metadata": {},
75+
"source": [
76+
"### Click Counter\n",
77+
"\n",
78+
"The `clicks` parameter increments each time the wrapped element is clicked:"
79+
]
80+
},
81+
{
82+
"cell_type": "code",
83+
"execution_count": null,
84+
"id": "c2",
85+
"metadata": {},
86+
"outputs": [],
87+
"source": [
88+
"clickable.clicks"
89+
]
90+
},
91+
{
92+
"cell_type": "markdown",
93+
"id": "d1",
94+
"metadata": {},
95+
"source": [
96+
"### Callback\n",
97+
"\n",
98+
"Use `on_click` to register a callback, either as a constructor argument or method call:"
99+
]
100+
},
101+
{
102+
"cell_type": "code",
103+
"execution_count": null,
104+
"id": "d2",
105+
"metadata": {},
106+
"outputs": [],
107+
"source": [
108+
"status = pmui.Typography(\"Not clicked yet\")\n",
109+
"\n",
110+
"clickable = pmui.Clickable(\n",
111+
" pmui.Card(title=\"Click this card\", height=100, width=250),\n",
112+
" on_click=lambda e: status.param.update(object=f\"Clicked {e.new} times\")\n",
113+
")\n",
114+
"\n",
115+
"pmui.Column(clickable, status)"
116+
]
117+
},
118+
{
119+
"cell_type": "markdown",
120+
"id": "e1",
121+
"metadata": {},
122+
"source": [
123+
"### Disable Ripple\n",
124+
"\n",
125+
"Set `disable_ripple=True` to remove the ripple animation on click while keeping the click behavior:"
126+
]
127+
},
128+
{
129+
"cell_type": "code",
130+
"execution_count": null,
131+
"id": "e2",
132+
"metadata": {},
133+
"outputs": [],
134+
"source": [
135+
"pmui.Row(\n",
136+
" pmui.Clickable(\n",
137+
" pmui.Paper(pmui.Column(pn.pane.Str(\"With ripple\")), elevation=2, width=150, height=80),\n",
138+
" ),\n",
139+
" pmui.Clickable(\n",
140+
" pmui.Paper(pmui.Column(pn.pane.Str(\"No ripple\")), elevation=2, width=150, height=80),\n",
141+
" disable_ripple=True,\n",
142+
" ),\n",
143+
")"
144+
]
145+
},
146+
{
147+
"cell_type": "markdown",
148+
"id": "f1",
149+
"metadata": {},
150+
"source": [
151+
"### Disabled\n",
152+
"\n",
153+
"Set `disabled=True` to prevent clicks:"
154+
]
155+
},
156+
{
157+
"cell_type": "code",
158+
"execution_count": null,
159+
"id": "f2",
160+
"metadata": {},
161+
"outputs": [],
162+
"source": [
163+
"pmui.Clickable(\n",
164+
" pmui.Paper(pmui.Column(pn.pane.Str(\"Can't click me\")), elevation=2, width=200, height=80),\n",
165+
" disabled=True,\n",
166+
")"
167+
]
168+
},
169+
{
170+
"cell_type": "markdown",
171+
"id": "g1",
172+
"metadata": {},
173+
"source": [
174+
"### Responsive Sizing\n",
175+
"\n",
176+
"When the wrapped component uses a responsive `sizing_mode`, set the same on the `Clickable` so the child fills the available space:"
177+
]
178+
},
179+
{
180+
"cell_type": "code",
181+
"execution_count": null,
182+
"id": "g2",
183+
"metadata": {},
184+
"outputs": [],
185+
"source": [
186+
"pmui.Clickable(\n",
187+
" pmui.Paper(\n",
188+
" pmui.Column(pn.pane.Str(\"Full width clickable area\")),\n",
189+
" elevation=2, sizing_mode=\"stretch_width\", height=80\n",
190+
" ),\n",
191+
" sizing_mode=\"stretch_width\",\n",
192+
")"
193+
]
194+
}
195+
],
196+
"metadata": {
197+
"kernelspec": {
198+
"display_name": "Python 3 (ipykernel)",
199+
"language": "python",
200+
"name": "python3"
201+
},
202+
"language_info": {
203+
"codemirror_mode": {
204+
"name": "ipython",
205+
"version": 3
206+
},
207+
"file_extension": ".py",
208+
"mimetype": "text/x-python",
209+
"name": "python",
210+
"nbconvert_exporter": "python",
211+
"pygments_lexer": "ipython3",
212+
"version": "3.14.4"
213+
}
214+
},
215+
"nbformat": 4,
216+
"nbformat_minor": 5
217+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import ButtonBase from "@mui/material/ButtonBase"
2+
import Box from "@mui/material/Box"
3+
4+
export function render({model}) {
5+
const [disabled] = model.useState("disabled")
6+
const [disableRipple] = model.useState("disable_ripple")
7+
const [sx] = model.useState("sx")
8+
const object = model.get_child("object")
9+
10+
const mergedSx = React.useMemo(() => ({
11+
display: "inline-flex",
12+
width: "100%",
13+
height: "100%",
14+
textAlign: "inherit",
15+
...sx,
16+
}), [sx])
17+
18+
return (
19+
<ButtonBase
20+
disabled={disabled}
21+
disableRipple={disableRipple}
22+
onClick={() => model.send_event("click", {})}
23+
sx={mergedSx}
24+
>
25+
<Box sx={{width: "100%", height: "100%"}}>
26+
{object || null}
27+
</Box>
28+
</ButtonBase>
29+
)
30+
}

src/panel_material_ui/wrappers/base.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22

33
import typing as t
4+
from collections.abc import Awaitable, Callable
45

56
import param
67
from panel.viewable import Child
8+
from panel.widgets.button import _ClickButton
79

810
from ..base import COLORS, ColorType, MaterialComponent
911

@@ -25,6 +27,70 @@ def __init__(self, object=None, **params):
2527
super().__init__(**params)
2628

2729

30+
class Clickable(Wrapper, _ClickButton):
31+
"""
32+
The `Clickable` wrapper adds click interaction to any child
33+
component. It wraps a single child element with a clickable area,
34+
providing a `clicks` counter and an `on_click` callback mechanism.
35+
36+
Optionally renders a Material UI ripple effect on click.
37+
38+
:References:
39+
40+
- https://mui.com/material-ui/api/button-base/
41+
42+
:Example:
43+
44+
>>> Clickable(Card(...), on_click=lambda e: print("Clicked!"))
45+
"""
46+
47+
clicks = param.Integer(default=0, doc="""
48+
Number of clicks. Increment triggers registered callbacks.""")
49+
50+
disable_ripple = param.Boolean(default=False, doc="""
51+
Whether to disable the ripple effect on click.""")
52+
53+
disabled = param.Boolean(default=False, doc="""
54+
Whether the clickable area is disabled.""")
55+
56+
value = param.Event(doc="""
57+
Toggles from False to True while the event is being processed.""")
58+
59+
_esm_base = "Clickable.jsx"
60+
_event = "dom_event"
61+
_rename = {"title": None, "name": None, "label": None}
62+
63+
def __init__(self, object=None, **params):
64+
click_handler = params.pop("on_click", None)
65+
if object is not None:
66+
params["object"] = object
67+
super().__init__(**params)
68+
if click_handler:
69+
self.on_click(click_handler)
70+
71+
def _handle_click(self, event):
72+
self.param.update(clicks=self.clicks + 1, value=True)
73+
74+
def on_click(
75+
self, callback: Callable[[param.parameterized.Event], None | Awaitable[None]]
76+
) -> param.parameterized.Watcher:
77+
"""
78+
Register a callback to be executed when the component is clicked.
79+
80+
Arguments
81+
---------
82+
callback:
83+
The function to run on click events. Must accept a positional
84+
`Event` argument. Can be a sync or async function.
85+
86+
Returns
87+
-------
88+
watcher: param.Parameterized.Watcher
89+
A `Watcher` that executes the callback when clicked.
90+
"""
91+
return self.param.watch(callback, "clicks", onlychanged=False)
92+
93+
2894
class Transition(Wrapper):
2995
"""
3096
The `Transition` wraps a child component with a transition effect

0 commit comments

Comments
 (0)