From 9efab77745a40cb2906b3b25bb564b899ad3fcea Mon Sep 17 00:00:00 2001 From: Will Chen Date: Mon, 17 Jun 2024 16:44:27 -0700 Subject: [PATCH] Support web components (MVP for labs) (#416) --- build_defs/defaults.bzl | 1 + docs/components/box.md | 2 +- .../components.md => components/index.md} | 14 +- docs/getting_started/quickstart.md | 2 +- docs/guides/interactivity.md | 2 +- docs/web_components/api.md | 24 ++++ docs/web_components/index.md | 24 ++++ docs/web_components/quickstart.md | 81 ++++++++++++ mesop/component_helpers/helper.py | 64 ++++++++- mesop/components/audio/audio.py | 2 +- mesop/components/badge/badge.py | 2 +- mesop/components/box/box.py | 2 +- mesop/components/button/button.py | 4 +- mesop/components/checkbox/checkbox.py | 4 +- mesop/components/divider/divider.py | 2 +- mesop/components/embed/embed.py | 2 +- mesop/components/html/html.py | 2 +- mesop/components/icon/icon.py | 2 +- mesop/components/image/image.py | 2 +- mesop/components/input/input.py | 6 +- mesop/components/progress_bar/progress_bar.py | 2 +- .../progress_spinner/progress_spinner.py | 2 +- mesop/components/radio/radio.py | 2 +- mesop/components/select/select.py | 2 +- mesop/components/sidenav/sidenav.py | 2 +- mesop/components/slide_toggle/slide_toggle.py | 4 +- mesop/components/slider/slider.py | 2 +- mesop/components/text/text.py | 2 +- mesop/components/tooltip/tooltip.py | 2 +- mesop/events/__init__.py | 3 + mesop/events/events.py | 13 ++ mesop/examples/BUILD | 1 + mesop/examples/__init__.py | 1 + mesop/examples/web_component/BUILD | 18 +++ mesop/examples/web_component/__init__.py | 12 ++ mesop/examples/web_component/plotly/BUILD | 14 ++ .../web_component/plotly/plotly_app.py | 17 +++ .../web_component/plotly/plotly_component.js | 55 ++++++++ .../web_component/plotly/plotly_component.py | 6 + mesop/examples/web_component/quickstart/BUILD | 14 ++ .../quickstart/counter_component.js | 38 ++++++ .../quickstart/counter_component.py | 22 ++++ .../quickstart/counter_component_app.py | 33 +++++ .../web_component/shared_js_module/BUILD | 14 ++ .../shared_js_module/shared_js_module_app.py | 12 ++ .../shared_js_module/shared_module.js | 1 + .../shared_js_module/web_component.js | 13 ++ .../shared_js_module/web_component.py | 6 + mesop/examples/web_component/slot/BUILD | 14 ++ .../web_component/slot/counter_component.js | 38 ++++++ .../web_component/slot/counter_component.py | 22 ++++ .../web_component/slot/outer_component.js | 42 ++++++ .../web_component/slot/outer_component.py | 23 ++++ mesop/examples/web_component/slot/slot_app.py | 60 +++++++++ mesop/labs/__init__.py | 7 + mesop/labs/web_component.py | 55 ++++++++ mesop/protos/ui.proto | 5 + mesop/runtime/runtime.py | 4 + mesop/security/security_policy.py | 11 ++ mesop/server/static_file_serving.py | 48 ++++++- ...ity_test.ts_csp-allowed-iframe-parents.txt | 5 +- .../snapshots/web_security_test.ts_csp.txt | 5 +- .../e2e/web_components/quickstart_test.ts | 10 ++ .../web_components/shared_js_module_test.ts | 9 ++ mesop/tests/e2e/web_components/slot_test.ts | 35 +++++ mesop/utils/runfiles.py | 4 + mesop/web/src/app/editor/index.html | 3 +- mesop/web/src/app/prod/index.html | 3 +- .../component_renderer/component_renderer.ts | 122 ++++++++++++++++-- .../web/src/component_renderer/mesop_event.ts | 17 +++ mesop/web/src/editor/editor.ts | 17 ++- mesop/web/src/shell/shell.ts | 27 +++- mkdocs.yml | 9 +- package.json | 1 + playwright.config.ts | 4 +- scripts/smoketest_app/inner_component.js | 39 ++++++ scripts/smoketest_app/inner_component.py | 23 ++++ scripts/smoketest_app/main.py | 3 +- scripts/smoketest_app/outer_component.js | 42 ++++++ scripts/smoketest_app/outer_component.py | 23 ++++ scripts/smoketest_app/simple_slot_app.py | 39 ++++++ yarn.lock | 38 ++---- 82 files changed, 1261 insertions(+), 103 deletions(-) rename docs/{guides/components.md => components/index.md} (83%) create mode 100644 docs/web_components/api.md create mode 100644 docs/web_components/index.md create mode 100644 docs/web_components/quickstart.md create mode 100644 mesop/examples/web_component/BUILD create mode 100644 mesop/examples/web_component/__init__.py create mode 100644 mesop/examples/web_component/plotly/BUILD create mode 100644 mesop/examples/web_component/plotly/plotly_app.py create mode 100644 mesop/examples/web_component/plotly/plotly_component.js create mode 100644 mesop/examples/web_component/plotly/plotly_component.py create mode 100644 mesop/examples/web_component/quickstart/BUILD create mode 100644 mesop/examples/web_component/quickstart/counter_component.js create mode 100644 mesop/examples/web_component/quickstart/counter_component.py create mode 100644 mesop/examples/web_component/quickstart/counter_component_app.py create mode 100644 mesop/examples/web_component/shared_js_module/BUILD create mode 100644 mesop/examples/web_component/shared_js_module/shared_js_module_app.py create mode 100644 mesop/examples/web_component/shared_js_module/shared_module.js create mode 100644 mesop/examples/web_component/shared_js_module/web_component.js create mode 100644 mesop/examples/web_component/shared_js_module/web_component.py create mode 100644 mesop/examples/web_component/slot/BUILD create mode 100644 mesop/examples/web_component/slot/counter_component.js create mode 100644 mesop/examples/web_component/slot/counter_component.py create mode 100644 mesop/examples/web_component/slot/outer_component.js create mode 100644 mesop/examples/web_component/slot/outer_component.py create mode 100644 mesop/examples/web_component/slot/slot_app.py create mode 100644 mesop/labs/web_component.py create mode 100644 mesop/tests/e2e/web_components/quickstart_test.ts create mode 100644 mesop/tests/e2e/web_components/shared_js_module_test.ts create mode 100644 mesop/tests/e2e/web_components/slot_test.ts create mode 100644 mesop/web/src/component_renderer/mesop_event.ts create mode 100644 scripts/smoketest_app/inner_component.js create mode 100644 scripts/smoketest_app/inner_component.py create mode 100644 scripts/smoketest_app/outer_component.js create mode 100644 scripts/smoketest_app/outer_component.py create mode 100644 scripts/smoketest_app/simple_slot_app.py diff --git a/build_defs/defaults.bzl b/build_defs/defaults.bzl index b4af07e46..401b8e4ab 100644 --- a/build_defs/defaults.bzl +++ b/build_defs/defaults.bzl @@ -39,6 +39,7 @@ ts_library = _ts_library ANGULAR_CORE_DEPS = [ "@npm//@angular/compiler", "@npm//@angular/router", + "@npm//@angular/elements", ] ANGULAR_MATERIAL_TS_DEPS = [ diff --git a/docs/components/box.md b/docs/components/box.md index f826acf04..4b8291f1b 100644 --- a/docs/components/box.md +++ b/docs/components/box.md @@ -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 diff --git a/docs/guides/components.md b/docs/components/index.md similarity index 83% rename from docs/guides/components.md rename to docs/components/index.md index 72d031b43..437721404 100644 --- a/docs/guides/components.md +++ b/docs/components/index.md @@ -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 @@ -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: @@ -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: @@ -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). diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index 9ccb15f97..8fd722fe1 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -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). diff --git a/docs/guides/interactivity.md b/docs/guides/interactivity.md index bd4091ce1..29201dd23 100644 --- a/docs/guides/interactivity.md +++ b/docs/guides/interactivity.md @@ -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). diff --git a/docs/web_components/api.md b/docs/web_components/api.md new file mode 100644 index 000000000..66f578ecf --- /dev/null +++ b/docs/web_components/api.md @@ -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 diff --git a/docs/web_components/index.md b/docs/web_components/index.md new file mode 100644 index 000000000..f84230bef --- /dev/null +++ b/docs/web_components/index.md @@ -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. diff --git a/docs/web_components/quickstart.md b/docs/web_components/quickstart.md new file mode 100644 index 000000000..473d5f345 --- /dev/null +++ b/docs/web_components/quickstart.md @@ -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/). diff --git a/mesop/component_helpers/helper.py b/mesop/component_helpers/helper.py index 388730c1e..c9d4af19a 100644 --- a/mesop/component_helpers/helper.py +++ b/mesop/component_helpers/helper.py @@ -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 @@ -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() @@ -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 to ensure there's never any overlap with built-in components. + type_name="" + name, + proto=type_proto, + key=key, + ) + + +# TODO: remove insert_custom_component def insert_custom_component( component_name: str, proto: Message, @@ -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", diff --git a/mesop/components/audio/audio.py b/mesop/components/audio/audio.py index 6b8cb9c1f..1768e64b2 100644 --- a/mesop/components/audio/audio.py +++ b/mesop/components/audio/audio.py @@ -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, diff --git a/mesop/components/badge/badge.py b/mesop/components/badge/badge.py index 8bb744df5..73cefcdc2 100644 --- a/mesop/components/badge/badge.py +++ b/mesop/components/badge/badge.py @@ -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, diff --git a/mesop/components/box/box.py b/mesop/components/box/box.py index e87fdf44f..003529275 100644 --- a/mesop/components/box/box.py +++ b/mesop/components/box/box.py @@ -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. diff --git a/mesop/components/button/button.py b/mesop/components/button/button.py index 4dde100f1..d17f66790 100644 --- a/mesop/components/button/button.py +++ b/mesop/components/button/button.py @@ -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, @@ -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, diff --git a/mesop/components/checkbox/checkbox.py b/mesop/components/checkbox/checkbox.py index 224847cf7..86816bbc8 100644 --- a/mesop/components/checkbox/checkbox.py +++ b/mesop/components/checkbox/checkbox.py @@ -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, @@ -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, diff --git a/mesop/components/divider/divider.py b/mesop/components/divider/divider.py index e172b6529..da4c1c0b4 100644 --- a/mesop/components/divider/divider.py +++ b/mesop/components/divider/divider.py @@ -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( diff --git a/mesop/components/embed/embed.py b/mesop/components/embed/embed.py index 138f19541..43612510a 100644 --- a/mesop/components/embed/embed.py +++ b/mesop/components/embed/embed.py @@ -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, diff --git a/mesop/components/html/html.py b/mesop/components/html/html.py index e59411cd1..195c470ff 100644 --- a/mesop/components/html/html.py +++ b/mesop/components/html/html.py @@ -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() diff --git a/mesop/components/icon/icon.py b/mesop/components/icon/icon.py index a11a38fad..bb5ee344a 100644 --- a/mesop/components/icon/icon.py +++ b/mesop/components/icon/icon.py @@ -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 """ diff --git a/mesop/components/image/image.py b/mesop/components/image/image.py index b2cb709b2..4c6251926 100644 --- a/mesop/components/image/image.py +++ b/mesop/components/image/image.py @@ -21,7 +21,7 @@ def image( src: The source URL of the image. alt: The alternative text for the image if it cannot be displayed. style: The style to apply to the image, 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, diff --git a/mesop/components/input/input.py b/mesop/components/input/input.py index 4ccc5f333..015ccbf16 100644 --- a/mesop/components/input/input.py +++ b/mesop/components/input/input.py @@ -71,7 +71,7 @@ def textarea( float_label: Whether the label should always float or float as the user types. subscript_sizing: Whether the form field should reserve space for one line of hint/error text (default) or to have the spacing grow from 0px as needed based on the size of the hint/error content. Note that when using dynamic sizing, layout shifts will occur when hint/error text changes. hint_label: Text for the form field hint. - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). """ insert_component( @@ -159,7 +159,7 @@ def input( float_label: Whether the label should always float or float as the user types. subscript_sizing: Whether the form field should reserve space for one line of hint/error text (default) or to have the spacing grow from 0px as needed based on the size of the hint/error content. Note that when using dynamic sizing, layout shifts will occur when hint/error text changes. hint_label: Text for the form field hint. - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). """ insert_component( @@ -217,7 +217,7 @@ def native_textarea( placeholder: Placeholder value value: Initial value. readonly: Whether the element is readonly. - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). """ insert_component( diff --git a/mesop/components/progress_bar/progress_bar.py b/mesop/components/progress_bar/progress_bar.py index f45afd53b..ca48e1c7c 100644 --- a/mesop/components/progress_bar/progress_bar.py +++ b/mesop/components/progress_bar/progress_bar.py @@ -48,7 +48,7 @@ def progress_bar( """Creates a Progress bar component. Args: - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). color: Theme palette color of the progress bar. value: Value of the progress bar. Defaults to zero. Mirrored to aria-valuenow. buffer_value: Buffer value of the progress bar. Defaults to zero. diff --git a/mesop/components/progress_spinner/progress_spinner.py b/mesop/components/progress_spinner/progress_spinner.py index cceb77fff..41a2ff55f 100644 --- a/mesop/components/progress_spinner/progress_spinner.py +++ b/mesop/components/progress_spinner/progress_spinner.py @@ -18,7 +18,7 @@ def progress_spinner( """Creates a Progress spinner component. Args: - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). color: Theme palette color of the progress spinner. diameter: The diameter of the progress spinner (will set width and height of svg). stroke_width: Stroke width of the progress spinner. diff --git a/mesop/components/radio/radio.py b/mesop/components/radio/radio.py index 40dec7b68..dc6ef8873 100644 --- a/mesop/components/radio/radio.py +++ b/mesop/components/radio/radio.py @@ -64,7 +64,7 @@ def radio( value: Value for the radio-group. Should equal the value of the selected radio button if there is a corresponding radio button with a matching value. disabled: Whether the radio group 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). """ insert_component( key=key, diff --git a/mesop/components/select/select.py b/mesop/components/select/select.py index 80edb6a6d..df448c8ee 100644 --- a/mesop/components/select/select.py +++ b/mesop/components/select/select.py @@ -95,7 +95,7 @@ def select( placeholder: Placeholder to be shown if no value has been selected. value: Value of the select control. style: Style. - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). """ insert_component( key=key, diff --git a/mesop/components/sidenav/sidenav.py b/mesop/components/sidenav/sidenav.py index 3c199c66b..1551475ba 100644 --- a/mesop/components/sidenav/sidenav.py +++ b/mesop/components/sidenav/sidenav.py @@ -19,7 +19,7 @@ def sidenav( Args: opened: A flag to determine if the sidenav is open or closed. Defaults to True. style: An optional Style object to apply custom styles. Defaults to None. - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). """ return insert_composite_component( key=key, diff --git a/mesop/components/slide_toggle/slide_toggle.py b/mesop/components/slide_toggle/slide_toggle.py index 9b2acace2..b2db1ce7c 100644 --- a/mesop/components/slide_toggle/slide_toggle.py +++ b/mesop/components/slide_toggle/slide_toggle.py @@ -60,7 +60,7 @@ def slide_toggle( tab_index: Tabindex of slide toggle. checked: Whether the slide-toggle element is checked or not. hide_icon: Whether to hide the icon inside of the slide toggle. - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). """ with content_slide_toggle( key=key, @@ -106,7 +106,7 @@ def content_slide_toggle( tab_index: Tabindex of slide toggle. checked: Whether the slide-toggle element is checked or not. hide_icon: Whether to hide the icon inside of the slide toggle. - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). """ return insert_composite_component( key=key, diff --git a/mesop/components/slider/slider.py b/mesop/components/slider/slider.py index c41fe6de3..cb43b0532 100644 --- a/mesop/components/slider/slider.py +++ b/mesop/components/slider/slider.py @@ -64,7 +64,7 @@ def slider( color: Palette color of the slider. disable_ripple: Whether ripples are disabled in the slider. style: Style for the component. - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). """ insert_component( key=key, diff --git a/mesop/components/text/text.py b/mesop/components/text/text.py index 7a72a6667..7a3bef008 100644 --- a/mesop/components/text/text.py +++ b/mesop/components/text/text.py @@ -37,7 +37,7 @@ def text( text: The text to display. type: The typography level for the text. style: Style to apply to component. Follows [HTML Element inline style API](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style). - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). """ insert_component( key=key, diff --git a/mesop/components/tooltip/tooltip.py b/mesop/components/tooltip/tooltip.py index 11544831d..6423b0dcc 100644 --- a/mesop/components/tooltip/tooltip.py +++ b/mesop/components/tooltip/tooltip.py @@ -24,7 +24,7 @@ def tooltip( Tooltip is a composite component. Args: - key: The component [key](../guides/components.md#component-key). + key: The component [key](../components/index.md#component-key). position: Allows the user to define the position of the tooltip relative to the parent element position_at_origin: Whether tooltip should be relative to the click or touch origin instead of outside the element bounding box. disabled: Disables the display of the tooltip. diff --git a/mesop/events/__init__.py b/mesop/events/__init__.py index bb925e432..98b332961 100644 --- a/mesop/events/__init__.py +++ b/mesop/events/__init__.py @@ -10,3 +10,6 @@ from .events import ( MesopEvent as MesopEvent, ) +from .events import ( + WebEvent as WebEvent, +) diff --git a/mesop/events/events.py b/mesop/events/events.py index 9438fc39d..0a6c946d1 100644 --- a/mesop/events/events.py +++ b/mesop/events/events.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any @dataclass(kw_only=True) @@ -43,3 +44,15 @@ class LoadEvent: """ path: str + + +@dataclass(kw_only=True) +class WebEvent(MesopEvent): + """An event emitted by a web component. + + Attributes: + value: The value associated with the web event. + key (str): key of the component that emitted this event. + """ + + value: Any diff --git a/mesop/examples/BUILD b/mesop/examples/BUILD index 89474abfc..e62221a5e 100644 --- a/mesop/examples/BUILD +++ b/mesop/examples/BUILD @@ -40,6 +40,7 @@ py_library( "//mesop/components/box/e2e", "//mesop/components/checkbox/e2e", "//mesop/components/text/e2e", + "//mesop/examples/web_component", "//mesop/examples/docs", "//mesop/examples/integrations", "//mesop/examples/shared", diff --git a/mesop/examples/__init__.py b/mesop/examples/__init__.py index 5bba7f7a6..874ed3efa 100644 --- a/mesop/examples/__init__.py +++ b/mesop/examples/__init__.py @@ -28,4 +28,5 @@ from mesop.examples import sxs as sxs from mesop.examples import testing as testing from mesop.examples import viewport_size as viewport_size +from mesop.examples import web_component as web_component # Do not import error_state_missing_init_prop because it cause all examples to fail. diff --git a/mesop/examples/web_component/BUILD b/mesop/examples/web_component/BUILD new file mode 100644 index 000000000..e344c4642 --- /dev/null +++ b/mesop/examples/web_component/BUILD @@ -0,0 +1,18 @@ +load("//build_defs:defaults.bzl", "py_library") + +package( + default_visibility = ["//build_defs:mesop_examples"], +) + +py_library( + name = "web_component", + srcs = glob(["*.py"]), + data = glob(["*.js"]), + deps = [ + "//mesop", + "//mesop/examples/web_component/plotly", + "//mesop/examples/web_component/quickstart", + "//mesop/examples/web_component/shared_js_module", + "//mesop/examples/web_component/slot", + ], +) diff --git a/mesop/examples/web_component/__init__.py b/mesop/examples/web_component/__init__.py new file mode 100644 index 000000000..fa26eebfa --- /dev/null +++ b/mesop/examples/web_component/__init__.py @@ -0,0 +1,12 @@ +from mesop.examples.web_component.plotly import ( + plotly_app as plotly_app, +) +from mesop.examples.web_component.quickstart import ( + counter_component_app as counter_component_app, +) +from mesop.examples.web_component.shared_js_module import ( + shared_js_module_app as shared_js_module_app, +) +from mesop.examples.web_component.slot import ( + slot_app as slot_app, +) diff --git a/mesop/examples/web_component/plotly/BUILD b/mesop/examples/web_component/plotly/BUILD new file mode 100644 index 000000000..c7f9515fa --- /dev/null +++ b/mesop/examples/web_component/plotly/BUILD @@ -0,0 +1,14 @@ +load("//build_defs:defaults.bzl", "py_library") + +package( + default_visibility = ["//build_defs:mesop_examples"], +) + +py_library( + name = "plotly", + srcs = glob(["*.py"]), + data = glob(["*.js"]), + deps = [ + "//mesop", + ], +) diff --git a/mesop/examples/web_component/plotly/plotly_app.py b/mesop/examples/web_component/plotly/plotly_app.py new file mode 100644 index 000000000..e7d07afd7 --- /dev/null +++ b/mesop/examples/web_component/plotly/plotly_app.py @@ -0,0 +1,17 @@ +import mesop as me +from mesop.examples.web_component.plotly.plotly_component import ( + plotly_component, +) + + +@me.page( + path="/web_component/plotly/plotly_app", + # CAUTION: this disables an important web security feature and + # should not be used for most mesop apps. + # + # Disabling trusted types because plotly uses DomParser#parseFromString + # which violates TrustedHTML assignment. + security_policy=me.SecurityPolicy(dangerously_disable_trusted_types=True), +) +def page(): + plotly_component() diff --git a/mesop/examples/web_component/plotly/plotly_component.js b/mesop/examples/web_component/plotly/plotly_component.js new file mode 100644 index 000000000..4116980fb --- /dev/null +++ b/mesop/examples/web_component/plotly/plotly_component.js @@ -0,0 +1,55 @@ +import { + LitElement, + html, +} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js'; +import 'https://cdn.plot.ly/plotly-2.32.0.min.js'; + +class PlotlyComponent extends LitElement { + createRenderRoot() { + return this; + } + + firstUpdated() { + this.renderPlot(); + } + + renderPlot() { + var trace1 = { + x: [1, 2, 3, 4, 5], + y: [10, 15, 13, 17, 21], + type: 'scatter', + mode: 'lines+markers', + marker: {color: 'red'}, + name: 'Line 1', + }; + + var trace2 = { + x: [1, 2, 3, 4, 5], + y: [16, 5, 11, 9, 8], + type: 'scatter', + mode: 'lines+markers', + marker: {color: 'blue'}, + name: 'Line 2', + }; + + var data = [trace1, trace2]; + + var layout = { + title: 'Simple Line Chart Example', + xaxis: { + title: 'X Axis', + }, + yaxis: { + title: 'Y Axis', + }, + }; + + Plotly.newPlot(document.getElementById('plot'), data, layout); + } + + render() { + return html`
`; + } +} + +customElements.define('plotly-component', PlotlyComponent); diff --git a/mesop/examples/web_component/plotly/plotly_component.py b/mesop/examples/web_component/plotly/plotly_component.py new file mode 100644 index 000000000..e12119222 --- /dev/null +++ b/mesop/examples/web_component/plotly/plotly_component.py @@ -0,0 +1,6 @@ +import mesop.labs as mel + + +@mel.web_component(path="./plotly_component.js") +def plotly_component(): + return mel.insert_web_component(name="plotly-component") diff --git a/mesop/examples/web_component/quickstart/BUILD b/mesop/examples/web_component/quickstart/BUILD new file mode 100644 index 000000000..66b97e4cc --- /dev/null +++ b/mesop/examples/web_component/quickstart/BUILD @@ -0,0 +1,14 @@ +load("//build_defs:defaults.bzl", "py_library") + +package( + default_visibility = ["//build_defs:mesop_examples"], +) + +py_library( + name = "quickstart", + srcs = glob(["*.py"]), + data = glob(["*.js"]), + deps = [ + "//mesop", + ], +) diff --git a/mesop/examples/web_component/quickstart/counter_component.js b/mesop/examples/web_component/quickstart/counter_component.js new file mode 100644 index 000000000..4372fcb32 --- /dev/null +++ b/mesop/examples/web_component/quickstart/counter_component.js @@ -0,0 +1,38 @@ +import { + LitElement, + html, +} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js'; + +class CounterComponent extends LitElement { + static properties = { + value: {type: Number}, + decrementEvent: {type: String}, + }; + + constructor() { + super(); + this.value = 0; + this.decrementEvent = ''; + } + + render() { + return html` +
+ Value: ${this.value} + +
+ `; + } + + _onDecrement() { + this.dispatchEvent( + new MesopEvent(this.decrementEvent, { + value: this.value - 1, + }), + ); + } +} + +customElements.define('quickstart-counter-component', CounterComponent); diff --git a/mesop/examples/web_component/quickstart/counter_component.py b/mesop/examples/web_component/quickstart/counter_component.py new file mode 100644 index 000000000..92c3cf149 --- /dev/null +++ b/mesop/examples/web_component/quickstart/counter_component.py @@ -0,0 +1,22 @@ +from typing import Any, Callable + +import mesop.labs as mel + + +@mel.web_component(path="./counter_component.js") +def counter_component( + *, + value: int, + on_decrement: Callable[[mel.WebEvent], Any], + key: str | None = None, +): + return mel.insert_web_component( + name="quickstart-counter-component", + key=key, + events={ + "decrementEvent": on_decrement, + }, + properties={ + "value": value, + }, + ) diff --git a/mesop/examples/web_component/quickstart/counter_component_app.py b/mesop/examples/web_component/quickstart/counter_component_app.py new file mode 100644 index 000000000..9ddbd8bc8 --- /dev/null +++ b/mesop/examples/web_component/quickstart/counter_component_app.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel + +import mesop as me +import mesop.labs as mel +from mesop.examples.web_component.quickstart.counter_component import ( + counter_component, +) + + +@me.page( + path="/web_component/quickstart/counter_component_app", +) +def page(): + counter_component( + value=me.state(State).value, + on_decrement=on_decrement, + ) + + +@me.stateclass +class State: + value: int = 10 + + +class ChangeValue(BaseModel): + value: int + + +def on_decrement(e: mel.WebEvent): + # Creating a Pydantic model from the JSON value of the WebEvent + # to enforce type safety. + decrement = ChangeValue(**e.value) + me.state(State).value = decrement.value diff --git a/mesop/examples/web_component/shared_js_module/BUILD b/mesop/examples/web_component/shared_js_module/BUILD new file mode 100644 index 000000000..0de787588 --- /dev/null +++ b/mesop/examples/web_component/shared_js_module/BUILD @@ -0,0 +1,14 @@ +load("//build_defs:defaults.bzl", "py_library") + +package( + default_visibility = ["//build_defs:mesop_examples"], +) + +py_library( + name = "shared_js_module", + srcs = glob(["*.py"]), + data = glob(["*.js"]), + deps = [ + "//mesop", + ], +) diff --git a/mesop/examples/web_component/shared_js_module/shared_js_module_app.py b/mesop/examples/web_component/shared_js_module/shared_js_module_app.py new file mode 100644 index 000000000..7969bdc43 --- /dev/null +++ b/mesop/examples/web_component/shared_js_module/shared_js_module_app.py @@ -0,0 +1,12 @@ +import mesop as me +from mesop.examples.web_component.shared_js_module.web_component import ( + web_component, +) + + +@me.page( + path="/web_component/shared_js_module/shared_js_module_app", +) +def page(): + me.text("Loaded") + web_component() diff --git a/mesop/examples/web_component/shared_js_module/shared_module.js b/mesop/examples/web_component/shared_js_module/shared_module.js new file mode 100644 index 000000000..2b4fc2a4b --- /dev/null +++ b/mesop/examples/web_component/shared_js_module/shared_module.js @@ -0,0 +1 @@ +export const VALUE = 'shared_module.js'; diff --git a/mesop/examples/web_component/shared_js_module/web_component.js b/mesop/examples/web_component/shared_js_module/web_component.js new file mode 100644 index 000000000..ddd690bcb --- /dev/null +++ b/mesop/examples/web_component/shared_js_module/web_component.js @@ -0,0 +1,13 @@ +import { + LitElement, + html, +} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js'; +import {VALUE} from './shared_module.js'; + +class SharedJsModuleComponent extends LitElement { + render() { + return html`
value from shared module: ${VALUE}
`; + } +} + +customElements.define('shared-js-module-component', SharedJsModuleComponent); diff --git a/mesop/examples/web_component/shared_js_module/web_component.py b/mesop/examples/web_component/shared_js_module/web_component.py new file mode 100644 index 000000000..09a4d54b8 --- /dev/null +++ b/mesop/examples/web_component/shared_js_module/web_component.py @@ -0,0 +1,6 @@ +import mesop.labs as mel + + +@mel.web_component(path="./web_component.js") +def web_component(): + return mel.insert_web_component(name="shared-js-module-component") diff --git a/mesop/examples/web_component/slot/BUILD b/mesop/examples/web_component/slot/BUILD new file mode 100644 index 000000000..52ded591c --- /dev/null +++ b/mesop/examples/web_component/slot/BUILD @@ -0,0 +1,14 @@ +load("//build_defs:defaults.bzl", "py_library") + +package( + default_visibility = ["//build_defs:mesop_examples"], +) + +py_library( + name = "slot", + srcs = glob(["*.py"]), + data = glob(["*.js"]), + deps = [ + "//mesop", + ], +) diff --git a/mesop/examples/web_component/slot/counter_component.js b/mesop/examples/web_component/slot/counter_component.js new file mode 100644 index 000000000..fc8483a22 --- /dev/null +++ b/mesop/examples/web_component/slot/counter_component.js @@ -0,0 +1,38 @@ +import { + LitElement, + html, +} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js'; + +class CounterComponent extends LitElement { + static properties = { + value: {type: Number}, + decrementEvent: {type: String}, + }; + + constructor() { + super(); + this.value = 0; + this.decrementEvent = ''; + } + + render() { + return html` +
+ Value: ${this.value} + +
+ `; + } + + _onDecrement() { + this.dispatchEvent( + new MesopEvent(this.decrementEvent, { + value: this.value - 1, + }), + ); + } +} + +customElements.define('slot-counter-component', CounterComponent); diff --git a/mesop/examples/web_component/slot/counter_component.py b/mesop/examples/web_component/slot/counter_component.py new file mode 100644 index 000000000..00ce380d0 --- /dev/null +++ b/mesop/examples/web_component/slot/counter_component.py @@ -0,0 +1,22 @@ +from typing import Any, Callable + +import mesop.labs as mel + + +@mel.web_component(path="./counter_component.js") +def counter_component( + *, + value: int, + on_decrement: Callable[[mel.WebEvent], Any], + key: str | None = None, +): + return mel.insert_web_component( + name="slot-counter-component", + key=key, + events={ + "decrementEvent": on_decrement, + }, + properties={ + "value": value, + }, + ) diff --git a/mesop/examples/web_component/slot/outer_component.js b/mesop/examples/web_component/slot/outer_component.js new file mode 100644 index 000000000..7a91aec03 --- /dev/null +++ b/mesop/examples/web_component/slot/outer_component.js @@ -0,0 +1,42 @@ +import { + LitElement, + html, + css, +} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js'; + +class OuterComponent extends LitElement { + static properties = { + value: {type: Number}, + incrementEventHandlerId: {attribute: 'increment-event', type: String}, + }; + + constructor() { + super(); + this.value = 0; + this.incrementEventHandlerId = ''; + } + + render() { + return html` +
+ Value: ${this.value} + + Start of Slot: + + End of Slot: +
+ `; + } + + _onIncrement() { + this.dispatchEvent( + new MesopEvent(this.incrementEventHandlerId, { + value: this.value + 1, + }), + ); + } +} + +customElements.define('slot-outer-component', OuterComponent); diff --git a/mesop/examples/web_component/slot/outer_component.py b/mesop/examples/web_component/slot/outer_component.py new file mode 100644 index 000000000..9aac9dee5 --- /dev/null +++ b/mesop/examples/web_component/slot/outer_component.py @@ -0,0 +1,23 @@ +from typing import Any, Callable + +import mesop.labs as mel + + +@mel.web_component(path="./outer_component.js") +def outer_component( + *, + value: int, + on_increment: Callable[[mel.WebEvent], Any], + key: str | None = None, +): + return mel.insert_web_component( + name="slot-outer-component", + key=key, + events={ + "increment-event": on_increment, + }, + properties={ + "value": value, + "active": True, + }, + ) diff --git a/mesop/examples/web_component/slot/slot_app.py b/mesop/examples/web_component/slot/slot_app.py new file mode 100644 index 000000000..3b6eb1e6e --- /dev/null +++ b/mesop/examples/web_component/slot/slot_app.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel + +import mesop as me +import mesop.labs as mel +from mesop.examples.web_component.slot.counter_component import ( + counter_component, +) +from mesop.examples.web_component.slot.outer_component import ( + outer_component, +) + + +@me.page( + path="/web_component/slot/slot_app", +) +def page(): + with outer_component( + value=me.state(State).value, + on_increment=on_increment, + ): + with me.box(): + me.text( + "You can use built-in components inside the slot of a web component." + ) + # + me.checkbox( + # Need to set |checked| because of https://github.com/google/mesop/issues/449 + checked=me.state(State).checked, + label="Checked?", + on_change=on_checked, + ) + counter_component( + value=me.state(State).value, + on_decrement=on_decrement, + ) + me.text(f"Checked? {me.state(State).checked}") + + +def on_checked(e: me.CheckboxChangeEvent): + me.state(State).checked = e.checked + + +@me.stateclass +class State: + checked: bool + value: int = 10 + + +class ChangeValue(BaseModel): + value: int + + +def on_increment(e: mel.WebEvent): + increment = ChangeValue(**e.value) + me.state(State).value = increment.value + + +def on_decrement(e: mel.WebEvent): + decrement = ChangeValue(**e.value) + me.state(State).value = decrement.value diff --git a/mesop/labs/__init__.py b/mesop/labs/__init__.py index 3215319ee..380bd46ef 100644 --- a/mesop/labs/__init__.py +++ b/mesop/labs/__init__.py @@ -1,5 +1,12 @@ +from mesop.component_helpers.helper import ( + insert_web_component as insert_web_component, +) +from mesop.events import WebEvent as WebEvent from mesop.labs.chat import ChatMessage as ChatMessage from mesop.labs.chat import chat as chat from mesop.labs.text_to_image import text_to_image as text_to_image from mesop.labs.text_to_text import text_io as text_io from mesop.labs.text_to_text import text_to_text as text_to_text +from mesop.labs.web_component import ( + web_component as web_component, +) diff --git a/mesop/labs/web_component.py b/mesop/labs/web_component.py new file mode 100644 index 000000000..ee5b491d3 --- /dev/null +++ b/mesop/labs/web_component.py @@ -0,0 +1,55 @@ +import inspect +import os +from functools import wraps +from typing import Any, Callable, TypeVar, cast + +from mesop.runtime import runtime +from mesop.utils.validate import validate + +C = TypeVar("C", bound=Callable[..., Any]) + + +def web_component(*, path: str, skip_validation: bool = False): + """A decorator for defining a web component. + + This decorator is used to define a web component. It takes a path to the + JavaScript file of the web component and an optional parameter to skip + validation. It then registers the JavaScript file in the runtime. + + Args: + path: The path to the JavaScript file of the web component. + skip_validation: If set to True, skips validation. Defaults to False. + """ + current_frame = inspect.currentframe() + assert current_frame + previous_frame = current_frame.f_back + assert previous_frame + caller_module_file = inspect.getfile(previous_frame) + caller_module_dir = format_filename( + os.path.dirname(os.path.abspath(caller_module_file)) + ) + full_path = os.path.normpath(os.path.join(caller_module_dir, path)) + if not full_path.startswith("/"): + full_path = "/" + full_path + + runtime().register_js_module(full_path) + + def component_wrapper(fn: C) -> C: + validated_fn = fn if skip_validation else validate(fn) + + @wraps(fn) + def wrapper(*args: Any, **kw_args: Any): + return validated_fn(*args, **kw_args) + + return cast(C, wrapper) + + return component_wrapper + + +def format_filename(filename: str) -> str: + if ".runfiles" in filename: + # Handle Bazel case + return filename.split(".runfiles", 1)[1] + else: + # Handle pip CLI case + return os.path.relpath(filename, os.getcwd()) diff --git a/mesop/protos/ui.proto b/mesop/protos/ui.proto index 730be4a57..8a43180b6 100644 --- a/mesop/protos/ui.proto +++ b/mesop/protos/ui.proto @@ -296,6 +296,11 @@ message Type { optional int32 type_index = 7; } +message WebComponentType { + optional string properties_json = 1; + optional string events_json = 2; +} + // Represents user-defined components. // This is useful for editor/devtools support. message UserDefinedType { diff --git a/mesop/runtime/runtime.py b/mesop/runtime/runtime.py index 18b307bda..58f88076b 100644 --- a/mesop/runtime/runtime.py +++ b/mesop/runtime/runtime.py @@ -43,6 +43,7 @@ class Runtime: _state_classes: list[type[Any]] _loading_errors: list[pb.ServerError] component_fns: set[Callable[..., Any]] + js_modules: set[str] = set() debug_mode: bool = False # If True, then the server is still re-executing the modules # needed for hot reloading. @@ -134,6 +135,9 @@ def get_loading_errors(self) -> list[pb.ServerError]: def register_native_component_fn(self, component_fn: Callable[..., Any]): self.component_fns.add(component_fn) + def register_js_module(self, js_module: str) -> None: + self.js_modules.add(js_module) + def get_component_fns(self) -> set[Callable[..., Any]]: return self.component_fns diff --git a/mesop/security/security_policy.py b/mesop/security/security_policy.py index 6d29524f4..9c0bf68ce 100644 --- a/mesop/security/security_policy.py +++ b/mesop/security/security_policy.py @@ -3,4 +3,15 @@ @dataclass(kw_only=True) class SecurityPolicy: + """ + A class to represent the security policy. + + Attributes: + allowed_iframe_parents: A list of allowed iframe parents. + dangerously_disable_trusted_types: A flag to disable trusted types. + Highly recommended to not disable trusted types because + it's an important web security feature! + """ + allowed_iframe_parents: list[str] = field(default_factory=list) + dangerously_disable_trusted_types: bool = False diff --git a/mesop/server/static_file_serving.py b/mesop/server/static_file_serving.py index 7121b3709..ea075306d 100644 --- a/mesop/server/static_file_serving.py +++ b/mesop/server/static_file_serving.py @@ -10,8 +10,11 @@ from flask import Flask, Response, g, request, send_file from werkzeug.security import safe_join +from mesop.exceptions import MesopException from mesop.runtime import runtime -from mesop.utils.runfiles import get_runfile_location +from mesop.utils.runfiles import get_runfile_location, has_runfiles + +WEB_COMPONENTS_PATH_SEGMENT = "__web-components-module__" def noop(): @@ -40,9 +43,19 @@ def retrieve_index_html() -> io.BytesIO | str: for i, line in enumerate(lines): if "$$INSERT_CSP_NONCE$$" in line: lines[i] = lines[i].replace("$$INSERT_CSP_NONCE$$", g.csp_nonce) + if ( + runtime().js_modules + and line.strip() == "" + ): + lines[i] = "\n".join( + [ + f"" + for js_module in runtime().js_modules + ] + ) if ( livereload_script_url - and line.strip() == "" + and line.strip() == "" ): lines[i] = ( f'\n' @@ -71,6 +84,20 @@ def serve_root(): preprocess_request() return send_file(retrieve_index_html(), download_name="index.html") + @app.route(f"/{WEB_COMPONENTS_PATH_SEGMENT}/") + def serve_web_components(path: str): + if not is_file_path(path): + raise MesopException("Unexpected request to " + path) + serving_path = ( + get_runfile_location(path) + if has_runfiles() + else os.path.join(os.getcwd(), path) + ) + return send_file_compressed( + serving_path, + disable_gzip_cache=disable_gzip_cache, + ) + @app.route("/") def serve_file(path: str): preprocess_request() @@ -111,25 +138,32 @@ def add_security_headers(response: Response): # Mesop app developers should be able to load images and media from various origins. "img-src": "'self' data: https: http:", "media-src": "'self' data: https:", - "style-src": f"'self' 'nonce-{g.csp_nonce}' fonts.googleapis.com", # Need 'unsafe-inline' because we apply inline styles for our components. # This is also used by Angular for animations: # https://github.com/angular/angular/pull/55260 - "style-src-attr": "'unsafe-inline'", + # Finally, other third-party libraries like Plotly rely on setting stylesheets dynamically. + "style-src": "'self' 'unsafe-inline' fonts.googleapis.com", "script-src": f"'self' 'nonce-{g.csp_nonce}'", # https://angular.io/guide/security#enforcing-trusted-types - "trusted-types": "angular angular#unsafe-bypass", + "trusted-types": "angular angular#unsafe-bypass lit-html", "require-trusted-types-for": "'script'", } ) + security_policy = None + if page_config and page_config.security_policy: + security_policy = page_config.security_policy + if security_policy and security_policy.dangerously_disable_trusted_types: + del csp["trusted-types"] + del csp["require-trusted-types-for"] + if runtime().debug_mode: # Allow all origins in debug mode (aka editor mode) because # when Mesop is running under Colab, it will be served from # a randomly generated origin. csp["frame-ancestors"] = "*" - elif page_config and page_config.security_policy.allowed_iframe_parents: + elif security_policy and security_policy.allowed_iframe_parents: csp["frame-ancestors"] = "'self' " + " ".join( - list(page_config.security_policy.allowed_iframe_parents) + list(security_policy.allowed_iframe_parents) ) else: csp["frame-ancestors"] = default_allowed_iframe_parents diff --git a/mesop/tests/e2e/snapshots/web_security_test.ts_csp-allowed-iframe-parents.txt b/mesop/tests/e2e/snapshots/web_security_test.ts_csp-allowed-iframe-parents.txt index 8c8bae52d..e1889c019 100644 --- a/mesop/tests/e2e/snapshots/web_security_test.ts_csp-allowed-iframe-parents.txt +++ b/mesop/tests/e2e/snapshots/web_security_test.ts_csp-allowed-iframe-parents.txt @@ -3,9 +3,8 @@ font-src fonts.gstatic.com frame-src 'self' https: img-src 'self' data: https: http: media-src 'self' data: https: -style-src 'self' 'nonce-{{NONCE}}' fonts.googleapis.com -style-src-attr 'unsafe-inline' +style-src 'self' 'unsafe-inline' fonts.googleapis.com script-src 'self' 'nonce-{{NONCE}}' -trusted-types angular angular#unsafe-bypass +trusted-types angular angular#unsafe-bypass lit-html require-trusted-types-for 'script' frame-ancestors 'self' google.com \ No newline at end of file diff --git a/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt b/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt index 1717dfb69..a11b3ac23 100644 --- a/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt +++ b/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt @@ -3,9 +3,8 @@ font-src fonts.gstatic.com frame-src 'self' https: img-src 'self' data: https: http: media-src 'self' data: https: -style-src 'self' 'nonce-{{NONCE}}' fonts.googleapis.com -style-src-attr 'unsafe-inline' +style-src 'self' 'unsafe-inline' fonts.googleapis.com script-src 'self' 'nonce-{{NONCE}}' -trusted-types angular angular#unsafe-bypass +trusted-types angular angular#unsafe-bypass lit-html require-trusted-types-for 'script' frame-ancestors 'self' https://google.github.io \ No newline at end of file diff --git a/mesop/tests/e2e/web_components/quickstart_test.ts b/mesop/tests/e2e/web_components/quickstart_test.ts new file mode 100644 index 000000000..d35bff4cd --- /dev/null +++ b/mesop/tests/e2e/web_components/quickstart_test.ts @@ -0,0 +1,10 @@ +import {test, expect} from '@playwright/test'; + +test('web components - quickstart', async ({page}) => { + await page.goto('/web_component/quickstart/counter_component_app'); + expect(await page.getByText('Value: ').textContent()).toEqual('Value: 10'); + await page.getByRole('button', {name: 'Decrement'}).click(); + await page.getByText('Value: 9').textContent(); + await page.getByRole('button', {name: 'Decrement'}).click(); + await page.getByText('Value: 8').textContent(); +}); diff --git a/mesop/tests/e2e/web_components/shared_js_module_test.ts b/mesop/tests/e2e/web_components/shared_js_module_test.ts new file mode 100644 index 000000000..f52c9de60 --- /dev/null +++ b/mesop/tests/e2e/web_components/shared_js_module_test.ts @@ -0,0 +1,9 @@ +import {test, expect} from '@playwright/test'; + +test('web components - shared JS module', async ({page}) => { + await page.goto('/web_component/shared_js_module/shared_js_module_app'); + expect(page.getByText('Loaded')).toBeVisible(); + expect( + page.getByText('value from shared module: shared_module.js'), + ).toBeVisible(); +}); diff --git a/mesop/tests/e2e/web_components/slot_test.ts b/mesop/tests/e2e/web_components/slot_test.ts new file mode 100644 index 000000000..c6302d92c --- /dev/null +++ b/mesop/tests/e2e/web_components/slot_test.ts @@ -0,0 +1,35 @@ +import {test, expect, Page} from '@playwright/test'; + +test('web components - slot', async ({page}) => { + await page.goto('http://localhost:32123/web_component/slot/slot_app'); + // Make sure the page has loaded: + expect(page.getByRole('button', {name: 'Decrement'})).toBeVisible(); + + assertValue(10); + await page.getByRole('button', {name: 'increment'}).click(); + assertValue(11); + await page.getByRole('button', {name: 'increment'}).click(); + assertValue(12); + await page.getByRole('button', {name: 'Decrement'}).click(); + assertValue(11); + await page.getByRole('button', {name: 'Decrement'}).click(); + assertValue(10); + + async function assertValue(value: number) { + // Check that the outer component is displaying the right value. + expect( + await page + .locator('div') + .filter({hasText: `Value: ${value} increment Start of`}) + .textContent(), + ).toContain(value.toString()); + + // Check that the inner component is displaying the right value. + expect( + await page + .locator('slot-counter-component') + .getByText('Value:') + .textContent(), + ).toContain(value.toString()); + } +}); diff --git a/mesop/utils/runfiles.py b/mesop/utils/runfiles.py index 179f85e0f..4a141c0a6 100644 --- a/mesop/utils/runfiles.py +++ b/mesop/utils/runfiles.py @@ -3,6 +3,10 @@ from rules_python.python.runfiles import runfiles # type: ignore +def has_runfiles(): + return runfiles.Create() is not None # type: ignore + + def get_runfile_location(identifier: str) -> str: """Use this wrapper to retrieve a runfile because this util is replaced in downstream sync.""" diff --git a/mesop/web/src/app/editor/index.html b/mesop/web/src/app/editor/index.html index 09912c988..5de9c0922 100644 --- a/mesop/web/src/app/editor/index.html +++ b/mesop/web/src/app/editor/index.html @@ -19,8 +19,9 @@ Loading... - + + diff --git a/mesop/web/src/app/prod/index.html b/mesop/web/src/app/prod/index.html index f3fecce25..d3436b473 100644 --- a/mesop/web/src/app/prod/index.html +++ b/mesop/web/src/app/prod/index.html @@ -17,8 +17,9 @@ Loading... - + + diff --git a/mesop/web/src/component_renderer/component_renderer.ts b/mesop/web/src/component_renderer/component_renderer.ts index c5d5d7332..85c891bcb 100644 --- a/mesop/web/src/component_renderer/component_renderer.ts +++ b/mesop/web/src/component_renderer/component_renderer.ts @@ -9,10 +9,12 @@ import { ViewChild, ViewContainerRef, } from '@angular/core'; +import {createCustomElement} from '@angular/elements'; import {CommonModule} from '@angular/common'; import { Component as ComponentProto, UserEvent, + WebComponentType, } from 'mesop/mesop/protos/ui_jspb_proto_pb/mesop/protos/ui_pb'; import {ComponentLoader} from './component_loader'; import {BoxType} from 'mesop/mesop/components/box/box_jspb_proto_pb/mesop/components/box/box_pb'; @@ -36,8 +38,12 @@ import {formatStyle} from '../utils/styles'; import {isComponentNameEquals} from '../utils/proto'; import {MatTooltipModule} from '@angular/material/tooltip'; import {TemplatePortal} from '@angular/cdk/portal'; +import {jsonParse} from '../utils/strict_types'; +import {MESOP_EVENT_NAME, MesopEvent} from './mesop_event'; -const CORE_NAMESPACE = 'me'; +export const COMPONENT_RENDERER_ELEMENT_NAME = 'component-renderer-element'; + +const WEB_COMPONENT_PREFIX = ''; @Component({ selector: 'component-renderer', @@ -69,6 +75,7 @@ export class ComponentRenderer { isEditorMode: boolean; isEditorOverlayOpen = false; overlayRef?: OverlayRef; + customElement: HTMLElement | undefined; constructor( private channel: Channel, @@ -97,6 +104,12 @@ export class ComponentRenderer { } ngOnDestroy() { + if (this.customElement) { + this.customElement.removeEventListener( + MESOP_EVENT_NAME, + this.dispatchCustomUserEvent, + ); + } if (this.isEditorMode) { (this.elementRef.nativeElement as HTMLElement).removeEventListener( 'mouseover', @@ -143,6 +156,31 @@ export class ComponentRenderer { } ngOnChanges() { + if (this.customElement) { + // This is a naive way to apply changes by removing all the children + // and creating new children. In the future, this can be optimized + // to be more performant, but this naive approach should have the + // correct behavior, albeit inefficiently. + // See: https://github.com/google/mesop/issues/449 + + // Clear existing children + for (const element of Array.from( + this.customElement.querySelectorAll(COMPONENT_RENDERER_ELEMENT_NAME), + )) { + this.customElement.removeChild(element); + } + + // Update the custom element and its children + this.updateCustomElement(this.customElement); + for (const child of this.component.getChildrenList()) { + const childElement = document.createElement( + COMPONENT_RENDERER_ELEMENT_NAME, + ); + (childElement as any)['component'] = child; + this.customElement.appendChild(childElement); + } + return; + } if (isRegularComponent(this.component)) { this.updateComponentRef(); return; @@ -156,6 +194,42 @@ export class ComponentRenderer { this.computeStyles(); } + updateCustomElement(customElement: HTMLElement) { + const webComponentType = WebComponentType.deserializeBinary( + this.component.getType()!.getValue() as unknown as Uint8Array, + ); + const properties = jsonParse( + webComponentType.getPropertiesJson()!, + ) as object; + for (const key of Object.keys(properties)) { + const value = (properties as any)[key]; + // Explicitly don't set attribute for boolean attribute. + // If you set any value to a boolean attribute, it will be treated as enabled. + // Source: https://lit.dev/docs/components/properties/#boolean-attributes + if (value !== false) { + customElement.setAttribute(key, (properties as any)[key]); + } + } + + const events = jsonParse(webComponentType.getEventsJson()!) as object; + for (const event of Object.keys(events)) { + customElement.setAttribute(event, (events as any)[event]); + } + // Always try to remove the event listener since we will attach the event listener + // next. If the event listener wasn't already attached, then removeEventListener is + // effectively a no-op (i.e. it won't throw an error). + customElement.removeEventListener( + MESOP_EVENT_NAME, + this.dispatchCustomUserEvent, + ); + if (Object.keys(events).length) { + customElement.addEventListener( + MESOP_EVENT_NAME, + this.dispatchCustomUserEvent, + ); + } + } + ngDoCheck() { // Only need to re-compute styles in editor mode to properly // show focused component highlight. @@ -219,15 +293,47 @@ export class ComponentRenderer { const componentClass = typeName.getCoreModule() ? typeToComponent[typeName.getFnName()!] || UserDefinedComponent // Some core modules rely on UserDefinedComponent : UserDefinedComponent; - // Need to insert at insertionRef and *not* viewContainerRef, otherwise - // the component (e.g. will not be properly nested inside ). - this._componentRef = this.insertionRef.createComponent( - componentClass, // If it's an unrecognized type, we assume it's a user-defined component - options, - ); - this.updateComponentRef(); + if (typeName.getFnName()?.startsWith(WEB_COMPONENT_PREFIX)) { + const customElementName = typeName + .getFnName()! + .slice(WEB_COMPONENT_PREFIX.length); + this.customElement = document.createElement(customElementName); + this.updateCustomElement(this.customElement); + + for (const child of this.component.getChildrenList()) { + const childElement = document.createElement( + COMPONENT_RENDERER_ELEMENT_NAME, + ); + (childElement as any)['component'] = child; + this.customElement.appendChild(childElement); + } + + this.insertionRef.element.nativeElement.parentElement.appendChild( + this.customElement, + ); + } else { + // Need to insert at insertionRef and *not* viewContainerRef, otherwise + // the component (e.g. will not be properly nested inside ). + this._componentRef = this.insertionRef.createComponent( + componentClass, // If it's an unrecognized type, we assume it's a user-defined / Python custom component + options, + ); + this.updateComponentRef(); + } } + dispatchCustomUserEvent = (event: Event) => { + const mesopEvent = event as MesopEvent; + const userEvent = new UserEvent(); + // Use bracket property access to avoid renaming because MesopEvent + // is referenced by web component modules which may be compiled independently + // so property renaming is unsafe. + userEvent.setStringValue(JSON.stringify(mesopEvent['payload'])); + userEvent.setHandlerId(mesopEvent['handlerId']); + userEvent.setKey(this.component.getKey()); + this.channel.dispatch(userEvent); + }; + updateComponentRef() { if (this._componentRef) { const instance = this._componentRef.instance; diff --git a/mesop/web/src/component_renderer/mesop_event.ts b/mesop/web/src/component_renderer/mesop_event.ts new file mode 100644 index 000000000..d98655fc3 --- /dev/null +++ b/mesop/web/src/component_renderer/mesop_event.ts @@ -0,0 +1,17 @@ +export const MESOP_EVENT_NAME = 'mesop-event'; + +export class MesopEvent extends Event { + payload: T; + handlerId: string; + + constructor(handlerId: string, payload: T) { + super(MESOP_EVENT_NAME, {bubbles: true}); + this.payload = payload; + this.handlerId = handlerId; + } +} + +// Place it on the global object so that web component modules +// can consume this class without needing an explicit import +// (which would require publishing this to an npm package). +(window as any)['MesopEvent'] = MesopEvent; diff --git a/mesop/web/src/editor/editor.ts b/mesop/web/src/editor/editor.ts index a9226657a..9b48a66a4 100644 --- a/mesop/web/src/editor/editor.ts +++ b/mesop/web/src/editor/editor.ts @@ -27,11 +27,12 @@ import { HotReloadWatcher, DefaultHotReloadWatcher, } from '../services/hot_reload_watcher'; -import {Shell} from '../shell/shell'; +import {Shell, registerComponentRendererElement} from '../shell/shell'; import {EditorService, SelectionMode} from '../services/editor_service'; import {Channel} from '../services/channel'; import {isMac} from '../utils/platform'; import {CommandDialogService} from '../dev_tools/command_dialog/command_dialog_service'; +import {createCustomElement} from '@angular/elements'; // Keep the following comment to ensure there's a hook for adding TS imports in the downstream sync. // ADD_TS_IMPORT_HERE @@ -272,16 +273,20 @@ function findPath( @Component({ selector: 'mesop-editor-app', template: '', - standalone: true, - providers: [{provide: EditorService, useClass: EditorServiceImpl}], imports: [Editor, RouterOutlet], + standalone: true, }) class MesopEditorApp {} -export function bootstrapApp() { - bootstrapApplication(MesopEditorApp, { - providers: [provideAnimations(), provideRouter(routes)], +export async function bootstrapApp() { + const app = await bootstrapApplication(MesopEditorApp, { + providers: [ + provideAnimations(), + provideRouter(routes), + {provide: EditorService, useClass: EditorServiceImpl}, + ], }); + registerComponentRendererElement(app); } export const TEST_ONLY = {EditorServiceImpl}; diff --git a/mesop/web/src/shell/shell.ts b/mesop/web/src/shell/shell.ts index a64c32cea..b89e295a7 100644 --- a/mesop/web/src/shell/shell.ts +++ b/mesop/web/src/shell/shell.ts @@ -1,4 +1,5 @@ import { + ApplicationRef, Component, ErrorHandler, HostListener, @@ -18,7 +19,10 @@ import { InitRequest, } from 'mesop/mesop/protos/ui_jspb_proto_pb/mesop/protos/ui_pb'; import {CommonModule} from '@angular/common'; -import {ComponentRenderer} from '../component_renderer/component_renderer'; +import { + COMPONENT_RENDERER_ELEMENT_NAME, + ComponentRenderer, +} from '../component_renderer/component_renderer'; import {Channel} from '../services/channel'; import {provideAnimations} from '@angular/platform-browser/animations'; import {bootstrapApplication} from '@angular/platform-browser'; @@ -28,6 +32,7 @@ import {MatSidenavModule} from '@angular/material/sidenav'; import {ErrorBox} from '../error/error_box'; import {GlobalErrorHandlerService} from '../services/global_error_handler'; import {getViewportSize} from '../utils/viewport_size'; +import {createCustomElement} from '@angular/elements'; @Component({ selector: 'mesop-shell', @@ -144,14 +149,24 @@ const routes: Routes = [{path: '**', component: Shell}]; @Component({ selector: 'mesop-app', template: '', - standalone: true, imports: [Shell, RouterOutlet], - providers: [EditorService], + standalone: true, }) class MesopApp {} -export function bootstrapApp() { - bootstrapApplication(MesopApp, { - providers: [provideAnimations(), provideRouter(routes)], +export async function bootstrapApp() { + const app = await bootstrapApplication(MesopApp, { + providers: [provideAnimations(), provideRouter(routes), EditorService], + }); + registerComponentRendererElement(app); +} + +export function registerComponentRendererElement(app: ApplicationRef) { + const ComponentRendererElement = createCustomElement(ComponentRenderer, { + injector: app.injector, }); + customElements.define( + COMPONENT_RENDERER_ELEMENT_NAME, + ComponentRendererElement, + ); } diff --git a/mkdocs.yml b/mkdocs.yml index 21d2ff123..357734000 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,7 +8,6 @@ nav: - Installing: getting_started/installing.md - Quickstart: getting_started/quickstart.md - Guides: - - Components: guides/components.md - State Management: guides/state_management.md - Interactivity: guides/interactivity.md - Multi-Pages: guides/multi_pages.md @@ -17,6 +16,12 @@ nav: - Web Security: guides/web_security.md - Labs: guides/labs.md - Components: + - Types: + - Overview: components/index.md + - Web Components: + - Overview: web_components/index.md + - Quickstart: web_components/quickstart.md + - API: web_components/api.md - High-level: - Chat: components/chat.md - Text to Text: components/text_to_text.md @@ -105,7 +110,7 @@ theme: - navigation.path - navigation.instant - navigation.tracking - - navigation.expand + - navigation.prune - navigation.tabs - navigation.tabs.sticky - navigation.sections diff --git a/package.json b/package.json index 501c45b7f..95ab16e7f 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@angular/common": "^18.0.0", "@angular/compiler": "^18.0.0", "@angular/core": "^18.0.0", + "@angular/elements": "^18.0.3", "@angular/forms": "^18.0.0", "@angular/material": "^18.0.0", "@angular/material-experimental": "^18.0.0", diff --git a/playwright.config.ts b/playwright.config.ts index 7d7384e2b..007ded0e9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,7 +16,7 @@ const enableComponentTreeDiffs = process.env.ENABLE_COMPONENT_TREE_DIFFS * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - timeout: process.env.CI ? 20000 : 10000, // Budget more time for CI since tests run slower there. + timeout: process.env.CI ? 30000 : 15000, // Budget more time for CI since tests run slower there. testDir: '.', // Use a custom snapshot path template because Playwright's default // is platform-specific which isn't necessary for Mesop e2e tests @@ -24,7 +24,7 @@ export default defineConfig({ snapshotPathTemplate: '{testDir}/{testFileDir}/snapshots/{testFileName}_{arg}{ext}', - testMatch: ['e2e/*_test.ts', 'demo/screenshot.ts'], + testMatch: ['e2e/**/*_test.ts', 'demo/screenshot.ts'], testIgnore: 'scripts/**', /* Run tests in files in parallel */ fullyParallel: true, diff --git a/scripts/smoketest_app/inner_component.js b/scripts/smoketest_app/inner_component.js new file mode 100644 index 000000000..b8bb85c5d --- /dev/null +++ b/scripts/smoketest_app/inner_component.js @@ -0,0 +1,39 @@ +import { + LitElement, + html, + css, +} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js'; + +class InnerComponent extends LitElement { + static properties = { + value: {type: Number}, + decrementEventHandlerId: {attribute: 'decrement-event', type: String}, + }; + + constructor() { + super(); + this.value = 0; + this.decrementEventHandlerId = ''; + } + + render() { + return html` +
+ Value: ${this.value} + +
+ `; + } + + _onDecrement() { + this.dispatchEvent( + new MesopEvent(this.decrementEventHandlerId, { + value: this.value - 1, + }), + ); + } +} + +customElements.define('inner-component', InnerComponent); diff --git a/scripts/smoketest_app/inner_component.py b/scripts/smoketest_app/inner_component.py new file mode 100644 index 000000000..e64b62fd0 --- /dev/null +++ b/scripts/smoketest_app/inner_component.py @@ -0,0 +1,23 @@ +from typing import Any, Callable + +import mesop.labs as mel + + +@mel.web_component(path="./inner_component.js") +def inner_component( + *, + value: int, + on_decrement: Callable[[mel.WebEvent], Any], + key: str | None = None, +): + return mel.insert_web_component( + name="inner-component", + key=key, + events={ + "decrement-event": on_decrement, + }, + properties={ + "value": value, + "active": True, + }, + ) diff --git a/scripts/smoketest_app/main.py b/scripts/smoketest_app/main.py index 5b3cfdb72..3b995e448 100644 --- a/scripts/smoketest_app/main.py +++ b/scripts/smoketest_app/main.py @@ -1,4 +1,5 @@ -import mesop as me +import mesop as me # noqa: I001 +import simple_slot_app # type: ignore # noqa: F401 @me.stateclass diff --git a/scripts/smoketest_app/outer_component.js b/scripts/smoketest_app/outer_component.js new file mode 100644 index 000000000..a5121d4d2 --- /dev/null +++ b/scripts/smoketest_app/outer_component.js @@ -0,0 +1,42 @@ +import { + LitElement, + html, + css, +} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js'; + +class OuterComponent extends LitElement { + static properties = { + value: {type: Number}, + incrementEventHandlerId: {attribute: 'increment-event', type: String}, + }; + + constructor() { + super(); + this.value = 0; + this.incrementEventHandlerId = ''; + } + + render() { + return html` +
+ Value: ${this.value} + + Start of Slot: + + End of Slot: +
+ `; + } + + _onIncrement() { + this.dispatchEvent( + new MesopEvent(this.incrementEventHandlerId, { + value: this.value + 1, + }), + ); + } +} + +customElements.define('outer-component', OuterComponent); diff --git a/scripts/smoketest_app/outer_component.py b/scripts/smoketest_app/outer_component.py new file mode 100644 index 000000000..6f804ed39 --- /dev/null +++ b/scripts/smoketest_app/outer_component.py @@ -0,0 +1,23 @@ +from typing import Any, Callable + +import mesop.labs as mel + + +@mel.web_component(path="./outer_component.js") +def outer_component( + *, + value: int, + on_increment: Callable[[mel.WebEvent], Any], + key: str | None = None, +): + return mel.insert_web_component( + name="outer-component", + key=key, + events={ + "increment-event": on_increment, + }, + properties={ + "value": value, + "active": True, + }, + ) diff --git a/scripts/smoketest_app/simple_slot_app.py b/scripts/smoketest_app/simple_slot_app.py new file mode 100644 index 000000000..e6f73ab55 --- /dev/null +++ b/scripts/smoketest_app/simple_slot_app.py @@ -0,0 +1,39 @@ +from inner_component import inner_component # type: ignore +from outer_component import outer_component # type: ignore +from pydantic import BaseModel + +import mesop as me +import mesop.labs as mel + + +@me.page( + path="/simple_slot_app", +) +def page(): + with outer_component( + value=me.state(State).value, + on_increment=on_increment, + ): + inner_component( + value=me.state(State).value, + on_decrement=on_decrement, + ) + + +@me.stateclass +class State: + value: int = 10 + + +class ChangeValue(BaseModel): + value: int + + +def on_increment(e: mel.WebEvent): + increment = ChangeValue(**e.value) + me.state(State).value = increment.value + + +def on_decrement(e: mel.WebEvent): + decrement = ChangeValue(**e.value) + me.state(State).value = decrement.value diff --git a/yarn.lock b/yarn.lock index aa231043a..0d15a181b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -396,6 +396,13 @@ dependencies: tslib "^2.3.0" +"@angular/elements@^18.0.3": + version "18.0.3" + resolved "https://registry.yarnpkg.com/@angular/elements/-/elements-18.0.3.tgz#e9aa429af76be6b7e045c57d3e4362ece5b38a91" + integrity sha512-lJoQKFm1rVaYyiXUO7boT0jq2oXv3b+ighpnJcFqviQ14/pEi19LaUvGAqM3xzKwj4sTEhWlVlftIy1C9Iqvyw== + dependencies: + tslib "^2.3.0" + "@angular/forms@^18.0.0": version "18.0.0" resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-18.0.0.tgz#ca142054fea9af9d90d3c69ccd43dbe9da3a256a" @@ -15662,7 +15669,7 @@ string-argv@~0.3.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -15680,15 +15687,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -15732,7 +15730,7 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15746,13 +15744,6 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" @@ -17196,7 +17187,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -17214,15 +17205,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"