Skip to content

Commit

Permalink
Support web components (MVP for labs) (google#416)
Browse files Browse the repository at this point in the history
  • Loading branch information
wwwillchen authored Jun 17, 2024
1 parent e1ee74a commit 9efab77
Show file tree
Hide file tree
Showing 82 changed files with 1,261 additions and 103 deletions.
1 change: 1 addition & 0 deletions build_defs/defaults.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ ts_library = _ts_library
ANGULAR_CORE_DEPS = [
"@npm//@angular/compiler",
"@npm//@angular/router",
"@npm//@angular/elements",
]

ANGULAR_MATERIAL_TS_DEPS = [
Expand Down
2 changes: 1 addition & 1 deletion docs/components/box.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Overview

Box is a [content component](../guides/components.md#content-components) which acts as a container to group children components and styling them.
Box is a [content component](../components/index.md#content-components) which acts as a container to group children components and styling them.

## Examples

Expand Down
14 changes: 9 additions & 5 deletions docs/guides/components.md → docs/components/index.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Components

Please read [Quickstart](../getting_started/quickstart.md) before this as it explains the basics of components. This page provides an in-depth explanation of the different types of components in Mesop.
Please read [Quickstart](../getting_started/quickstart.md) before this as it explains the basics of components. This page provides an overview of the different types of components in Mesop.

## Kinds of components
## Types of components

### Native components

Expand All @@ -14,9 +14,13 @@ If you have a use case that's not supported by the existing native components, p

User-defined components are essentially Python functions which call other components, which can be native components or other user-defined components. It's very easy to write your own components, and it's encouraged to split your app into modular components for better maintainability and reusability.

### Web components

Web components in Mesop are custom HTML elements created using JavaScript and CSS. They enable custom JavaScript execution and bi-directional communication between the browser and server. They can wrap JavaScript libraries and provide stateful client-side interactions. [Learn more about web components](../web_components/index.md).

## Content components

Content components allow you to compose components more flexibly than regular components by accepting child(ren) components. A commonly used content component is the [button](../components/button.md) component, which accepts a child component which oftentimes the [text](../components/text.md) component.
Content components allow you to compose components more flexibly than regular components by accepting child(ren) components. A commonly used content component is the [button](./button.md) component, which accepts a child component which oftentimes the [text](./text.md) component.

Example:

Expand Down Expand Up @@ -60,7 +64,7 @@ Every native component in Mesop accepts a `key` argument which is a component id

### Resetting a component

You can reset a component to the initial state (e.g. reset a [select](../components/select.md) component to the unselected state) by giving it a new key value across renders.
You can reset a component to the initial state (e.g. reset a [select](./select.md) component to the unselected state) by giving it a new key value across renders.

For example, you can reset a component by "incrementing" the key:

Expand Down Expand Up @@ -117,4 +121,4 @@ def on_click(event: me.ClickEvent):

!!! Tip "Use component key for reusable event handler"

This avoids a [subtle issue with using closure variables in event handlers](./interactivity.md#avoid-using-closure-variables-in-event-handler).
This avoids a [subtle issue with using closure variables in event handlers](../guides/interactivity.md#avoid-using-closure-variables-in-event-handler).
2 changes: 1 addition & 1 deletion docs/getting_started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,4 @@ In summary, you've learned how to define a state class, an event handler and wir

At this point, you've learned all the basics of building a Mesop app and now you should be able to understand how [Text to Text is implemented](https://github.com/google/mesop/blob/main/mesop/labs/text_to_text.py) under the hood.

To learn more about Mesop, I recommend reading the [Guides](../guides/components.md) and then spend time looking at the [examples on GitHub](https://github.com/google/mesop/tree/main/mesop/examples). As you build your own applications, you'll want to reference the [Components API reference](../components/button.md) docs.
To learn more about Mesop, I recommend reading the [Guides](../components/index.md) and then spend time looking at the [examples on GitHub](https://github.com/google/mesop/tree/main/mesop/examples). As you build your own applications, you'll want to reference the [Components docs](../components/index.md).
2 changes: 1 addition & 1 deletion docs/guides/interactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,4 @@ def link_component(url: str):
return me.button(url, key=url, on_click=on_click)
```

For more info on using component keys, please refer to the [Component Key docs](./components.md#component-key).
For more info on using component keys, please refer to the [Component Key docs](../components/index.md#component-key).
24 changes: 24 additions & 0 deletions docs/web_components/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Web Components API

> Note: Web components are a new experimental feature released under labs and may have breaking changes.
**Example usage:**

```python
import mesop.labs as mel


@mel.web_component(...)
def a_web_component():
mel.insert_web_component(...)
```

## API

::: mesop.labs.web_component.web_component

::: mesop.labs.insert_web_component

::: mesop.labs.WebEvent

::: mesop.slot
24 changes: 24 additions & 0 deletions docs/web_components/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Web Components

> Note: Web components are a new experimental feature released under labs and may have breaking changes.
Mesop allows you to define custom components with web components which is a set of web standards that allows you to use JavaScript and CSS to define custom HTML elements.

## Use cases

- **Custom JavaScript** - You can execute custom JavaScript and have simple bi-directional communication between the JavaScript code running in the browser and the Python code running the server.

- **JavaScript libraries** - If you want to use a JavaScript library, you can wrap them with a web component.

- **Rich-client side interactivity** - You can use web components to deliver stateful client-side interactions without a network roundtrip.

## Anatomy of a web component

Mesop web component consists of two parts:

- **Python module** - defines a Python API so that your Mesop app can use the web component seamlessly.
- **JavaScript module** - implements the web component.

## Next steps

Learn how to build your first web component in the [quickstart](./quickstart.md) page.
81 changes: 81 additions & 0 deletions docs/web_components/quickstart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Quickstart

> Note: Web components are a new experimental feature released under labs and may have breaking changes.
You will learn how to build your first web component step-by-step, a counter component.

Although it's a simple example, it will show you the core APIs of defining your own web component and how to support bi-directional communication between the Python code running on the server and JavaScript code running on the browser.

### Python module

Let's first take a look at the Python module which defines the interface so that the rest of your Mesop app can call the web component in a Pythonic way.

```python title="counter_component.py"
--8<-- "mesop/examples/web_component/quickstart/counter_component.py"
```

The first part you will notice is the decorator: `@mel.web_component`. This annotates a function as a web component and specifies where the corresponding JavaScript module is located, relative to the location of this Python module.

We've defined the function parameters just like a regular Python function.

> Tip: We recommend annotating your parameter with types because Mesop will do runtime validation which will catch type issues earlier.
Finally, we call the function `mel.insert_web_component` with the following arguments:

- `name` - This is the web component name and must match the name defined in the JavaScript module.
- `key` - Like all components, web components accept a key which is a unique identifier. See the [component key docs](../components/index.md#component-key) for more info.
- `events` - A dictionary where the key is the event name. This must match a property name, defined in JavaScript. The value is the event handler (callback) function.
- `properties` - A dictionary where the key is the property name that's defined in JavaScript and the value is the property value which is plumbed to the JavaScript component.

In summary, when you see a string literal, it should match something on the JavaScript side which is explained next.

### JavaScript module

Let's now take a look at how we implement in the web component in JavaScript:

```javascript title="counter_component.js"
--8<-- "mesop/examples/web_component/quickstart/counter_component.js"
```

In this example, we have used [Lit](https://lit.dev/) which is a small library built on top of web standards in a simple, secure and declarative manner.

> Note: you can write your web components using any web technologies (e.g. TypeScript) or frameworks as long as they conform to the interface defined by your Python module.
#### Properties

The static property named `properties` defines two kinds of properties:

- **Regular properties** - these were defined in the `properties` argument of `insert_web_component`. The property name in JS must match one of the `properties` dictionary key. You also should make sure the Python and JS types are compatible to avoid issues.
- **Event properties** - these were defined in the `events` argument of `insert_web_component`. The property name in JS must match one of the `events` dictionary key. Event properties are always type `String` because the value is a handler id which identifies the Python event handler function.

#### Triggering an event

To trigger an event in your component, let's look at the `_onDecrement` method implementation:

```javascript
this.dispatchEvent(
new MesopEvent(this.decrementEvent, {
value: this.value - 1,
}),
);
```

`this.dispatchEvent` is a [standard web API](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) where a DOM element can emit an event. For Mesop web components, we will always emit a `MesopEvent` which is a class provided on the global object (`window`). The first argument is the event handler id so Mesop knows which Python function to call as the event handler and the second argument is the payload which is a JSON-serializable value (oftentimes an object) that the Python event handler can access.

#### Learn more about Lit

I didn't cover the `render` function which is a [standard Lit method](https://lit.dev/docs/components/rendering/). I recommend reading through [Lit's docs](https://lit.dev/docs/getting-started/) which are excellent ahd have interactive tutorials.

### Using the component

Finally, let's use the web component we defined. When you click on the decrement button, the value will decrease from 10 to 9 and so on.

```python title="counter_component_app.py"
--8<-- "mesop/examples/web_component/quickstart/counter_component_app.py"
```

Even though this was a toy example, you've learned how to build a web component from scratch which does bi-directional communication between the Python server and JavaScript client.

## Next steps

To learn more, read the [API docs](./api.md) or look at the [examples](https://github.com/google/mesop/tree/main/mesop/examples/web_component/).
64 changes: 62 additions & 2 deletions mesop/component_helpers/helper.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import hashlib
import inspect
import json
from functools import wraps
from typing import Any, Callable, Generator, Type, TypeVar, cast, overload
from typing import (
Any,
Callable,
Generator,
Type,
TypeVar,
cast,
overload,
)

from google.protobuf import json_format
from google.protobuf.message import Message

import mesop.protos.ui_pb2 as pb
from mesop.component_helpers.style import Style, to_style_proto
from mesop.events import ClickEvent, InputEvent, MesopEvent
from mesop.events import ClickEvent, InputEvent, MesopEvent, WebEvent
from mesop.exceptions import MesopDeveloperException
from mesop.key import Key, key_from_proto
from mesop.runtime import runtime
Expand Down Expand Up @@ -45,6 +54,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore


def slot():
"""
This function is used when defining a content component to mark a place in the component tree where content
can be provided by a child component.
"""
runtime().context().save_current_node_as_slot()


Expand Down Expand Up @@ -227,6 +240,45 @@ def insert_composite_component(
)


def insert_web_component(
name: str,
events: dict[str, Callable[[WebEvent], Any]] | None = None,
properties: dict[str, Any] | None = None,
key: str | None = None,
):
"""
Inserts a web component into the current component tree.
Args:
name: The name of the web component. This should match the custom element name defined in JavaScript.
events: A dictionary where the key is the event name, which must match a web component property name defined in JavaScript.
The value is the event handler (callback) function.
properties: A dictionary where the key is the web component property name that's defined in JavaScript and the value is the
property value which is plumbed to the JavaScript component.
key: A unique identifier for the web component. Defaults to None.
"""
if events is None:
events = dict()
if properties is None:
properties = dict()

event_to_ids: dict[str, str] = {}
for event in events:
event_handler = events[event]
event_to_ids[event] = register_event_handler(event_handler, WebEvent)
type_proto = pb.WebComponentType(
properties_json=json.dumps(properties),
events_json=json.dumps(event_to_ids),
)
return insert_composite_component(
# Prefix with <web> to ensure there's never any overlap with built-in components.
type_name="<web>" + name,
proto=type_proto,
key=key,
)


# TODO: remove insert_custom_component
def insert_custom_component(
component_name: str,
proto: Message,
Expand Down Expand Up @@ -342,6 +394,14 @@ def register_event_mapper(
),
)

runtime().register_event_mapper(
WebEvent,
lambda userEvent, key: WebEvent(
key=key.key,
value=json.loads(userEvent.string_value),
),
)


_COMPONENT_DIFF_FIELDS = (
"key",
Expand Down
2 changes: 1 addition & 1 deletion mesop/components/audio/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def audio(
Args:
src: The URL of the audio to be played.
autoplay: boolean value indicating if the audio should be autoplayed or not. **Note**: There are autoplay restrictions in modern browsers, including Chrome, are designed to prevent audio or video from playing automatically without user interaction. This is intended to improve user experience and reduce unwanted interruptions
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
"""
insert_component(
key=key,
Expand Down
2 changes: 1 addition & 1 deletion mesop/components/badge/badge.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def badge(
description: Message used to describe the decorated element via aria-describedby
size: Size of the badge. Can be 'small', 'medium', or 'large'.
hidden: Whether the badge is hidden.
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
"""
return insert_composite_component(
key=key,
Expand Down
2 changes: 1 addition & 1 deletion mesop/components/box/box.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def box(
style: Style to apply to component. Follows [HTML Element inline style API](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style).
on_click: The callback function that is called when the box is clicked.
It receives a ClickEvent as its only argument.
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
Returns:
The created box component.
Expand Down
4 changes: 2 additions & 2 deletions mesop/components/button/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def button(
disable_ripple: Whether the ripple effect is disabled or not.
disabled: Whether the button is disabled.
style: Style for the component.
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
"""
with content_button(
on_click=on_click,
Expand Down Expand Up @@ -70,7 +70,7 @@ def content_button(
disable_ripple: Whether the ripple effect is disabled or not.
disabled: Whether the button is disabled.
style: Style for the component.
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
"""
return insert_composite_component(
key=key,
Expand Down
4 changes: 2 additions & 2 deletions mesop/components/checkbox/checkbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def checkbox(
disabled: Whether the checkbox is disabled.
indeterminate: Whether the checkbox is indeterminate. This is also known as "mixed" mode and can be used to represent a checkbox with three states, e.g. a checkbox that represents a nested list of checkable items. Note that whenever checkbox is manually clicked, indeterminate is immediately set to false.
style: Style for the component.
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
"""
with content_checkbox(
on_change=on_change,
Expand Down Expand Up @@ -136,7 +136,7 @@ def content_checkbox(
disabled: Whether the checkbox is disabled.
indeterminate: Whether the checkbox is indeterminate. This is also known as "mixed" mode and can be used to represent a checkbox with three states, e.g. a checkbox that represents a nested list of checkable items. Note that whenever checkbox is manually clicked, indeterminate is immediately set to false.
style: Style for the component.
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
"""
return insert_composite_component(
key=key,
Expand Down
2 changes: 1 addition & 1 deletion mesop/components/divider/divider.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def divider(*, key: str | None = None, inset: bool = False):
"""Creates a Divider component.
Args:
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
inset: Whether the divider is an inset divider.
"""
insert_component(
Expand Down
2 changes: 1 addition & 1 deletion mesop/components/embed/embed.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def embed(
Args:
src: The source URL for the embed content.
style: The style to apply to the embed, such as width and height.
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
"""
insert_component(
key=key,
Expand Down
2 changes: 1 addition & 1 deletion mesop/components/html/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def html(
Args:
html: The HTML content to be rendered.
style: The style to apply to the embed, such as width and height.
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
"""
if style is None:
style = Style()
Expand Down
2 changes: 1 addition & 1 deletion mesop/components/icon/icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def icon(
"""Creates a Icon component.
Args:
key: The component [key](../guides/components.md#component-key).
key: The component [key](../components/index.md#component-key).
icon: Name of the [Material Symbols icon](https://fonts.google.com/icons).
style: Inline styles
"""
Expand Down
Loading

0 comments on commit 9efab77

Please sign in to comment.