Skip to content

Commit

Permalink
refactor!: make widget composable by allowing children (#52)
Browse files Browse the repository at this point in the history
* refactor!: make widget composable by allowing children

BREAKING CHANGE: this is a breaking change because of renaming
set_value to setValue on the frontend side.

This commit makes several changes, instead of having a single
ReactWidget we now have a Widget (with no default value trait)
and a ValueWidget (with a default value trait).

Furthermore, by specificing _module and _type instead of _esm
we can now render any React component from any ES module,
or even standard html components like <div> or <span>.

The main (wrapper) component is now created in the model, which
makes it easier to obtain the components of children. Once the
main wrapper component is created, the while children tree is also
resolved, and a synchroneous render can be made in one go.

* fix: create components out of children instead of elements to pass down the rootView

* polish and docs

* suggestion by mario for changing wording

* add tests
  • Loading branch information
maartenbreddels authored Feb 6, 2024
1 parent 3011c5b commit eb18699
Show file tree
Hide file tree
Showing 13 changed files with 1,133 additions and 535 deletions.
165 changes: 142 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ React for ipywidgets that just works. No webpack, no npm, no hassle. Just write

Build on top of [AnyWidget](https://anywidget.dev/).

## Why

Ipyreact adds composability, allowing you to add children to your widget, which will render the whole react tree in
a single react context, without adding extra divs or creating a new react context.

This allows wrapping libraries such as [Material UI](https://mui.com/), [Ant Design](https://ant.design/) and even
[React-three-fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction).

## Tutorial

This tutorial will walk you through the steps of building a complete ipywidget with react.
Expand All @@ -13,9 +21,11 @@ This tutorial will walk you through the steps of building a complete ipywidget w

Just click the JupyterLite or Binder link to start the interactive walkthrough.

## Goal
## Goals

Take any [Material UI example](https://mui.com/material-ui/react-rating/), copy/paste the code, and it should work in Jupyter Notebook, Jupyter Lab, Voila, and more specifically, [Solara](https://github.com/widgetti/solara).
- Take any [Material UI example](https://mui.com/material-ui/react-rating/), copy/paste the code, and it should work in Jupyter Notebook, Jupyter Lab, Voila, and more specifically, [Solara](https://github.com/widgetti/solara).
- Wrap a library such as [Ant Design](https://ant.design/) giving the options to customize any JSON<->JavaScript Object (de)serialization, such as the [DatePicker](https://ant.design/components/date-picker) which uses a dayjs object internally, which cannot be serialized over the wire to Python.
- Compose widgets together to form a single react tree, with the same react context (e.g. useContext).

## Examples

Expand All @@ -25,13 +35,13 @@ Take any [Material UI example](https://mui.com/material-ui/react-rating/), copy/
import ipyreact


class ConfettiWidget(ipyreact.ReactWidget):
class ConfettiWidget(ipyreact.ValueWidget):
_esm = """
import confetti from "canvas-confetti";
import * as React from "react";
export default function({value, set_value, debug}) {
return <button onClick={() => confetti() && set_value(value + 1)}>
export default function({value, setValue}) {
return <button onClick={() => confetti() && setValue(value + 1)}>
{value || 0} times confetti
</button>
};"""
Expand All @@ -40,6 +50,8 @@ ConfettiWidget()

![initial-30-fps-compressed](https://user-images.githubusercontent.com/1765949/233469170-c659b670-07f5-4666-a201-80dea01ebabe.gif)

(_NOTE: in the recording we used on_value, we now use setValue_)

### Hot reloading

Create a tsx file:
Expand All @@ -49,9 +61,9 @@ Create a tsx file:
import confetti from "canvas-confetti";
import * as React from "react";

export default function ({ value, set_value, debug }) {
export default function ({ value, setValue }) {
return (
<button onClick={() => confetti() && set_value(value + 1)}>
<button onClick={() => confetti() && setValue(value + 1)}>
{value || 0} times confetti
</button>
);
Expand All @@ -65,7 +77,7 @@ import ipyreact
import pathlib


class ConfettiWidget(ipyreact.ReactWidget):
class ConfettiWidget(ipyreact.ValueWidget):
_esm = pathlib.Path("confetti.tsx")

ConfettiWidget()
Expand All @@ -75,6 +87,8 @@ Now edit, save, and see the changes in your browser/notebook.

![hot-reload-compressed](https://user-images.githubusercontent.com/1765949/233470113-b2aa9284-71b9-44f0-bd52-906a08b06e14.gif)

(_NOTE: in the recording we used on_value, we now use setValue_)

### IPython magic

First load the ipyreact extension:
Expand All @@ -90,8 +104,8 @@ Then use the `%%react` magic to directly write jsx/tsx in your notebook:
import confetti from "canvas-confetti";
import * as React from "react";

export default function({value, set_value, debug}) {
return <button onClick={() => confetti() && set_value(value + 1)}>
export default function({value, setValue}) {
return <button onClick={() => confetti() && setValue(value + 1)}>
{value || 0} times confetti
</button>
};
Expand All @@ -101,6 +115,8 @@ Access the underlying widget with the name `_last_react_widget` (e.g. `_last_rea

![magic-optimized](https://user-images.githubusercontent.com/1765949/233471041-62e807d6-c16d-4fc5-af5d-13c0acb2c677.gif)

(_NOTE: in the recording we used on_value, we now use setValue_)

## Installation

You can install using `pip`:
Expand All @@ -111,19 +127,97 @@ pip install ipyreact

## Usage

## Facts
### Summary

- The ReactWidget has an `value` trait, which is a `traitlets.Any` trait. Use this to pass data to your react component, or to get data back from your react component.
- All traits are added as props to your react component (e.g. `{value, ...}` in th example above.
- For every trait `ipyreact` automatically provides a `set_<traitname>` callback, which you can use to set the trait value from your react component (e.g. `set_value` in the example above). (_Note: we used `on_value` before, this is now deprecated_)
- The `ValueWidget` has an `value` trait, which is a `traitlets.Any` trait. Use this to pass data to your react component, or to get data back from your react component (since it inherits from ipywidgets.ValueWidget it
can be used in combination with ipywidgets' [interact](https://ipywidgets.readthedocs.io/en/latest/examples/Using%20Interact.html)).
- The `ipyreact.Widget` does not have the `value` trait.
- All traits are added as props to your react component (e.g. `{value, setValue...}` pairs in the example above.
- For every trait `ipyreact` automatically provides a `set<Traitname>` callback, which you can use to set the trait value from your react component (e.g. `setValue` in the example above). (_Note: we used `on_value` before, this is now deprecated_)
- Props can de passed as `Widget(props={"title": "My title"})`, and contrary to a trait, will not add a `setTitle` callable to the props.
- Children can be passed using `Widget(children=['text', or_widget])` supporting text, widgets, and un-interrupted rendering of ipyreact widgets.
- Your code gets transpiled using [sucrase](https://github.com/alangpierce/sucrase) in the frontend, no bundler needed.
- Your code should be written in ES modules.
- Set `debug=True` to get more debug information in the browser console (also accessible in the props).
- Set `_debug=True` to get more debug information in the browser console.
- Make sure you export a default function from your module (e.g. `export default function MyComponent() { ... }`). This is the component that will be rendered.
- Pass `events={"onClick": handler}` to the constructor or add a method with the name `event_onClick(self, data=None)` to add a `onClick` callback to your props.

### HTML elements

You do not need to provide the module code to create built-in HTML elements, ipyreact supports the same API as [React's createElement](https://react.dev/reference/react/createElement)
allowing creation of buttons for instance.

```python
import ipyreact
ipyreact.Widget(_type="button", children=["click me"])
```

Note that in addition to all native browser elements, also web components are supported.

### Children

As shown in the above example, we also support children, which supports a list of strings (text), `ipyreact.Widget` widgets that will be rendered as an uninterrupted react tree, or
any other `ipywidgets`

```python
import ipyreact
import ipywidgets as widgets
ipyreact.Widget(_type="div", children=[
"normal text",
ipyreact.Widget(_type="button", children=["nested react widgets"]),
widgets.FloatSlider(description="regular ipywidgets")
])
```

[![JupyterLight](https://jupyterlite.rtfd.io/en/latest/_static/badge.svg)](https://widgetti.github.io/ipyreact/lab/?path=children.ipynb)
[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/widgetti/ipyreact/HEAD?labpath=examples%2Fchildren.ipynb)

### Events

Events can be passed via the event argument. In this case `onClick` will be added as a prop to the button element.

```python
import ipyreact
ipyreact.Widget(_type="button", children=["click me"], events={"onClick": print})
```

Subclasses can also add an `event_onClick` method, which will also add a `onClick` event handler to the props.

[![JupyterLight](https://jupyterlite.rtfd.io/en/latest/_static/badge.svg)](https://widgetti.github.io/ipyreact/lab/?path=events.ipynb)
[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/widgetti/ipyreact/HEAD?labpath=examples%2Fevents.ipynb)

### Importing external modules

Writing JSX code without having to compile/bundle is great, but so is using external libraries.

Ipyreact uses ES modules, which allows native importing of external libraries when written as an ES module.
In the example below, we use https://esm.sh/ which exposes many JS libraries as ES modules that
we can directly import.

```python
import ipyreact

ipyreact.ValueWidget(
_esm="""
import confetti from "https://esm.sh/[email protected]";
import * as React from "react";
export default function({value, setValue}) {
return <button onClick={() => confetti() && setValue(value + 1)}>
{value || 0} times confetti
</button>
};
"""
)
```

### Import maps

For every widget, you can provide an `_import_map`, which is a dictionary of module names to urls. By default we support `react` and `react-dom` which is prebundled.
However, the above code now has a direct link to "https://esm.sh/[email protected]" which makes the code very specific to esm.sh.

To address this, we also support [import maps](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) to
write code more independant of where the modules come from.
For every widget, you can provide an `_import_map`, which is a dictionary of module names to urls or other modules. By default we support `react` and `react-dom` which is prebundled.

Apart from `react`, the default we provide is:

Expand All @@ -139,30 +233,55 @@ _import_map = {
}
```

Which means we can copy paste _most_ of the examples from [mui](https://mui.com/)
Which means we can now write our ConfettiButton as:

```python
import ipyreact

ipyreact.ValueWidget(
_esm="""
import confetti from "confetti";
import * as React from "react";
export default function({value, setValue}) {
return <button onClick={() => confetti() && setValue(value + 1)}>
{value || 0} times confetti
</button>
};
""",
# note that this import_map is already part of the default
_import_map={
"imports": {
"confetti": "https://esm.sh/[email protected]",
},

}
)
```

And it also means we can copy paste _most_ of the examples from [mui](https://mui.com/)

```tsx
%%react -n my_widget -d
import Button from "@mui/material/Button";
import confetti from "canvas-confetti";
import * as React from "react";

export default function({ value, set_value, debug }) {
if(debug) {
console.log("value=", value, set_value);
}
export default function({ value, setValue}) {
console.log("value=", value);
return (
<Button
variant="contained"
onClick={() => confetti() && set_value(value + 1)}
onClick={() => confetti() && setValue(value + 1)}
>
{value || 0} times confetti
</Button>
);
}
```

We add the https://github.com/guybedford/es-module-shims shim to the browser page for the import maps functionality.
We use the https://github.com/guybedford/es-module-shims shim to the browser page for the import maps functionality.
This also means that although import maps can be configured per widget, they configuration of import maps is global.

## Development Installation

Expand Down
28 changes: 6 additions & 22 deletions examples/Observe_example.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "code",
"execution_count": 2,
"execution_count": null,
"id": "aba8b4d5-199f-4fe0-97a2-5a2a3f43cbf2",
"metadata": {},
"outputs": [],
Expand All @@ -13,26 +13,10 @@
},
{
"cell_type": "code",
"execution_count": 3,
"execution_count": null,
"id": "b11af66b-f256-4441-8c38-0283866024bd",
"metadata": {},
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "a8bc3645b9e14fc7bfeaa919a8d67d75",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
"EvenOddWidget()"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"outputs": [],
"source": [
"def check_is_even(number):\n",
" if number %2 == 0:\n",
Expand All @@ -56,8 +40,8 @@
" import confetti from \"canvas-confetti\";\n",
" import * as React from \"react\";\n",
"\n",
" export default function({set_count, debug, count, message}) {\n",
" return <div><button onClick={() => confetti() && set_count(count + 1)}>\n",
" export default function({setCount, count, message}) {\n",
" return <div><button onClick={() => confetti() && setCount(count + 1)}>\n",
" {count} times confetti\n",
" </button>\n",
" <br/>\n",
Expand Down Expand Up @@ -93,7 +77,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.3"
"version": "3.9.16"
}
},
"nbformat": 4,
Expand Down
50 changes: 50 additions & 0 deletions examples/children.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "a828d6c2",
"metadata": {},
"outputs": [],
"source": [
"import ipyreact\n",
"import ipywidgets as widgets\n",
"\n",
"ipyreact.Widget(_type=\"div\", children=[\n",
" \"normal text\",\n",
" ipyreact.Widget(_type=\"button\", children=[\"nested react widgets\"]),\n",
" widgets.FloatSlider(description=\"regular ipywidgets\")\n",
"])"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6b9e745b",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.9.16"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Loading

0 comments on commit eb18699

Please sign in to comment.