{ "cells": [ { "cell_type": "code", "execution_count": null, "id": "b94295e3-3bbc-478f-a6eb-a5550e524573", "metadata": {}, "outputs": [], "source": [ "import panel as pn\n", "\n", "pn.extension()" ] }, { "cell_type": "markdown", "id": "34c90e0e-c932-4346-8452-72f9c3aeecbb", "metadata": {}, "source": [ "`JSComponent` simplifies the creation of custom Panel components using JavaScript." ] }, { "cell_type": "code", "execution_count": null, "id": "f7e59ce8-e218-41b9-bb9c-4c5685ed5b44", "metadata": {}, "outputs": [], "source": [ "import panel as pn\n", "import param\n", "\n", "from panel.custom import JSComponent\n", "\n", "\n", "class CounterButton(JSComponent):\n", "\n", " value = param.Integer()\n", "\n", " _esm = \"\"\"\n", " export function render({ model }) {\n", " let btn = document.createElement(\"button\");\n", " btn.innerHTML = `count is ${model.value}`;\n", " btn.addEventListener(\"click\", () => {\n", " model.value += 1\n", " });\n", " model.on('value', () => {\n", " btn.innerHTML = `count is ${model.value}`;\n", " })\n", " return btn\n", " }\n", " \"\"\"\n", "\n", "CounterButton().servable()" ] }, { "cell_type": "markdown", "id": "188e2bf7-1bf0-4a61-a867-7770dd54eb46", "metadata": {}, "source": [ ":::{note}\n", "`JSComponent` was introduced in June 2024 as a successor to `ReactiveHTML`.\n", "\n", "`JSComponent` bears similarities to [`AnyWidget`](https://anywidget.dev/), but it is specifically optimized for use with Panel.\n", "\n", "If you are looking to create custom components using Python and Panel component only, check out [`Viewer`](Viewer.md).\n", ":::\n", "\n", "## API\n", "\n", "### JSComponent Attributes\n", "\n", "- **`_esm`** (str | PurePath): This attribute accepts either a string or a path that points to an [ECMAScript module](https://nodejs.org/api/esm.html#modules-ecmascript-modules). The ECMAScript module should export a `render` function which returns the HTML element to display. In a development environment such as a notebook or when using `--dev` flag, the module will automatically reload upon saving changes.\n", "- **`_importmap`** (dict | None): This optional dictionary defines an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap), allowing you to customize how module specifiers are resolved.\n", "- **`_stylesheets`** (optional list of strings): This optional attribute accepts a list of CSS strings or paths to CSS files. It supports automatic reloading in development environments.\n", "\n", ":::note\n", "\n", "You may specify a path to a file as a string instead of a PurePath. The path should be specified relative to the file its specified in.\n", "\n", ":::\n", "\n", "### `render` Function\n", "\n", "The `_esm` attribute must export the `render` function. It accepts the following parameters:\n", "\n", "- **`model`**: Represents the Parameters of the component and provides methods to add (and remove) event listeners using `.on` and `.off`, render child elements using `.get_child`, and to `.send_event` back to Python.\n", "- **`view`**: The Bokeh view.\n", "- **`el`**: The HTML element that the component will be rendered into.\n", "\n", "Any HTML element returned from the `render` function will be appended to the HTML element (`el`) of the component but you may also manually append to and manipulate the `el` directly.\n", "\n", "### Callbacks\n", "\n", "The `model.on` and `model.off` methods allow registering event handlers inside the render function. This includes the ability to listen to parameter changes and register lifecycle hooks.\n", "\n", "#### Change Events\n", "\n", "The following signatures are valid when listening to change events:\n", "\n", "- `.on('', callback)`: Allows registering an event handler for a single parameter.\n", "- `.on(['', ...], callback)`: Allows adding an event handler for multiple parameters at once.\n", "- `.on('change:', callback)`: The `change:` prefix allows disambiguating change events from lifecycle hooks should a parameter name and lifecycle hook overlap.\n", "- \n", "\n", "The `change:` prefix allows disambiguating change events from lifecycle hooks should a parameter name and lifecycle hook overlap.\n", "\n", "#### Bidirectional Events\n", "\n", "##### JS -> Python\n", "\n", "- `.send_event('', DOMEvent)`: Allows sending browser `DOMEvent` to Python and associating it with a name. An event handler can be registered by name with the `.on_event` method or by implementing a `_handle_` method on the class.\n", "- `.send_msg(data)`: Allows sending arbitrary data to Python. An event handler can be registered with the `.on_msg(callback)` method on the Python component or by implementing a `_handle_msg` method on the class.\n", "\n", "##### Python -> JS\n", "\n", "- `._send_event(ESMEvent, data=msg)`: Allows sending arbitrary data to the frontend, which can be observed by registering a handler with `.on('msg:custom', callback)`.\n", "\n", "\n", "#### Lifecycle Hooks\n", "\n", "- `.on('after_layout', callback)`: Called whenever the layout around the component is changed.\n", "- `.on('after_render', callback)`: Called once after the component has been fully rendered.\n", "- `.on('resize', callback)`: Called after the component has been resized.\n", "- `.on('remove', callback)`: Called when the component view is being removed from the DOM.\n", "\n", "The `lifecycle:` prefix allows disambiguating lifecycle hooks from change events should a parameter name and lifecycle hook overlap.\n", "\n", "## Usage\n", "\n", "### Styling with CSS\n", "\n", "Include CSS within the `_stylesheets` attribute to style the component. The CSS is injected directly into the component's HTML." ] }, { "cell_type": "code", "execution_count": null, "id": "0f0ca9f3-547e-4378-8cc8-f745b987b121", "metadata": {}, "outputs": [], "source": [ "import panel as pn\n", "import param\n", "\n", "from panel.custom import JSComponent\n", "\n", "\n", "class StyledCounterButton(JSComponent):\n", "\n", " value = param.Integer()\n", "\n", " _stylesheets = [\n", " \"\"\"\n", " button {\n", " background: #0072B5;\n", " color: white;\n", " border: none;\n", " padding: 10px;\n", " border-radius: 4px;\n", " }\n", " button:hover {\n", " background: #4099da;\n", " }\n", " \"\"\"\n", " ]\n", "\n", " _esm = \"\"\"\n", " export function render({ model }) {\n", " const btn = document.createElement(\"button\");\n", " btn.innerHTML = `count is ${model.value}`;\n", " btn.addEventListener(\"click\", () => {\n", " model.value += 1\n", " });\n", " model.on('value', () => {\n", " btn.innerHTML = `count is ${model.value}`;\n", " })\n", " return btn\n", " }\n", " \"\"\"\n", "\n", "StyledCounterButton().servable()" ] }, { "cell_type": "markdown", "id": "1d16400f-d238-48c6-aeca-73d65ed62027", "metadata": {}, "source": [ "## Send Events from JavaScript to Python\n", "\n", "Events from JavaScript can be sent to Python using the `model.send_event` method. Define a *handler* in Python to manage these events. A *handler* is a method on the form `_handle_(self, event)`:" ] }, { "cell_type": "code", "execution_count": null, "id": "a21ae67f-2077-4bf8-9184-b02bf84e4326", "metadata": {}, "outputs": [], "source": [ "import panel as pn\n", "import param\n", "\n", "from panel.custom import JSComponent\n", "\n", "\n", "class EventExample(JSComponent):\n", "\n", " value = param.Parameter()\n", "\n", " _esm = \"\"\"\n", " export function render({ model }) {\n", " const btn = document.createElement('button')\n", " btn.innerHTML = `Click Me`\n", " btn.onclick = (event) => model.send_event('click', event)\n", " return btn\n", " }\n", " \"\"\"\n", "\n", " def _handle_click(self, event):\n", " self.value = event.data\n", "\n", "button = EventExample()\n", "\n", "event_json = pn.pane.JSON(button.param.value)\n", "\n", "pn.Column(button, event_json)" ] }, { "cell_type": "markdown", "id": "4979e5b0-7caa-4ffd-a8bd-40680e048935", "metadata": {}, "source": [ "You can also define and send arbitrary data using the `.send_msg()` API and by implementing a `_handle_msg` method on the component:" ] }, { "cell_type": "code", "execution_count": null, "id": "f99cc078-3b4f-43d8-85aa-983263f1a800", "metadata": {}, "outputs": [], "source": [ "import datetime\n", "\n", "import panel as pn\n", "import param\n", "\n", "from panel.custom import JSComponent\n", "\n", "\n", "class CustomEventExample(JSComponent):\n", "\n", " value = param.String()\n", "\n", " _esm = \"\"\"\n", " export function render({ model }) {\n", " const btn = document.createElement('button')\n", " btn.innerHTML = `Click Me`;\n", " btn.onclick = (event) => {\n", " const currentDate = new Date();\n", " model.send_msg(currentDate.getTime())\n", " }\n", " return btn\n", " }\n", " \"\"\"\n", "\n", " def _handle_msg(self, msg):\n", " unix_timestamp = msg/1000\n", " python_datetime = datetime.datetime.fromtimestamp(unix_timestamp)\n", " self.value = str(python_datetime)\n", "\n", "button = CustomEventExample()\n", "\n", "pn.Column(button, button.param.value)" ] }, { "cell_type": "markdown", "id": "d4b72890-3512-42ad-9352-30c89b7e9c52", "metadata": {}, "source": [ "## Dependency Imports\n", "\n", "JavaScript dependencies can be directly imported via URLs, such as those from [`esm.sh`](https://esm.sh/)." ] }, { "cell_type": "code", "execution_count": null, "id": "0a617ff6-a168-4bc2-8348-304e3d85c70d", "metadata": {}, "outputs": [], "source": [ "import panel as pn\n", "\n", "from panel.custom import JSComponent\n", "\n", "\n", "class ConfettiButton(JSComponent):\n", "\n", " _esm = \"\"\"\n", " import confetti from \"https://esm.sh/canvas-confetti@1.6.0\";\n", "\n", " export function render() {\n", " let btn = document.createElement(\"button\");\n", " btn.innerHTML = \"Click Me\";\n", " btn.addEventListener(\"click\", () => {\n", " confetti()\n", " });\n", " return btn\n", " }\n", " \"\"\"\n", "\n", "ConfettiButton()" ] }, { "cell_type": "markdown", "id": "8dd08bad-a752-4102-ac7d-a09c4b96991d", "metadata": {}, "source": [ "Use the `_importmap` attribute for more concise module references." ] }, { "cell_type": "code", "execution_count": null, "id": "936b653c-637e-4ecd-bc57-324a007c8802", "metadata": {}, "outputs": [], "source": [ "import panel as pn\n", "\n", "from panel.custom import JSComponent\n", "\n", "\n", "class ConfettiButton(JSComponent):\n", " _importmap = {\n", " \"imports\": {\n", " \"canvas-confetti\": \"https://esm.sh/canvas-confetti@1.6.0\",\n", " }\n", " }\n", "\n", " _esm = \"\"\"\n", " import confetti from \"canvas-confetti\";\n", "\n", " export function render() {\n", " let btn = document.createElement(\"button\");\n", " btn.innerHTML = `Click Me`;\n", " btn.addEventListener(\"click\", () => {\n", " confetti()\n", " });\n", " return btn\n", " }\n", " \"\"\"\n", "\n", "ConfettiButton()" ] }, { "cell_type": "markdown", "id": "80a29850-659f-4254-ac94-0a2b78e9e541", "metadata": {}, "source": [ "See [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) for more info about the import map format." ] }, { "cell_type": "markdown", "id": "c890e2e9-9027-4a52-9d21-65fd135b8c38", "metadata": {}, "source": [ "## External Files\n", "\n", "You can load JavaScript and CSS from files by providing the paths to these files.\n", "\n", "Create the file **counter_button.py**.\n", "\n", "```python\n", "from pathlib import Path\n", "\n", "import param\n", "import panel as pn\n", "\n", "from panel.custom import JSComponent\n", "\n", "pn.extension()\n", "\n", "class CounterButton(JSComponent):\n", "\n", " value = param.Integer()\n", "\n", " _esm = Path(\"counter_button.js\")\n", " _stylesheets = [Path(\"counter_button.css\")]\n", "\n", "CounterButton().servable()\n", "```\n", "\n", "Now create the file **counter_button.js**.\n", "\n", "```javascript\n", "export function render({ model }) {\n", " let btn = document.createElement(\"button\");\n", " btn.innerHTML = `count is ${model.value}`;\n", " btn.addEventListener(\"click\", () => {\n", " model.value += 1;\n", " });\n", " model.on('value', () => {\n", " btn.innerHTML = `count is ${model.value}`;\n", " });\n", " return btn;\n", "}\n", "```\n", "\n", "Now create the file **counter_button.css**.\n", "\n", "```css\n", "button {\n", " background: #0072B5;\n", " color: white;\n", " border: none;\n", " padding: 10px;\n", " border-radius: 4px;\n", "}\n", "button:hover {\n", " background: #4099da;\n", "}\n", "```\n", "\n", "Serve the app with `panel serve counter_button.py --dev`.\n", "\n", "You can now edit the JavaScript or CSS file, and the changes will be automatically reloaded.\n", "\n", "- Try changing the `innerHTML` from `count is ${model.value}` to `COUNT IS ${model.value}` and observe the update. Note you must update `innerHTML` in two places.\n", "- Try changing the background color from `#0072B5` to `#008080`.\n", "\n", "## Displaying A Single Child\n", "\n", "You can display Panel components (`Viewable`s) by defining a `Child` parameter.\n", "\n", "Lets start with the simplest example:" ] }, { "cell_type": "code", "execution_count": null, "id": "6810c585-c55a-428b-85ac-ca1363dfd33a", "metadata": {}, "outputs": [], "source": [ "import panel as pn\n", "\n", "from panel.custom import Child, JSComponent\n", "\n", "class Example(JSComponent):\n", "\n", " child = Child()\n", "\n", " _esm = \"\"\"\n", " export function render({ model }) {\n", " const button = document.createElement(\"button\");\n", " button.append(model.get_child(\"child\"))\n", " return button\n", " }\"\"\"\n", "\n", "Example(child=pn.panel(\"A **Markdown** pane!\"))" ] }, { "cell_type": "markdown", "id": "e8f2695f-2ced-427c-88d7-414e97e0ec60", "metadata": {}, "source": [ "If you provide a non-`Viewable` child it will automatically be converted to a `Viewable` by `pn.panel`:" ] }, { "cell_type": "code", "execution_count": null, "id": "d796b164-fe14-44da-8795-666e3d4117a2", "metadata": {}, "outputs": [], "source": [ "Example(child=\"A **Markdown** pane!\")" ] }, { "cell_type": "markdown", "id": "c7bf894f-579a-4e19-b7e9-011879db5fc4", "metadata": {}, "source": [ "If you want to allow a certain type of Panel components only you can specify the specific type in the `class_` argument." ] }, { "cell_type": "code", "execution_count": null, "id": "853faef4-c890-4c81-b661-bdba7e89e9df", "metadata": {}, "outputs": [], "source": [ "import panel as pn\n", "\n", "from panel.custom import Child, JSComponent\n", "\n", "class Example(JSComponent):\n", "\n", " child = Child(class_=pn.pane.Markdown)\n", "\n", " _esm = \"\"\"\n", " export function render({ model }) {\n", " const button = document.createElement(\"button\");\n", " button.append(model.get_child(\"child\"))\n", " return button\n", " }\"\"\"\n", "\n", "Example(child=pn.panel(\"A **Markdown** pane!\"))" ] }, { "cell_type": "markdown", "id": "d25ec6a5-5d6f-4def-a3f2-aff9fa0a01b3", "metadata": {}, "source": [ "The `class_` argument also supports a tuple of types:\n", "\n", "```python\n", " child = Child(class_=(pn.pane.Markdown, pn.pane.HTML))\n", "```" ] }, { "cell_type": "markdown", "id": "bc15da31-0841-4166-8e70-bfa9ea97576a", "metadata": {}, "source": [ "## Displaying a List of Children\n", "\n", "You can also display a `List` of `Viewable` objects using the `Children` parameter type:" ] }, { "cell_type": "code", "execution_count": null, "id": "71f95d2b-e3fd-49de-890c-901cc0a7693e", "metadata": {}, "outputs": [], "source": [ "import panel as pn\n", "\n", "from panel.custom import Children, JSComponent\n", "\n", "\n", "class Example(JSComponent):\n", "\n", " objects = Children()\n", "\n", " _esm = \"\"\"\n", " export function render({ model }) {\n", " const div = document.createElement('div')\n", " div.append(...model.get_child(\"objects\"))\n", " return div\n", " }\"\"\"\n", "\n", "\n", "Example(\n", " objects=[pn.panel(\"A **Markdown** pane!\"), pn.widgets.Button(name=\"Click me!\"), {\"text\": \"I'm shown as a JSON Pane\"}]\n", ")" ] }, { "cell_type": "markdown", "id": "d56af9f3-39bd-4c68-bb8e-f889c534326c", "metadata": {}, "source": [ ":::note\n", "You can change the `item_type` to a specific subtype of `Viewable` or a tuple of\n", "`Viewable` subtypes.\n", ":::\n", "\n", "## References\n", "\n", "### Tutorials\n", "\n", "- [Build Custom Components](../../how_to/custom_components/esm/custom_layout.md)\n", "\n", "### How-To Guides\n", "\n", "- [Convert `AnyWidget` widgets](../../how_to/migrate/anywidget/index.md)\n", "\n", "### Reference Guides\n", "\n", "- [`AnyWidgetComponent`](AnyWidgetComponent.ipynb)\n", "- [`JSComponent`](JSComponent.ipynb)\n", "- [`ReactComponent`](ReactComponent.ipynb)" ] } ], "metadata": { "language_info": { "name": "python", "pygments_lexer": "ipython3" } }, "nbformat": 4, "nbformat_minor": 5 }