diff --git a/README.md b/README.md index d775e01..ec06e0c 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 };""" @@ -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: @@ -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 ( - ); @@ -65,7 +77,7 @@ import ipyreact import pathlib -class ConfettiWidget(ipyreact.ReactWidget): +class ConfettiWidget(ipyreact.ValueWidget): _esm = pathlib.Path("confetti.tsx") ConfettiWidget() @@ -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: @@ -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 }; @@ -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`: @@ -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_` 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` 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/canvas-confetti@1.6.0"; + import * as React from "react"; + + export default function({value, setValue}) { + return + }; + """ +) +``` ### 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/canvas-confetti@1.6.0" 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: @@ -139,7 +233,33 @@ _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 + }; + """, + # note that this import_map is already part of the default + _import_map={ + "imports": { + "confetti": "https://esm.sh/canvas-confetti@1.6.0", + }, + + } +) +``` + +And it also means we can copy paste _most_ of the examples from [mui](https://mui.com/) ```tsx %%react -n my_widget -d @@ -147,14 +267,12 @@ 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 ( @@ -162,7 +280,8 @@ export default function({ value, set_value, debug }) { } ``` -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 diff --git a/examples/Observe_example.ipynb b/examples/Observe_example.ipynb index b9cf1b6..8ab14e6 100644 --- a/examples/Observe_example.ipynb +++ b/examples/Observe_example.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "aba8b4d5-199f-4fe0-97a2-5a2a3f43cbf2", "metadata": {}, "outputs": [], @@ -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", @@ -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
\n", "
\n", @@ -93,7 +77,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.3" + "version": "3.9.16" } }, "nbformat": 4, diff --git a/examples/children.ipynb b/examples/children.ipynb new file mode 100644 index 0000000..88dca13 --- /dev/null +++ b/examples/children.ipynb @@ -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 +} diff --git a/examples/events.ipynb b/examples/events.ipynb index c1ab3f0..d227226 100644 --- a/examples/events.ipynb +++ b/examples/events.ipynb @@ -1,5 +1,16 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "a7ea28e1", + "metadata": {}, + "outputs": [], + "source": [ + "import ipyreact\n", + "ipyreact.Widget(_type=\"button\", children=[\"click me\"], events={\"onClick\": print})" + ] + }, { "cell_type": "code", "execution_count": null, @@ -33,18 +44,26 @@ " \n", " # all on_* methods are automatically available from the frontend\n", " # with the same name as a prop\n", - " def on_click(self):\n", + " def event_on_click(self):\n", " self.label = \"Clicked\"\n", "\n", " # an optional argument can be passed\n", " # an optional third argument can contain buffers (not used here)\n", - " def on_pass_data(self, data):\n", + " def event_on_pass_data(self, data):\n", " print(data)\n", " self.label = f'Clicked \"Pass data\" at {data[\"x\"]},{data[\"y\"]} when label was {data[\"label\"]}'\n", " \n", - "b = ButtonWithHandler()\n", + "b = ButtonWithHandler(debug=True)\n", "b" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b09c0518", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/examples/full_tutorial.ipynb b/examples/full_tutorial.ipynb index 728eee0..0fad7e5 100644 --- a/examples/full_tutorial.ipynb +++ b/examples/full_tutorial.ipynb @@ -1,7 +1,6 @@ { "cells": [ { - "attachments": {}, "cell_type": "markdown", "id": "d8ec9a76-0819-463d-8955-55789ee36400", "metadata": {}, @@ -10,7 +9,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "083f2dc7-c0ec-482f-a720-aa2896ad193e", "metadata": { @@ -37,7 +35,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "06cb067d-0aea-4851-827d-d07f22466693", "metadata": {}, @@ -50,7 +47,9 @@ "cell_type": "code", "execution_count": null, "id": "de17b188", - "metadata": {}, + "metadata": { + "scrolled": true + }, "outputs": [], "source": [ "%pip install -q ipyreact\n", @@ -86,14 +85,13 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "4b51d34f-8abc-4b7b-936e-6d582a1b64fe", "metadata": {}, "source": [ "Great, here we can see react code rendering in the jupyter notebook! \n", "Next, we **convert this into a widget.** \n", - "For that, we need the code in a `_esm` string inside a class that inherits from `ipyreact.ReactWidget`. \n", + "For that, we need the code in a `_esm` string inside a class that inherits from `ipyreact.Widget`. \n", "`esm` is short for for EcmaScript module, and thats standard for structuring JavaScript code in reusable components." ] }, @@ -108,24 +106,69 @@ "source": [ "import ipyreact\n", "\n", - "class MyExampleWidget(ipyreact.ReactWidget):\n", + "class MyExampleWidget(ipyreact.Widget):\n", " _esm = \"\"\"\n", " import * as React from \"react\";\n", "\n", " export default function MyButton() {\n", - " return < button > X < /button> \n", - " };\"\"\"\n", + " return \n", - " }};\"\"\"\n", + " return \n", + " }};\"\"\"\n", "MyExampleWidget()" ] }, { - "attachments": {}, "cell_type": "markdown", - "id": "944363e3-92b1-4ee6-8e16-1a2f6879fefb", + "id": "12d2a8a2", "metadata": {}, "source": [ - "Instead, we can **use [traitlets](https://traitlets.readthedocs.io/en/stable/using_traitlets.html)**. \n", - "Traitlets are objects that can be used both in JavaScript and python. \n", - "Traitlets that are tagged with sync=True will be available not only on the Python side, but also in the frontend. \n", + "## Parametrizing using props\n", "\n", - "To access the value from the JavaScript side, we can use that all values are passed as a single object as first argument. \n", - "So we can use [object destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#object_destructuring) to get access to the variable: `MyButton({my_message})`." + "If you only want to set a value from the Python side, you can use the props to pass data to the component in the frontend.\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "0e27dc18-82f2-4598-9117-9e0cfec49f99", - "metadata": { - "tags": [] - }, + "id": "a2af801c", + "metadata": {}, "outputs": [], "source": [ "import ipyreact\n", "from traitlets import Unicode\n", "\n", - "# ⭐⭐⭐ This is good practice again ⭐⭐⭐\n", - "class MyExampleWidget(ipyreact.ReactWidget):\n", - " my_message = Unicode(\"Hi There\").tag(sync=True)\n", + "class MyExampleWidget(ipyreact.Widget):\n", " _esm = \"\"\"\n", " import * as React from \"react\";\n", "\n", - " export default function MyButton({ my_message }) {\n", - " return ;\n", + " export default function MyButton({ message }) {\n", + " return ;\n", " };\"\"\"\n", - "MyExampleWidget()" + "MyExampleWidget(props={\"message\": \"hi\"})" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "e2fb6dcb-4477-4ff5-a7d0-5e0f209d83cf", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# we can use this traitlets also as parameters\n", - "MyExampleWidget(my_message=\"Super Button\")" - ] - }, - { - "attachments": {}, "cell_type": "markdown", - "id": "12f3691c-6d99-40ac-b80d-1db9b6f5d78c", + "id": "67118dd2", "metadata": {}, "source": [ - "Here we have an example of a button that changes the postion using traitlets" + "### Forwarding unused props and children\n", + "However, with this, we lose the ability to set all the other props like in the example above. We can use the following pattern using [object destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#object_destructuring) by passing all unused props (called `rest` in this example) to the button element. Also, do not forget to pass the children!." ] }, { "cell_type": "code", "execution_count": null, - "id": "56c9e4d7-03fc-4a30-81f8-83d63bab170a", - "metadata": { - "tags": [] - }, + "id": "a26f1e3d", + "metadata": {}, "outputs": [], "source": [ "import ipyreact\n", - "from traitlets import Int\n", + "from traitlets import Unicode\n", "\n", - "class MyExampleWidget(ipyreact.ReactWidget):\n", - " my_width = Int(23).tag(sync=True)\n", + "# ⭐⭐⭐ This is good practice again ⭐⭐⭐\n", + "class MyExampleWidget(ipyreact.Widget):\n", " _esm = \"\"\"\n", " import * as React from \"react\";\n", "\n", - " export default function MyButton({ my_width }) {\n", - " return (\n", - " \n", - " {\" \"}\n", - " Width of {my_width} px{\" \"}\n", - " \n", - " );\n", - " }\"\"\"\n", - "MyExampleWidget(my_width=300)" + " export default function MyButton({ message, children, ...rest }) {\n", + " return ;\n", + " };\"\"\"\n", + "MyExampleWidget(props={\"message\": \"hi\", \"title\": \"Behaves like a tooltip\"}, children=[' extra', ' children'])" + ] + }, + { + "cell_type": "markdown", + "id": "944363e3-92b1-4ee6-8e16-1a2f6879fefb", + "metadata": {}, + "source": [ + "## Adding state\n", + "\n", + "Props cannot be changed by the component, they are considered pure input.\n", + "\n", + "When you need the component to control state, you can add a trait ([see traitlets](https://traitlets.readthedocs.io/en/stable/using_traitlets.html)) to your widget class will (with `.tag(sync=True)` to make it sync to the frontend). For every trait added, you will receive a value and a setter to our props in the frontend.\n", + "\n", + "For instance, if you add a trait called `message`, you will have a `message` and `setMessage` in your props." ] }, { "cell_type": "code", "execution_count": null, - "id": "4dba603f-8052-4d8d-845d-d28029e17c35", + "id": "0e27dc18-82f2-4598-9117-9e0cfec49f99", "metadata": { "tags": [] }, "outputs": [], "source": [ - "MyExampleWidget(my_width=600)\n", - "#MyExampleWidget(my_width=\"Hello\") # <- this will throw an error, as my_width expects an integer" + "import ipyreact\n", + "from traitlets import Unicode\n", + "\n", + "class MyExampleWidget(ipyreact.Widget):\n", + " message = Unicode(\"Click me\").tag(sync=True)\n", + " _esm = \"\"\"\n", + " import * as React from \"react\";\n", + "\n", + " export default function MyButton({ message, setMessage }) {\n", + " return ;\n", + " };\"\"\"\n", + "w = MyExampleWidget()\n", + "w" ] }, { - "attachments": {}, "cell_type": "markdown", - "id": "7be3f055", + "id": "5dad7f59", "metadata": {}, "source": [ - "It's great that python will throw an error when the wrong type is given!" + "Every time the component calls `setMessage` the component will rerender itself with the new value for `message`, but will also synchronize the value to the Python side. \n", + "\n", + "If we change the value from the Python side, the value gets send to the frontend, and the component will render with the new `message` value." ] }, { "cell_type": "code", "execution_count": null, - "id": "93f2c43e-5a25-4787-a7a4-11f71a7ddbd1", - "metadata": { - "tags": [] - }, + "id": "5bc13be9", + "metadata": {}, "outputs": [], "source": [ - "# you can also create an instance of a class\n", - "w = MyExampleWidget(my_width=600)\n", - "w" + "w.message = \"Set from Python 🐍\"" ] }, { "cell_type": "code", "execution_count": null, - "id": "10d5d10c-2254-4be3-ac2b-66b851120b03", + "id": "e2fb6dcb-4477-4ff5-a7d0-5e0f209d83cf", "metadata": { "tags": [] }, "outputs": [], "source": [ - "# and display this instance multiple times\n", - "w" + "# we can use this traitlets also as parameters\n", + "MyExampleWidget(message=\"Different initial value\")" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "dc012a29-d639-48bd-b18f-21d687063e0d", - "metadata": { - "tags": [] - }, - "outputs": [], + "cell_type": "markdown", + "id": "5a4436e2", + "metadata": {}, "source": [ - "w.my_width = 400 # see how this will change the button width" + "It's great that python will throw an error when the wrong type is given!" ] }, { "cell_type": "code", "execution_count": null, - "id": "9dcc8417-b470-4981-aa77-4382e81882af", + "id": "4dba603f-8052-4d8d-845d-d28029e17c35", "metadata": { "tags": [] }, "outputs": [], "source": [ - "w.my_width = 200 # and one more time" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8c2956f1", - "metadata": {}, - "outputs": [], - "source": [ - "w.my_width # and get current parameter like this" + "# Note that traits can be type checked, this will result in an error because message is not a string (it is an int)\n", + "# w.message = 1" ] }, { - "attachments": {}, "cell_type": "markdown", - "id": "1463b79a", + "id": "30979edc", "metadata": {}, "source": [ - "Next, we will **add a counter** to this widget.\n", + "### ValueWidget\n", + "\n", + "Since it is very common that a component controls a single value (e.g. any input component, such as text input, a slider etc) we made a special subclass of `ipyreact.Widget` called `ipyreact.ValueWidget` that already contains a `value` trait.\n", "\n", - "There are two options:\n", - "🌱 Firstly, we can define a `my_count` traitlet and for the type we choose an Int traitlet. \n", - "Note that for for every trait there is a ` + set_` pair on the react/frontend side. \n", - "That means for `my_count`, there is `set_my_count`. \n" + "In many cases you do not even need to create a subclass, but can directly use the class to create an instance." ] }, { "cell_type": "code", "execution_count": null, - "id": "ea94bfd4", + "id": "2fbfdcf2", "metadata": {}, "outputs": [], "source": [ "import ipyreact\n", - "from traitlets import Int\n", + "from traitlets import Unicode\n", "\n", - "# 🌱🌱🌱 First option 🌱🌱🌱\n", "\n", + "# Although we can subclass, we don't need to in this case\n", + "# class MyExampleValueWidget(ipyreact.ValueWidget):\n", + "# # we get a value trait for free\n", + "# _esm = \"\"\"\n", + "# import * as React from \"react\";\n", "\n", - "class MyCounterWidget(ipyreact.ReactWidget):\n", - " my_count = Int(0).tag(sync=True)\n", - " _esm = \"\"\"\n", + "# export default function MyButton({ value, setValue }) {\n", + "# return ;\n", + "# };\"\"\"\n", + "# MyExampleValueWidget(value=\"Similar, but using the value/ValueWidget\")\n", + "\n", + "\n", + "# We can simply create an instance of ValueWidget\n", + "\n", + "ipyreact.ValueWidget(value=\"Similar, but using the value/ValueWidget\",\n", + " _esm=\"\"\"\n", " import * as React from \"react\";\n", "\n", - " export default function MyButton({my_count, set_my_count}) {\n", - " return \n", + " export default function MyButton({ value, setValue }) {\n", + " return ;\n", " };\"\"\"\n", - "MyCounterWidget()" + ")" ] }, { - "attachments": {}, "cell_type": "markdown", - "id": "c155a93a", + "id": "adb7a394", "metadata": {}, "source": [ - "🍁 Secondly, we can use the build in `value` traitlet, which has the type of traitlets.Any type.\n", - "And for `value` there is `set_value`. \n" + "The upside of using the `ValueWidget` is that it is a subclass of `ipywidgets.ValueWidget` and therefore can be used in [interact](https://ipywidgets.readthedocs.io/en/latest/examples/Using%20Interact.html).\n", + "\n", + "Having a standard name (`value`) for the trait can be useful, but might loss semantics in your specific case.\n", + "\n", + "Note that you may be tempted to add in many traits, since it makes it easier to modify the state of the component. But be aware that for ever trait added, your props get both a `foo` and `setFoo`. Make sure you do not accidently pass the `setFoo` to your child elements, as they might not support it (e.g. button has no setFoo attribute).\n" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "cf3566b7", + "cell_type": "markdown", + "id": "cee4af9a", "metadata": {}, - "outputs": [], "source": [ - "import ipyreact\n", + "## Adding events\n", "\n", - "# 🍁🍁🍁 Second option 🍁🍁🍁\n", + "Apart from the traits, the props trait, and the children, we also support events.\n", "\n", + "The events dict is a mapping from event name to an event handler. Event names can be native browser events, such as onClick on native elements (e.g. button), but they can also be custom events.\n", "\n", - "class MyCounterWidget(ipyreact.ReactWidget):\n", - " _esm = \"\"\"\n", - " import * as React from \"react\";\n", + "### Native events\n", "\n", - " export default function MyButton({value, set_value}) {\n", - " return \n", - " };\"\"\"\n", - "m = MyCounterWidget()\n", - "m" + "Native browser events are always of the form `on`, for instance, the [click](https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event) event will map to the `onClick` event name." ] }, { "cell_type": "code", "execution_count": null, - "id": "5d45c672", + "id": "269558f5", "metadata": {}, "outputs": [], "source": [ - "m.value # <- like this you can access the value from python" + "def on_click(event_data):\n", + " w.children = [\"Clicked ⭐\"]\n", + " \n", + "w = ipyreact.Widget(_type=\"button\",\n", + " children=[\"Click me\"],\n", + " props={\n", + " \"title\": \"Behaves like a tooltip\",\n", + " \"style\": {\"border\": \"5px solid orange\"},\n", + " \"class\": \"mybutton\"\n", + " },\n", + " events={\"onClick\": on_click})\n", + "w" + ] + }, + { + "cell_type": "markdown", + "id": "232e5e9c", + "metadata": {}, + "source": [ + "### Custom events\n", + "\n", + "If you are creating your own component, you are free to name events anything you'd like. Note that event handlers can optionally take arguments." ] }, { "cell_type": "code", "execution_count": null, - "id": "1827bb2b", + "id": "43016676", "metadata": {}, "outputs": [], "source": [ - "m.value = 20 # <- setting the value also works here." + "def on_my_click(new_label):\n", + " w.children = [new_label]\n", + " \n", + " \n", + "w = ipyreact.Widget(children=[\"Click me\"],\n", + " events={\"onMyClick\": on_my_click},\n", + " _esm=\"\"\"\n", + " import * as React from \"react\";\n", + "\n", + " export default function MyButton({ onMyClick, children }) {\n", + " return ;\n", + " };\n", + " \"\"\"\n", + ")\n", + "w" ] }, { - "attachments": {}, "cell_type": "markdown", - "id": "67827f3d", + "id": "f2736a01", "metadata": {}, "source": [ - "next, let's make a **simple traitlet oberservation using `change`.** \n", - "Note that `self.count` and `change['new']` is the same. " + "It is also possible to add methods with a subclass, prefixed with `event_` that will automatically be available in the props as well." ] }, { "cell_type": "code", "execution_count": null, - "id": "b23f3e06", + "id": "ae44863a", "metadata": {}, "outputs": [], "source": [ - "from traitlets import Any, observe\n", - "from traitlets import Int, Any\n", - "import ipyreact\n", - "\n", - "class Example(ipyreact.ReactWidget):\n", - " count = Int(0).tag(sync=True)\n", - "\n", - " @observe(\"count\")\n", - " def _observe_count(self, change):\n", - " print(f\"Old value: {change['old']}\")\n", - " print(f\"New value: {change['new']}\")\n", - " print(f\"--------------------------\")\n", - "\n", + "class MyButton(ipyreact.Widget):\n", " _esm = \"\"\"\n", " import * as React from \"react\";\n", "\n", - " export default function({set_count, count, prime_message}) {\n", - " return
\n", - "
\n", - " };\"\"\"\n", + " export default function MyButton({ onMyClick, children }) {\n", + " return ;\n", + " };\n", + " \"\"\"\n", "\n", + " # the method name should match the name in the props\n", + " def event_onMyClick(self, new_label):\n", + " w.children = [new_label]\n", "\n", - "Example()" + "w = MyButton(children=[\"Click me\"])\n", + "w" ] }, { - "attachments": {}, "cell_type": "markdown", - "id": "41e3e447", + "id": "f6b30c43", "metadata": {}, "source": [ - "next, let's **observe a traitlet and call python function** everytime the value of that traitlet changes. \n", - "First we need a python function, e.g. in our example it will tell us if we have a prime number or not:" + "## Traitlet events / observe\n", + "\n", + "Since traits can be [observed for changes](https://traitlets.readthedocs.io/en/stable/using_traitlets.html#observe) we can also add an event handler to state changes (instead of the event handler solution is the previous example).\n", + "\n", + "Both solutions can be valid. Sometimes events go together with a state change, and observing a state change then makes sense. In cases where a pure event is emitted, that does not directly lead to a state change, this solution might not be the right one.\n", + "\n", + "The example below does combine an event with a state change, and we therefore use the `@observe` decorator to handle further state changes." ] }, { "cell_type": "code", "execution_count": null, - "id": "e1b5af36", + "id": "24f42e00", "metadata": {}, "outputs": [], "source": [ @@ -566,221 +616,214 @@ "def is_prime_number(n):\n", " for i in range(2, n):\n", " if n % i == 0:\n", - " return \"No 💻🧊🧊🧊\"\n", - " return \"Yes 💻✅✅✅\"\n", + " return False\n", + " return True\n", "\n", "\n", - "class PrimePythonWidget(ipyreact.ReactWidget):\n", - " prime_message = Any(\"Click the Button\").tag(sync=True)\n", - " count = Int(0).tag(sync=True)\n", + "class PrimePythonWidget(ipyreact.Widget):\n", + " message = Any(\"Click to test the next number\").tag(sync=True)\n", + " number = Int(0).tag(sync=True)\n", "\n", - " @observe(\"count\")\n", + " @observe(\"number\")\n", " def _observe_count(self, change):\n", - " self.prime_message = is_prime_number(self.count)\n", + " if is_prime_number(self.number):\n", + " self.message = \"Yes ✅ it is a prime number\"\n", + " else:\n", + " self.message = \"No ❌, not a primer number\"\n", + " # alternatively: \n", + " # self.props = {**self.props, message: ....}\n", "\n", " _esm = \"\"\"\n", " import * as React from \"react\";\n", "\n", - " export default function({set_count, count, prime_message}) {\n", - " return
\n", - "
\n", - " {prime_message} \n", + " // NOTE: we add setMessage, even though we do not use it, to avoid forwarding\n", + " // it to button\n", + " export default function({setNumber, number, message, setMessage, ...rest}) {\n", + " return
\n", + " \n", + "
\n", + " {message}\n", "
\n", " };\"\"\"\n", "\n", "\n", - "primepy = PrimePythonWidget()\n", + "primepy = PrimePythonWidget(props={\"class\": \"mybutton\"})\n", "primepy" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "f0786387", - "metadata": {}, - "outputs": [], - "source": [ - "primepy.count = 3" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ebe77d4f", + "cell_type": "markdown", + "id": "448a0e28", "metadata": {}, - "outputs": [], "source": [ - "primepy.count = 4" + "Note that in this case, we have chosen to add `message` as a trait, instead of sending the `message` via the `props` trait. Since we also combine this with forwarding the rest of the props to the button, we *have* to take out the `setMessage` callback. If we do not, React will complain that the button element does not support the `setMessage` attribute." ] }, { - "attachments": {}, "cell_type": "markdown", - "id": "7eeee228", + "id": "7476173b", "metadata": {}, "source": [ - "Cool! But is this also possible to **observe a traitlet and call Javascript function** everytime the value of that traitlet changes? \n", - "Indeed, it is. (TODO: This example is not yet ready!)\n", - "Like this:" + "## Components in files\n", + "Having the JavaScript components in python string variables is good for the beginning. \n", + "That way the project is compact and there is no need of file switching. \n", + "\n", + "As examples are getting longer, the JavaScript components can be written in separate files. \n", + "That way, you will also get JavaScript syntax hilighting. " ] }, { "cell_type": "code", "execution_count": null, - "id": "ef5aaf1d", + "id": "2e1ce310", "metadata": {}, "outputs": [], "source": [ - "class PrimeJavaScriptWidget(ipyreact.ReactWidget):\n", - " prime_message = Any(\"Click the Button\").tag(sync=True) # <- TODO: this message does not show up because prime_message is overwritten\n", - " count = Int(0).tag(sync=True)\n", - "\n", - " _esm = \"\"\"\n", - " import * as React from \"react\";\n", - "\n", - " function isPrimeNumber(n) {\n", - " for (let i = 2; i < n; i++) {\n", - " if (n % i === 0) {\n", - " return \"No 🌐🧊🧊🧊\";\n", - " }\n", - " }\n", - " return \"Yes 🌐✅✅✅\";\n", - " }\n", + "import ipyreact\n", + "import pathlib\n", "\n", - " export const MyUpdater = ({ count, prime_message}) => {\n", - " prime_message = isPrimeNumber(count);\n", - " return {prime_message} ;\n", - " };\n", + "class WidgetFromFile(ipyreact.Widget):\n", + " _esm = pathlib.Path(\"my_component.tsx\").read_text()\n", "\n", - " export default function ({ set_count, count, prime_message}) {\n", - " return (\n", - "
\n", - " \n", - "
\n", - " \n", - "
\n", - " );\n", - " }\n", - " \"\"\"\n", - "primejs = PrimeJavaScriptWidget()\n", - "primejs" + "WidgetFromFile()" ] }, { - "attachments": {}, "cell_type": "markdown", - "id": "a038f153", + "id": "23f1401c", "metadata": {}, "source": [ - "It is also be possible to **call python functions from JavaScript** like this. \n", - "Here we need the on_ prefix for the function name.s" + "If you don't want to re-run the python code after making changes to the file that contains the component, you can see changes happening immediately thanks to **hot-reloading**.\n", + "This requires `pip install watchdog`. \n", + "Next, you replace the line \n", + "`_esm = pathlib.Path(\"my_component.tsx\").read_text()` \n", + "with \n", + "`_esm = pathlib.Path(\"my_component.tsx\")` \n", + "\n", + "Now open `my_component.tsx`, change \"Hello World\" to \"Hi there\", and you will see that the changes are reflected immediately." ] }, { "cell_type": "code", "execution_count": null, - "id": "abfa9586", + "id": "d7bd0c3c", "metadata": {}, "outputs": [], "source": [ "import ipyreact\n", - "from traitlets import Int, Unicode\n", - "\n", - "class Widget1(ipyreact.ReactWidget):\n", - " my_count = Int(0).tag(sync=True)\n", - " label = Unicode(\"Click me\").tag(sync=True)\n", + "import pathlib\n", "\n", - " def on_my_python_function(self):\n", - " self.my_count = self.my_count + 1\n", - " self.label = f\"Clicked {self.my_count}\"\n", + "class WidgetFromFile(ipyreact.Widget):\n", + " _esm = pathlib.Path(\"my_component.tsx\") # <- this will not work in JupyterLite\n", "\n", - " _esm = \"\"\"\n", - " import * as React from \"react\";\n", - "\n", - " export default function({on_my_python_function, label}) {\n", - " return(\n", - "
\n", - " \n", - "
\n", - " )\n", - " };\n", - " \"\"\"\n", - " \n", - "w1 = Widget1()\n", - "w1" + "WidgetFromFile()" ] }, { - "attachments": {}, "cell_type": "markdown", - "id": "7476173b", + "id": "b34a44f8", "metadata": {}, "source": [ - "Having the JavaScript components in python string variables is good for the beginning. \n", - "That way the project is compact and there is no need of file switching. \n", + "### Importing external modules\n", "\n", - "As examples are getting longer, the JavaScript components can be written in separate files. \n", - "That way, you will also get JavaScript syntax hilighting. " + "Writing JSX code without having to compile/bundle is great, but so is using external libraries.\n", + "\n", + "Ipyreact uses ES modules, which allows native importing of external libraries when written as an ES module.\n", + "In the example below, we use https://esm.sh/ which exposes many JS libraries as ES modules that\n", + "we can directly import." ] }, { "cell_type": "code", "execution_count": null, - "id": "2e1ce310", + "id": "381b5a21", "metadata": {}, "outputs": [], "source": [ "import ipyreact\n", - "import pathlib\n", "\n", - "class WidgetFromFile(ipyreact.ReactWidget):\n", - " _esm = pathlib.Path(\"my_component.tsx\").read_text()\n", + "ipyreact.ValueWidget(\n", + " _esm=\"\"\"\n", + " import confetti from \"https://esm.sh/canvas-confetti@1.6.0\";\n", + " import * as React from \"react\";\n", "\n", - "WidgetFromFile()" + " export default function({value, setValue}) {\n", + " return \n", + " };\n", + " \"\"\"\n", + ")" ] }, { - "attachments": {}, "cell_type": "markdown", - "id": "23f1401c", + "id": "366d143d", "metadata": {}, "source": [ - "If you don't want to re-run the python code after making changes to the file that contains the component, you can see changes happening immediately thanks to **hot-reloading**.\n", - "This requires `pip install watchdog`. \n", - "Next, you replace the line \n", - "`_esm = pathlib.Path(\"my_component.tsx\").read_text()` \n", - "with \n", - "`_esm = pathlib.Path(\"my_component.tsx\")` \n", + "However, the above code now has a direct link to \"https://esm.sh/canvas-confetti@1.6.0\" which makes the code very specific to esm.sh.\n", "\n", - "Now open `my_component.tsx`, change \"Hello World\" to \"Hi there\", and you will see that the changes are reflected immediately." + "To address this, we also support [import maps](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) to \n", + "write code more independant of where the modules come from.\n", + "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.\n", + "\n", + "Apart from `react`, the default we provide is:\n", + "\n", + "```python\n", + "_import_map = {\n", + " \"imports\": {\n", + " \"@mui/material/\": \"https://esm.sh/@mui/material@5.11.10/\",\n", + " \"@mui/icons-material/\": \"https://esm.sh/@mui/icons-material/\",\n", + " \"canvas-confetti\": \"https://esm.sh/canvas-confetti@1.6.0\",\n", + " },\n", + " \"scopes\": {\n", + " },\n", + "}\n", + "```\n", + "\n", + "Which means we can now write our ConfettiButton as:\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "d7bd0c3c", + "id": "9081c0cd", "metadata": {}, "outputs": [], "source": [ "import ipyreact\n", - "import pathlib\n", "\n", - "class WidgetFromFile(ipyreact.ReactWidget):\n", - " _esm = pathlib.Path(\"my_component.tsx\") # <- this will not work in JupyterLite\n", + "ipyreact.ValueWidget(\n", + " _esm=\"\"\"\n", + " import confetti from \"confetti\";\n", + " import * as React from \"react\";\n", "\n", - "WidgetFromFile()" + " export default function({value, setValue}) {\n", + " return \n", + " };\n", + " \"\"\",\n", + " # note that this import_map is already part of the default\n", + " _import_map={\n", + " \"imports\": {\n", + " \"confetti\": \"https://esm.sh/canvas-confetti@1.6.0\",\n", + " },\n", + " \n", + " }\n", + ")" ] }, { - "attachments": {}, "cell_type": "markdown", "id": "82c9fbe3-6112-4dae-b817-e1a12343b407", "metadata": {}, "source": [ + "## Optional\n", + "### Autocomplete\n", + "\n", "one more thing: \n", "Having **autocompletion in IDEs** is awesome! \n", "traitlets don't have that by default, but adding a `signature_has_traits` decorator will do the job!" @@ -799,7 +842,7 @@ "from traitlets import Any, Unicode, Int, observe, signature_has_traits\n", "\n", "@signature_has_traits\n", - "class MyExampleWidget(ipyreact.ReactWidget):\n", + "class MyExampleWidget(ipyreact.Widget):\n", " my_width = Int(23).tag(sync=True)\n", " _esm = \"\"\"\n", " import * as React from \"react\";\n", @@ -822,7 +865,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "38cfce36", "metadata": {}, @@ -832,7 +874,6 @@ ] }, { - "attachments": {}, "cell_type": "markdown", "id": "bae77944-e0dd-49c4-bd27-b401d7bbef1b", "metadata": {}, @@ -856,7 +897,7 @@ "# 🪄🪄🪄 this is an advanced example, feel free to skip 🪄🪄🪄\n", "\n", "@signature_has_traits\n", - "class MyExampleWidget(ipyreact.ReactWidget):\n", + "class MyExampleWidget(ipyreact.Widget):\n", " def __init__(self, **kwargs):\n", " super().__init__(**kwargs)\n", " self.print_welcome_message()\n", @@ -904,7 +945,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.15" + "version": "3.9.16" } }, "nbformat": 4, diff --git a/ipyreact/__init__.py b/ipyreact/__init__.py index e18be06..3381516 100644 --- a/ipyreact/__init__.py +++ b/ipyreact/__init__.py @@ -5,7 +5,7 @@ # Distributed under the terms of the Modified BSD License. from ._version import __version__ -from .widget import ReactWidget +from .widget import ReactWidget, ValueWidget, Widget def _jupyter_labextension_paths(): diff --git a/ipyreact/widget.py b/ipyreact/widget.py index 1359f6c..c2691a6 100644 --- a/ipyreact/widget.py +++ b/ipyreact/widget.py @@ -8,17 +8,21 @@ TODO: Add module docstring """ +import typing as t +import warnings from pathlib import Path import anywidget -from traitlets import Any, Bool, Dict, Int, List, Unicode +from ipywidgets import ValueWidget as ValueWidgetClassic +from ipywidgets import Widget, widget_serialization +from traitlets import Any, Bool, Dict, Int, List, Unicode, observe from ._frontend import module_name, module_version HERE = Path(__file__).parent -class ReactWidget(anywidget.AnyWidget): +class Widget(anywidget.AnyWidget): """TODO: Add docstring here""" _model_name = Unicode("ReactModel").tag(sync=True) @@ -27,11 +31,17 @@ class ReactWidget(anywidget.AnyWidget): _view_name = Unicode("ReactView").tag(sync=True) _view_module = Unicode(module_name).tag(sync=True) _view_module_version = Unicode(module_version).tag(sync=True) - value = Any(None, allow_none=True).tag(sync=True) - debug = Bool(False).tag(sync=True) - name = Unicode(None, allow_none=True).tag(sync=True) - react_version = Int(18).tag(sync=True) + props = Dict({}, allow_none=True).tag(sync=True) + children = List(t.cast(t.List[t.Union[Widget, str]], [])).tag(sync=True, **widget_serialization) + + # this stays on the python side + events = Dict({}) + # this is send of the frontend (keys of events) _event_names = List(Unicode(), allow_none=True).tag(sync=True) + _debug = Bool(False).tag(sync=True) + _type = Unicode(None, allow_none=True).tag(sync=True) + _module = Unicode(None, allow_none=True).tag(sync=True) + _react_version = Int(18).tag(sync=True) _cdn = Unicode("https://esm.sh/").tag _import_map = Dict({}).tag(sync=True) _import_map_default = { @@ -42,9 +52,21 @@ class ReactWidget(anywidget.AnyWidget): }, "scopes": {}, } - _esm = HERE / Path("basic.tsx") + _esm = "" + # _esm = HERE / Path("basic.tsx") def __init__(self, **kwargs) -> None: + _esm = kwargs.pop("_esm", None) + if _esm is not None: + extra_traits = {} + if isinstance(_esm, str): + extra_traits["_esm"] = Unicode(str(_esm)).tag(sync=True) + elif isinstance(_esm, Path): + from anywidget._util import try_file_contents + + self._esm = try_file_contents(_esm) + + self.add_traits(**extra_traits) _import_map = kwargs.pop("_import_map", {}) _import_map = { "imports": {**self._import_map_default["imports"], **_import_map.get("imports", {})}, @@ -52,23 +74,48 @@ def __init__(self, **kwargs) -> None: } kwargs["_import_map"] = _import_map _ignore = ["on_msg", "on_displayed", "on_trait_change", "on_widget_constructed"] - _event_names = [ - method_name[3:] - for method_name in dir(self) - if method_name.startswith("on_") and method_name not in _ignore - ] - super().__init__(**{"_event_names": _event_names, **kwargs}) + events = kwargs.pop("events", {}) + for method_name in dir(self): + if method_name.startswith("event_") and method_name not in _ignore: + event_name = method_name[len("event_") :] + method = getattr(self, method_name) + if method_name not in events: + events[event_name] = method + _event_names = list(events) + super().__init__(**{"_event_names": _event_names, "events": events, **kwargs}) self.on_msg(self._handle_event) def _handle_event(self, _, content, buffers): if "event_name" in content.keys(): event_name = content.get("event_name", "") data = content.get("data", {}) - method = getattr(self, "on_" + event_name) + event_hander = self.events.get(event_name, None) + if event_hander is None: + return if "data" not in content: - method() + event_hander() else: if buffers: - method(data, buffers) + event_hander(data, buffers) else: - method(data) + event_hander(data) + + @observe("events") + def _events(self, change): + self.event_names = list(change["new"].keys()) + + +class ValueWidget(Widget, ValueWidgetClassic): + # the ValueWidget from ipywidgets does not add sync=True to the value trait + value = Any(help="The value of the widget.").tag(sync=True) + + +# this is deprecated +class ReactWidget(ValueWidget): + _esm = HERE / Path("basic.tsx") + + def __init__(self, **kwargs) -> None: + warnings.warn( + "ReactWidget is deprecated, use Widget or ValueWidget instead", DeprecationWarning + ) + super().__init__(**kwargs) diff --git a/src/utils.ts b/src/utils.ts index e88c013..6ef7c64 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -81,3 +81,35 @@ export async function loadScript(type: string, src: string) { }; }); } + +// based on https://stackoverflow.com/a/58416333/5397207 +function pickSerializable(object: any, depth = 0, max_depth = 2) { + // change max_depth to see more levels, for a touch event, 2 is good + if (depth > max_depth) return "Object"; + + const obj: any = {}; + for (let key in object) { + let value = object[key]; + if (value instanceof Window) value = "Window"; + else if (value && value.getModifierState) + value = pickSerializable(value, depth + 1, max_depth); + else { + // test if serializable + try { + JSON.stringify(value); + } catch (e) { + value = "Object"; + } + } + obj[key] = value; + } + + return obj; +} + +export function eventToObject(event: any) { + if (event instanceof Event || (event && event.getModifierState)) { + return pickSerializable(event); + } + return event; +} diff --git a/src/widget.tsx b/src/widget.tsx index 78c5805..6f57c4c 100644 --- a/src/widget.tsx +++ b/src/widget.tsx @@ -2,9 +2,12 @@ // Distributed under the terms of the Modified BSD License. import { + WidgetModel, DOMWidgetModel, DOMWidgetView, ISerializers, + unpack_models, + WidgetView, } from "@jupyter-widgets/base"; import * as React from "react"; @@ -13,12 +16,11 @@ import * as ReactJsxRuntime from "react/jsx-runtime"; import * as ReactReconcilerContants from "react-reconciler/constants"; import * as ReactReconciler from "react-reconciler"; import * as ReactDOM from "react-dom"; - // @ts-ignore import * as ReactDOMClient from "react-dom/client"; // @ts-ignore import "../css/widget.css"; -import { expose, loadScript, setUpMuiFixModule } from "./utils"; +import { eventToObject, expose, loadScript, setUpMuiFixModule } from "./utils"; import { MODULE_NAME, MODULE_VERSION } from "./version"; // import * as Babel from '@babel/standalone'; // TODO: find a way to ship es-module-shims with the widget @@ -27,6 +29,23 @@ import { MODULE_NAME, MODULE_VERSION } from "./version"; import { transform } from "sucrase"; import { ErrorBoundary } from "./components"; import { Root } from "react-dom/client"; +import { ModelDestroyOptions } from "backbone"; + +declare function importShim( + specifier: string, + parentUrl?: string, +): Promise<{ default: Default } & Exports>; + +declare namespace importShim { + const resolve: (id: string, parentURL?: string) => string; + const addImportMap: (importMap: Partial) => void; + const getImportMap: () => any; +} + +// interface Window { +// esmsInitOptions?: any; +// importShim: typeof importShim; +// } // @ts-ignore // const react16Code = require('!!raw-loader!./react16.js'); @@ -66,13 +85,13 @@ function autoExternalReactResolve( if (!shipsWith && !alreadyPatched && !isBlob) { id = id + "?external=react,react-dom"; } - // console.log("resolve", id, parentUrl, resolve) return resolve(id, parentUrl); } // @ts-ignore window.esmsInitOptions = { shimMode: true, + mapOverrides: true, resolve: ( id: string, parentUrl: string, @@ -104,6 +123,24 @@ function ensureReactSetup(version: number) { } } +const widgetToReactComponent = async (widget: WidgetModel) => { + const WidgetRenderHOC = (widget: WidgetModel) => { + return ({ view }: { view: WidgetView | null }) => { + return
widget placeholder
; + }; + }; + if (widget instanceof ReactModel) { + return await widget.component; + // const el = ; + // return el; + } else if (typeof widget === "string") { + return () => widget; + } else { + const ChildComponent = WidgetRenderHOC(widget); + return ChildComponent; + } +}; + export class ReactModel extends DOMWidgetModel { defaults() { return { @@ -114,7 +151,6 @@ export class ReactModel extends DOMWidgetModel { _view_name: ReactModel.view_name, _view_module: ReactModel.view_module, _view_module_version: ReactModel.view_module_version, - value: null, }; // TODO: ideally, we only compile code in the widget model, but the react hooks are // super convenient. @@ -122,21 +158,74 @@ export class ReactModel extends DOMWidgetModel { static serializers: ISerializers = { ...DOMWidgetModel.serializers, + children: { deserialize: unpack_models as any }, }; - static model_name = "ReactModel"; - static model_module = MODULE_NAME; - static model_module_version = MODULE_VERSION; - static view_name = "ReactView"; // Set to null if no view - static view_module = MODULE_NAME; // Set to null if no view - static view_module_version = MODULE_VERSION; -} - -export class ReactView extends DOMWidgetView { - private root: Root | null = null; - - render() { - this.el.classList.add("jupyter-react-widget"); + initialize(attributes: any, options: any): void { + super.initialize(attributes, options); + this.component = new Promise((resolve, reject) => { + this.resolveComponent = resolve; + this.rejectComponent = reject; + }); + this.queue = Promise.resolve(); + this.on("change:_import_map", async () => { + this.enqueue(async () => { + // chain these updates, so they are executed in order + await this.updateComponentToWrap(); + }); + }); + this.on("change:_esm", async () => { + this.enqueue(async () => { + this.compileCode(); + await this.updateComponentToWrap(); + }); + }); + this.on("change:_module change:_type", async () => { + this.enqueue(async () => { + await this.updateImportMap(); + await this.updateComponentToWrap(); + }); + }); + this._initialSetup(); + } + enqueue(fn: () => Promise) { + // this makes sure that callbacks and _initialSetup are executed in order + // and not in parallel, which can lead to race conditions + this.queue = this.queue.then(async () => { + await fn(); + }); + return this.queue; + } + async _initialSetup() { + await this.enqueue(async () => { + await this.updateImportMap(); + this.compileCode(); + try { + let component: any = await this.createWrapperComponent(); + this.resolveComponent(component); + } catch (e) { + console.error(e); + this.rejectComponent(e); + } + }); + // await this.createComponen(); + } + async updateImportMap() { + await ensureImportShimLoaded(); + const reactImportMap = ensureReactSetup(this.get("_react_version")); + const importMapWidget = this.get("_import_map"); + const importMap = { + imports: { + ...reactImportMap, + ...importMapWidget["imports"], + }, + scopes: { + ...importMapWidget["scopes"], + }, + }; + importShim.addImportMap(importMap); + } + compileCode() { // using babel is a bit of an art, so leaving this code for if we // want to switch back to babel. However, babel is very large compared // to sucrase @@ -147,47 +236,213 @@ export class ReactView extends DOMWidgetView { // ] // }); // Babel.registerPlugin("importmap", pluginImport()); + const code = this.get("_esm"); + this.compileError = null; + if (!code) { + this.compiledCode = null; + return; + } + if (this.get("_debug")) { + console.log("original code:\n", code); + } + try { + // using babel: + // return Babel.transform(code, { presets: ["react", "es2017"], plugins: ["importmap"] }).code; + // using sucrase: + this.compiledCode = transform(code, { + transforms: ["jsx", "typescript"], + filePath: "test.tsx", + }).code; + if (this.get("_debug")) { + console.log("compiledCode:\n", this.compiledCode); + } + } catch (e) { + console.error(e); + this.compileError = e; + } + } + async updateComponentToWrap() { + try { + let component: any = await this.createComponentToWrap(); + this.currentComponentToWrapOrError = component; + this.trigger("component", component); + } catch (e) { + console.error(e); + this.trigger("component", e); + } + } + async createComponentToWrap() { + let moduleName = this.get("_module"); + let type = this.get("_type"); + if (this.compileError) { + return () =>
{this.compileError.message}
; + } else { + let module: any = null; + // html element like div or button + if (!moduleName && !this.compiledCode && type) { + return type; + } - // @ts-ignore - // const React : any = { - // 16: React16, - // 18: React18, - // }[this.model.get("react_version")]; + if (!this.compiledCode && !moduleName && !type) { + return () => ( +
no component provided, pass _esm, or _module and _type
+ ); + } else if (this.compiledCode) { + if (this.codeUrl) { + URL.revokeObjectURL(this.codeUrl); + } + this.codeUrl = URL.createObjectURL( + new Blob([this.compiledCode], { type: "text/javascript" }), + ); + module = await importShim(this.codeUrl); + if (!module) { + return () =>
error loading module
; + } + } else { + module = await importShim(moduleName); + if (!module) { + return () =>
no module found with name {moduleName}
; + } + } + let component = module[type || "default"]; + if (!component) { + if (type) { + return () => ( +
+ no component found in module {moduleName} (with name {type}) +
+ ); + } else { + return () => ( +
+ no component found in module {moduleName} (it should be exported + as default) +
+ ); + } + } else { + if (this.compiledCode) { + const needsMuiFix = this.compiledCode.indexOf("@mui") !== -1; + if (needsMuiFix) { + let muiFix = await setUpMuiFixModule(); + const componentToWrap = component; + // console.log("muiFix", muiFix); + // @ts-ignore + component = (props: any) => { + // console.log("component wrapper fix", props) + // return componentToWrap(props); + return muiFix.styleWrapper(componentToWrap(props)); + }; + } + } + return component; + } + } + } + async createWrapperComponent() { + // we wrap the component in a wrapper that puts in all the props from the + // widget model, and handles events, etc + const childrenToReactComponents = async (view: any) => { + let childrenWidgets: Array = this.get("children"); + return await Promise.all( + childrenWidgets.map( + async (child: any) => await widgetToReactComponent(child), + ), + ); + }; + + let initialChildrenComponents = await childrenToReactComponents(null); + // const resolveFormatters = async () => { + // let formatterDict = this.get("formatters") || {}; + // let formatterModules : any = {}; + // for (const key of Object.keys(formatterDict)) { + // // @ts-ignore + // let module = await importShim(formatterDict[key]); + // formatterModules[key] = module; + // } + // return formatterModules; + // } - const Component = () => { - // @ts-ignore - // @ts-ignore - const [_, setCounter] = useState(0); + // let formatterModules = await resolveFormatters(); + // console.log("formatterModules", formatterModules); + + try { + this.currentComponentToWrapOrError = await this.createComponentToWrap(); + } catch (e) { + this.currentComponentToWrapOrError = e; + } + + const isSpecialProp = (key: string) => { + const specialProps = [ + "children", + "props", + "tabbable", + "layout", + "tooltip", + ]; + if (specialProps.find((x) => x === key)) { + return true; + } + if (key.startsWith("_")) { + return true; + } + return false; + }; + + const WrapperComponent = ({ view, ...parentProps }: { view: any }) => { + const [component, setComponent] = useState( + () => this.currentComponentToWrapOrError, + ); + React.useEffect(() => { + this.listenTo(this, "component", (component) => { + console.log("set component", component); + setComponent(() => component); + }); + return () => { + this.stopListening(this, "component"); + }; + }, []); + const setForceRerenderCounter = useState(0)[1]; const forceRerender = () => { - setCounter((x) => x + 1); + console.log( + "force rerender", + name, + this.get("props"), + this.previous("props"), + ); + setForceRerenderCounter((x) => x + 1); + }; + const [childrenComponents, setChildrenComponents] = useState( + initialChildrenComponents, + ); + const updateChildren = () => { + console.log("update children"); + this.enqueue(async () => { + setChildrenComponents(await childrenToReactComponents(view)); + }); }; useEffect(() => { - this.listenTo(this.model, "change", forceRerender); - }, []); - - const compiledCode: string | Error = React.useMemo(() => { - const code = this.model.get("_esm"); - if (this.model.get("debug")) { - console.log("original code:\n", code); - } - try { - // using babel: - // return Babel.transform(code, { presets: ["react", "es2017"], plugins: ["importmap"] }).code; - // using sucrase: - let compiledCode = transform(code, { - transforms: ["jsx", "typescript"], - filePath: "test.tsx", - }).code; - if (this.model.get("debug")) { - console.log("compiledCode:\n", compiledCode); + this.listenTo(this, "change:props", forceRerender); + this.listenTo(this, "change:children", updateChildren); + for (const key of Object.keys(this.attributes)) { + if (isSpecialProp(key)) { + continue; } - return compiledCode; - } catch (e) { - return e; + this.listenTo(this, `change:${key}`, updateChildren); } - }, [this.model.get("_esm")]); - const props: any = {}; - for (const event_name of this.model.attributes["_event_names"]) { + return () => { + this.stopListening(this, "change:props", forceRerender); + this.stopListening(this, "change:children", updateChildren); + for (const key of Object.keys(this.attributes)) { + if (isSpecialProp(key)) { + continue; + } + this.stopListening(this, `change:${key}`, updateChildren); + } + }; + }, []); + const events: any = {}; + for (const event_name of this.attributes["_event_names"]) { const handler = (value: any, buffers: any) => { if (buffers) { const validBuffers = @@ -197,121 +452,106 @@ export class ReactView extends DOMWidgetView { buffers = undefined; } } - this.model.send( - { event_name, data: value }, - this.model.callbacks(this), + const saveValue = eventToObject(value); + console.log("sending", event_name, saveValue, view); + this.send( + { event_name, data: saveValue }, + this.callbacks(view), buffers, ); }; - props["on_" + event_name] = handler; + events[event_name] = handler; } - for (const key of Object.keys(this.model.attributes)) { - props[key] = this.model.get(key); - props["on_" + key] = (value: any) => { - console.warn(`on_${key} is deprecated, use set_${key} instead`); - this.model.set(key, value); - this.touch(); - }; - props["set_" + key] = (value: any) => { - this.model.set(key, value); - this.touch(); + // React.createElement('div', {"aria-activedescendant": "foo"}}) + //
+ const modelProps = { ...this.get("props") }; + // for (const key of Object.keys(modelProps)) { + // if(formatterModules[key]) { + // modelProps[key] = formatterModules[key].py2js(modelProps[key]); + // } + // } + // console.log("children", children); + let children = childrenComponents.map((ChildComponent: any) => { + return ; + }); + const childrenProps = children.length > 0 ? { children: children } : {}; + // useEffect(() => { + // // force render every 2 seconds + // const interval = setInterval(() => { + // forceRerender(); + // }, 2000); + // return () => { + // clearInterval(interval); + // } + // }, []); + //const [r//] + const backboneProps: any = {}; + for (const key of Object.keys(this.attributes)) { + if (isSpecialProp(key)) { + continue; + } + backboneProps[key] = this.get(key); + backboneProps["set" + key.charAt(0).toUpperCase() + key.slice(1)] = ( + value: any, + ) => { + this.set(key, value); + // this.touch(); + this.save_changes(this.callbacks(view)); }; } - const [scope, setScope] = React.useState(null as any | Error); - const [muiFix, setMuiFix] = React.useState(null as any | Error); - React.useEffect(() => { - let url: string | null = null; - (async () => { - if (compiledCode instanceof Error) { - setScope(compiledCode); - return; - } - const reactImportMap = ensureReactSetup( - this.model.get("react_version"), - ); - await ensureImportShimLoaded(); - let finalCode = compiledCode; - // @ts-ignore - const importMapWidget = this.model.get("_import_map"); - const importMap = { - imports: { - ...reactImportMap, - ...importMapWidget["imports"], - }, - scopes: { - ...importMapWidget["scopes"], - }, - }; - // @ts-ignore - importShim.addImportMap(importMap); - const needsMuiFix = compiledCode.indexOf("@mui") !== -1; - if (needsMuiFix) { - setMuiFix(await setUpMuiFixModule()); - } - url = URL.createObjectURL( - new Blob([finalCode], { type: "text/javascript" }), - ); - try { - // @ts-ignore - let module = await importShim(url); - let name = this.model.get("name"); - if (name && name.length > 0) { - // @ts-ignore - importShim.addImportMap({ imports: { [name]: url } }); - } - setScope(module); - } catch (e) { - setScope(e); - } - })(); - return () => { - if (url) { - URL.revokeObjectURL(url); - } - }; - }, [compiledCode]); - - if (!scope) { - return
Loading...
; - } else { - if (scope instanceof Error) { - return
{scope.message}
; - } else { - if (scope.default === undefined) { - return
Missing default component
; - } else { - if (this.model.get("debug")) { - console.log("props", props); - } - // @ts-ignore - let el = React.createElement(scope.default, props); - // check if @mui string is in compiledCode - // if so, we need to wrap the element in a style wrapper - // @ts-ignore - const needsMuiFix = compiledCode.indexOf("@mui") !== -1; - if (this.model.get("debug")) { - console.log("needsMuiFix", needsMuiFix); - } - if (needsMuiFix) { - el = muiFix.styleWrapper(el); - } - return el; - } - } + const props = { + ...modelProps, + ...backboneProps, + ...parentProps, + ...events, + ...childrenProps, + }; + console.log("props", props, children, component); + if (component instanceof Error) { + throw component; } + return React.createElement(component, props); }; - if (this.model.get("react_version") === 18) { - this.root = ReactDOMClient.createRoot(this.el); - this.root.render( - - - , - ); - } else { - // @ts-ignore - // ReactDOM16.render(, this.el); + return WrapperComponent; + } + destroy(options?: ModelDestroyOptions | undefined): false | JQueryXHR { + if (this.codeUrl) { + URL.revokeObjectURL(this.codeUrl); } + return super.destroy(options); + } + public component: Promise; + private resolveComponent: (value: any) => void; + private rejectComponent: (value: any) => void; + private compiledCode: string | null = null; + private compileError: any | null = null; + private codeUrl: string | null = null; + // this used so that the WrapperComponent can be rendered synchronously, + private currentComponentToWrapOrError: any = null; + private queue: Promise; + + static model_name = "ReactModel"; + static model_module = MODULE_NAME; + static model_module_version = MODULE_VERSION; + static view_name = "ReactView"; // Set to null if no view + static view_module = MODULE_NAME; // Set to null if no view + static view_module_version = MODULE_VERSION; +} + +export class ReactView extends DOMWidgetView { + private root: Root | null = null; + + async render() { + this.el.classList.add("jupyter-react-widget"); + // using babel is a bit of an art, so leaving this code for if we + this.root = ReactDOMClient.createRoot(this.el); + const Component: any = await (this.model as ReactModel).component; + this.root.render( + + + , + ); } remove() { diff --git a/tests/ui/children_test.py b/tests/ui/children_test.py new file mode 100644 index 0000000..4788d40 --- /dev/null +++ b/tests/ui/children_test.py @@ -0,0 +1,66 @@ +import ipywidgets +import playwright.sync_api +import pytest +from solara import display + +import ipyreact + + +def test_children_text(solara_test, page_session: playwright.sync_api.Page): + def on_click(event_data): + b.children = ["Clicked"] + + b = ipyreact.Widget( + _type="button", + events={"onClick": on_click}, + children=["Click me"], + props={"class": "test-button"}, + ) + + display(b) + button = page_session.locator(".test-button") + button.click() + page_session.locator(".test-button >> text=Clicked").wait_for() + + +def test_children_react(solara_test, page_session: playwright.sync_api.Page): + def on_click(event_data): + b.children = [ + ipyreact.Widget( + _type="span", props={"className": "test-span"}, children=["direct child"] + ) + ] + + b = ipyreact.Widget( + _type="button", + events={"onClick": on_click}, + children=["Click me"], + props={"className": "test-button"}, + ) + + display(b) + button = page_session.locator(".test-button") + button.click() + # direct child, not a grandchild (e.g. extra div around it) + page_session.locator(".test-button > .test-span").wait_for() + + +@pytest.mark.xfail +def test_children_ipywidgets(solara_test, page_session: playwright.sync_api.Page): + def on_click(event_data): + html = ipywidgets.HTML(value="not a direct child") + html.add_class("test-html") + b.children = [html] + + b = ipyreact.Widget( + _type="button", + events={"onClick": on_click}, + children=["Click me"], + props={"className": "test-button"}, + ) + + display(b) + button = page_session.locator(".test-button") + button.click() + # not per se a direct child + page_session.locator(".test-button >> .test-html").wait_for() diff --git a/tests/ui/event_test.py b/tests/ui/event_test.py index 25d1b6d..49d9148 100644 --- a/tests/ui/event_test.py +++ b/tests/ui/event_test.py @@ -16,7 +16,7 @@ class ButtonWithHandler(ipyreact.ReactWidget): }; """ - def on_click(self): + def event_on_click(self): self.label = "Clicked" diff --git a/tests/ui/jupyter_test.py b/tests/ui/jupyter_test.py index 2cd88d4..9803868 100644 --- a/tests/ui/jupyter_test.py +++ b/tests/ui/jupyter_test.py @@ -12,8 +12,8 @@ class Counter(ipyreact.ReactWidget): _esm = """ import * as React from "react"; - export default function({value, on_value, debug}) { - return };""" diff --git a/tests/ui/library_test.py b/tests/ui/library_test.py index c4319ff..4b22611 100644 --- a/tests/ui/library_test.py +++ b/tests/ui/library_test.py @@ -7,8 +7,8 @@ import Button from '@mui/material/Button'; import * as React from "react"; -export default function({value, set_value, debug}) { - return };