Open MarcSkovMadsen opened 2 years ago
Using webcomponents this should already work but for general react support I really need some code that dynamically imports React modules, which is not straightforward. I don't think there's any way to wrap arbitrary React components in a webcomponent in a general way.
I ended up creating a python
ReactGenerator
functionality.
class MaterialWidget(pn.reactive.ReactiveHTML):
@staticmethod
def _scripts(element, properties, events, children):
widget = ReactGenerator(
element=element,
properties=properties,
events=events,
children=children,
)
widget_with_tooltip = ReactGenerator(
element="MaterialUI.Tooltip",
properties={"title": "tooltip", "placement": "tooltip_placement"},
children=[widget],
)
return widget_with_tooltip.scripts
I expect to be able to generate the _scripts
of any materialui
widget something like
_scripts=MaterialWidget._scripts(
element="MaterialUI.Button",
properties={
# Widget
"autofocus": "autofocus",
"className": "_css_names",
"disabled": "disabled",
# MaterialButton
"color": "color",
"disableElevation": "disable_elevation",
"disableFocusRipple": "disable_focus_ripple",
"disableRipple": "disable_ripple",
"fullWidth": "full_width",
"size": "size",
"variant": "variant",
},
events={
"onClick": "data.clicks += 1"
},
children=["data.name"]
)
Similarly for reactbootstrap
, ant
etc.
from typing import Dict
import param
import pytest
import panel as pn
from panel.widgets.button import BUTTON_TYPES
from collections import namedtuple
class ReactGenerator(param.Parameterized):
element = param.String()
id = param.String("component")
properties = param.Dict({})
events = param.Dict({})
children = param.List()
_events: Dict[str, str] = {}
_properties: Dict[str, str] = {}
@property
def template(self) -> str:
"""Returns the `_template` of the component"""
return f"""<div id="{self.id}" class="pnc-component"></div>"""
def _create_element(self, child):
if isinstance(child, ReactGenerator):
return child.create_element
elif isinstance(child, str):
return child
else:
raise NotImplementedError()
@property
def all_properties(self):
return {**self._properties, **self.properties}
@property
def all_events(self):
return {**self._events, **self.events}
@property
def configuration(self):
properties = {k: "data." + v for k, v in self.all_properties.items()}
events = {k: f"()=>{{{v}}}" for k, v in self.all_events.items()}
return {**properties, **events}
@property
def create_element(self):
configuration = self.configuration
configuration_str = f"{configuration}".replace("'", "")
children = [self._create_element(child) for child in self.children]
if len(children) == 1:
children_str = children[0]
else:
children_str = f"{children}".replace("'", "")
return f"""React.createElement({self.element},{configuration_str},{children_str})"""
@property
def update_element(self):
return (
f"""element={self.create_element};"""
"""ReactDOM.unmountComponentAtNode(state.component);"""
"""ReactDOM.render(element,state.component)"""
)
@property
def update_scripts(self):
if self.all_properties:
scripts = {v: "self.updateElement()" for v in self.all_properties.values()}
else:
scripts = {}
for child in self.children:
if isinstance(child, ReactGenerator):
scripts.update(child.update_scripts)
return scripts
@property
def render_script(self):
return "state.component=component;self.updateElement()"
@property
def scripts(self) -> Dict:
"""Returns the `_scripts` of the component"""
return {
"render": self.render_script,
"updateElement": self.update_element,
**self.update_scripts,
}
class WidgetBase(pn.reactive.ReactiveHTML): # pylint: disable=too-few-public-methods, too-many-ancestors
"""Base Widget Class. You `panel_component` widgets should inherit from this"""
name = param.String(default="")
tooltip = param.String()
tooltip_placement = param.String("default")
css_names = param.List([])
disabled = param.Boolean(
default=False,
doc="""
Whether the widget is disabled.""",
)
autofocus = param.Boolean(
default=False,
doc="""The autofocus attribute. Defaults to `False`""",
)
margin = param.Parameter(
default=(5, 10),
doc="""
Allows to create additional space around the component. May
be specified as a two-tuple of the form (vertical, horizontal)
or a four-tuple (top, right, bottom, left).""",
)
_css_names = param.String("")
_scripts = {
"render": (
"component.disabled=data.disabled;"
"component.title=data.tooltip;"
"component.autofocus=data.autofocus;"
"component.className=data._css_names"
),
"disabled": "component.disabled=data.disabled",
"tooltip": "component.title=data.tooltip",
"autofocus": "component.autofocus=data.autofocus",
"_css_names": "component.className=data._css_names",
}
_properties = {
"disabled": "disabled",
"title": "tooltip",
"autofocus": "autofocus",
"className": "_css_names",
}
_events: Dict[str, str] = {}
def __init__(self, **params):
super().__init__(**params)
self.param.watch(self._handle_css_names_changed, "css_names")
self._handle_css_names_changed()
def _handle_css_names_changed(self, event=None): # pylint: disable=unused-argument
css_names = self._get_css_names()
self._set_css_names(css_names)
def _get_css_names(self):
return list(set(self._css_names_component + self.css_names))
def _set_css_names(self, css_names):
with param.edit_constant(self):
self._css_names = " ".join(css_names)
class ButtonBase(WidgetBase): # pylint: disable=too-many-ancestors
"""The Buttons in `panel_components` should inherit from this"""
value = param.Event(precedence=-1)
clicks = param.Integer(default=0)
button_type = param.ObjectSelector(default="default", objects=BUTTON_TYPES)
css_names = param.List([])
height = param.Integer(default=32, bounds=(0, None))
width = param.Integer(default=300, bounds=(0, None))
_css_names = param.String("")
_scripts = {
**WidgetBase._scripts,
"click": "data.clicks += 1",
}
_properties = {
**WidgetBase._properties
}
_events = {
**WidgetBase._events,
"onClick": "data.clicks += 1",
}
_css_names_component = ["pnc-widget"]
def __init__(self, **params):
super().__init__(**params)
self.param.watch(self._handle_button_type_changed, "button_type")
self.param.watch(self._handle_css_names_changed, "css_names")
self._handle_button_type_changed()
self._handle_css_names_changed()
@param.depends("clicks", watch=True)
def _trigger_value_event(self):
self.param.trigger("value")
def _handle_button_type_changed(self, event=None):
self._handle_css_names_changed(event=event)
def _handle_css_names_changed(self, event=None): # pylint: disable=unused-argument
css_names = self._get_css_names()
self._set_css_names(css_names)
def _get_css_names(self):
return list(set(self._css_names_component + self.css_names))
def _set_css_names(self, css_names):
with param.edit_constant(self):
self._css_names = " ".join(css_names)
@classmethod
def example(cls):
raise NotImplementedError()
TOOLTIP_PLACEMENTS = [
"bottom-end",
"bottom-start",
"bottom",
"left-end",
"left-start",
"left",
"right-end",
"right-start",
"right",
"top-end",
"top-start",
"top",
]
_Config = namedtuple("_Config", "variant color")
BUTTON_TYPE_MAP = {
"default": _Config("outlined", "primary"),
"primary": _Config("contained", "primary"),
"success": _Config("contained", "success"),
"warning": _Config("contained", "warning"),
"danger": _Config("contained", "error"),
"light": _Config("text", "primary"),
}
SIZE_MAP = {
"small": 32, # MaterialUI is 30.75 but we keep this consistent with Panels default
"medium": 36,
"large": 42,
}
SIZES = list(SIZE_MAP.keys())
class MaterialWidget(pn.reactive.ReactiveHTML):
_template = """<div id="component" class="pnc-component"></div>"""
__javascript__ = [
"https://unpkg.com/react@17.0.2/umd/react.production.min.js",
"https://unpkg.com/react-dom@17.0.2/umd/react-dom.production.min.js",
"https://unpkg.com/@material-ui/core@4.12.3/umd/material-ui.development.js",
]
__css__ = [
"https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap",
"https://fonts.googleapis.com/icon?family=Material+Icons",
]
@staticmethod
def _scripts(element, properties, events, children):
widget = ReactGenerator(
element=element,
properties=properties,
events=events,
children=children,
)
widget_with_tooltip = ReactGenerator(
element="MaterialUI.Tooltip",
properties={"title": "tooltip", "placement": "tooltip_placement"},
children=[widget],
)
return widget_with_tooltip.scripts
class MaterialButton(MaterialWidget, ButtonBase):
variant = param.Selector(
default="outlined", objects=["contained", "outlined", "text", "string"]
)
color = param.Selector(
default="primary",
objects=[
"inherit",
"primary",
"secondary",
"success",
"error",
"info",
"warning",
"string",
],
)
size = param.Selector(default="small", objects=SIZES)
disable_elevation = param.Boolean()
disable_focusRipple = param.Boolean()
disable_ripple = param.Boolean()
tooltip = param.String("Click Me!")
tooltip_placement = param.Selector(default="bottom", objects=TOOLTIP_PLACEMENTS)
width = param.Integer(default=300, bounds=(0, None))
full_width = param.Boolean(default=True)
height = param.Integer(default=36, bounds=(0, None))
_scripts=MaterialWidget._scripts(
element="MaterialUI.Button",
properties={
# Widget
"autofocus": "autofocus",
"className": "_css_names",
"disabled": "disabled",
# MaterialButton
"color": "color",
"disableElevation": "disable_elevation",
"disableFocusRipple": "disable_focus_ripple",
"disableRipple": "disable_ripple",
"fullWidth": "full_width",
"size": "size",
"variant": "variant",
},
events={
"onClick": "data.clicks += 1"
},
children=["data.name"]
)
def __init__(self, **params):
super().__init__(**params)
self._handle_size_changed()
def _handle_button_type_changed(self, event=None):
if event:
button_type = event.new
else:
button_type = self.button_type
config = BUTTON_TYPE_MAP[button_type]
self.variant = config.variant
self.color = config.color
@param.depends("size", watch=True)
def _handle_size_changed(self):
self.height = SIZE_MAP[self.size]
material_button = MaterialButton(name="click")
pn.Column(material_button, material_button.controls()).servable()
Feature
I would like to be able to wrap React components in
ReactiveHTML
easily. This includes MaterialUI, ReactBootstrap, Ant and many more. I would like to be able to do something as simple asThe special requirement here is to be able to use the react components as tags inside my
_template
. I believe it should be possible via preact, htm and maybe preact webcomponents on top of whichReactiveHTML
is built. I've tried to so far have not been successful to create something.Context
I'm trying to wrap React Component into Panel components here https://github.com/marcskovmadsen/panel-components. After having worked through different example of Ant, MaterialUi, ReactBootstrap components I feel
ReactiveHTML
does not make it easy to wrap React components.With inspiration from @xavArtley here https://discourse.holoviz.org/t/material-ui-framework-with-reactivehtml/2723/5?u=marc the concept for wrapping a MaterialUI Button looks like
The problem with this is 1) why do I need so many
self.updateElement
scripts 2) TheupdateElement
quickly becomes bloated and unreadable if I need to nest a few react components like the MaterialUI Button and Tooltip in this example.I've created some help functionality to make this more general
Its sort of simple and nice. The problem is still that a method in
MaterialGenerator
still needs to generate the nesting of the MaterialUI Button and Tooltip. It quickly becomes un-readable and un-writable.I see two solutions. One is to make a
python
wrapper aroundReact.createElement
the other is to make the_template
of ReactiveHTML more powerful. The latter is this Feature Request.Ideas
Maybe some kind of
on_creation
life cycle hook is need that can be used to register React components as Web Components before the first render of the template. then the React components could be used as web components in the template.Even better would just be some way to refer to
MaterialUI.Button
etc inside the_template
.