Skip to content

Add hotkeys and async action web component examples. #846

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mesop/examples/web_component/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ py_library(
data = glob(["*.js"]),
deps = [
"//mesop",
"//mesop/examples/web_component/async_action",
"//mesop/examples/web_component/code_mirror_editor",
"//mesop/examples/web_component/complex_props",
"//mesop/examples/web_component/copy_to_clipboard",
"//mesop/examples/web_component/custom_font_csp_repro",
"//mesop/examples/web_component/firebase_auth",
"//mesop/examples/web_component/hotkeys",
"//mesop/examples/web_component/markedjs",
"//mesop/examples/web_component/plotly",
"//mesop/examples/web_component/quickstart",
Expand Down
6 changes: 6 additions & 0 deletions mesop/examples/web_component/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from mesop.examples.web_component.async_action import (
async_action_app as async_action_app,
)
from mesop.examples.web_component.code_mirror_editor import (
code_mirror_editor_app as code_mirror_editor_app,
)
Expand All @@ -13,6 +16,9 @@
from mesop.examples.web_component.firebase_auth import (
firebase_auth_app as firebase_auth_app,
)
from mesop.examples.web_component.hotkeys import (
hotkeys_app as hotkeys_app,
)
from mesop.examples.web_component.markedjs import (
markedjs_app as markedjs_app,
)
Expand Down
14 changes: 14 additions & 0 deletions mesop/examples/web_component/async_action/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
load("//build_defs:defaults.bzl", "py_library")

package(
default_visibility = ["//build_defs:mesop_examples"],
)

py_library(
name = "async_action",
srcs = glob(["*.py"]),
data = glob(["*.js"]),
deps = [
"//mesop",
],
)
80 changes: 80 additions & 0 deletions mesop/examples/web_component/async_action/async_action_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import random
from dataclasses import field

import mesop as me
import mesop.labs as mel
from mesop.examples.web_component.async_action.async_action_component import (
AsyncAction,
async_action_component,
)


@me.stateclass
class State:
boxes: dict[str, list[bool | int | str]] = field(
default_factory=lambda: {
"box1": [False, 0, "red"],
"box2": [False, 0, "orange"],
"box3": [False, 0, "yellow"],
}
)
action: str
duration: int


@me.page(
path="/web_component/async_action/async_action",
security_policy=me.SecurityPolicy(
allowed_connect_srcs=["https://cdn.jsdelivr.net"],
allowed_script_srcs=["https://cdn.jsdelivr.net"],
dangerously_disable_trusted_types=True,
),
)
def page():
state = me.state(State)
action = (
AsyncAction(value=state.action, duration_seconds=state.duration)
if state.action
else None
)
async_action_component(
action=action, on_started=on_started, on_finished=on_finished
)
with me.box(
style=me.Style(
display="flex", flex_direction="column", margin=me.Margin.all(15)
)
):
for key, meta in state.boxes.items():
with me.box(style=me.Style(padding=me.Padding.all(15))):
me.button("Show " + key, type="flat", key=key, on_click=on_click)
if meta[0]:
with me.box(
style=me.Style(
background=str(meta[2]),
width=100,
height=100,
margin=me.Margin(top=15),
padding=me.Padding.all(15),
)
):
me.text(f"{meta[0]} {meta[1]}")


def on_click(e: me.ClickEvent):
state = me.state(State)
state.action = e.key
state.duration = random.randint(2, 10)
state.boxes[e.key][0] = True
state.boxes[e.key][1] = state.duration


def on_started(e: mel.WebEvent):
state = me.state(State)
state.action = ""


def on_finished(e: mel.WebEvent):
state = me.state(State)
state.action = ""
state.boxes[e.value["action"]][0] = False
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
LitElement,
html,
} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';

class AsyncAction extends LitElement {
static properties = {
startedEvent: {type: String},
finishedEvent: {type: String},
// Format: {action: String, duration_seconds: Number}
action: {type: Object},
isRunning: {type: Boolean},
};

render() {
return html`<div></div>`;
}

firstUpdated() {
if (this.action) {
this.runTimeout(this.action);
}
}

updated(changedProperties) {
if (changedProperties.has('action') && this.action) {
this.runTimeout(this.action);
}
}

runTimeout(action) {
this.dispatchEvent(
new MesopEvent(this.startedEvent, {
action: action,
}),
);
setTimeout(() => {
this.dispatchEvent(
new MesopEvent(this.finishedEvent, {
action: action.value,
}),
);
}, action.duration_seconds * 1000);
}
}

customElements.define('async-action-component', AsyncAction);
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from dataclasses import asdict, dataclass
from typing import Any, Callable

import mesop.labs as mel


@dataclass
class AsyncAction:
value: str
duration_seconds: int


@mel.web_component(path="./async_action_component.js")
def async_action_component(
*,
on_started: Callable[[mel.WebEvent], Any],
on_finished: Callable[[mel.WebEvent], Any],
action: AsyncAction | None = None,
key: str | None = None,
):
"""Creates an invisibe component that will delay state changes asynchronously.

Right now this implementation is limited since we basically just pass the key around.
But ideally we also pass in some kind of value to update when the time out expires.

The main benefit of this component is for cases, such as status messages that may
appear and disappear after some duration. The primary example here is the example
snackbar widget, which right now blocks the UI when using the sleep yield approach.

The other benefit of this component is that it works generically (rather than say
implementing a custom snackbar widget as a web component).
"""
return mel.insert_web_component(
name="async-action-component",
key=key,
events={
"startedEvent": on_started,
"finishedEvent": on_finished,
},
properties={"action": asdict(action) if action else ""},
)
14 changes: 14 additions & 0 deletions mesop/examples/web_component/hotkeys/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
load("//build_defs:defaults.bzl", "py_library")

package(
default_visibility = ["//build_defs:mesop_examples"],
)

py_library(
name = "hotkeys",
srcs = glob(["*.py"]),
data = glob(["*.js"]),
deps = [
"//mesop",
],
)
74 changes: 74 additions & 0 deletions mesop/examples/web_component/hotkeys/hotkeys_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Proof of concept of a webcomponent wrapper that adds hot key support.

This could be made more reusable if we added on_enter to the text area (but this is
more a hack since typically hot keys use enter+modifier to submit the input) With on
enter, the child text area would get refreshed less, but it would still lose focus,
which is not usable.

Other potential usages:

1. Global app hot keys
2. Close dialogs with escape
"""

import mesop as me
import mesop.labs as mel
from mesop.examples.web_component.hotkeys.hotkeys_component import (
HotKey,
hotkeys_component,
)


@me.stateclass
class State:
input: str
output: str


@me.page(
path="/web_component/hotkeys/hotkeys_app",
security_policy=me.SecurityPolicy(
allowed_connect_srcs=["https://cdn.jsdelivr.net"],
allowed_script_srcs=["https://cdn.jsdelivr.net"],
dangerously_disable_trusted_types=True,
),
)
def page():
state = me.state(State)
hotkeys = [
# Ex: Text input submit
HotKey(key="Enter", modifiers=["shift"], action="submit"),
# Ex: Custom save override
HotKey(key="s", modifiers=["meta"], action="save"),
# Ex: Could be used for closing dialogs
HotKey(key="Escape", action="close"),
]
with me.box(style=me.Style(margin=me.Margin.all(15))):
with hotkeys_component(hotkeys=hotkeys, on_hotkey_press=on_key_press):
me.textarea(
on_input=on_input, rows=5, style=me.Style(display="block", width="100%")
)
with me.box(
style=me.Style(
background="#ececec",
margin=me.Margin(top=15),
padding=me.Padding.all(10),
)
):
me.text(state.output)


def on_input(e: me.InputEvent | me.InputEnterEvent):
state = me.state(State)
state.input = e.value


def on_key_press(e: mel.WebEvent):
state = me.state(State)

if e.value["action"] == "submit":
state.output = "Pressed submit hotkey: " + state.input
elif e.value["action"] == "save":
state.output = "Pressed save hotkey."
elif e.value["action"] == "close":
state.output = "Pressed close hotkey."
74 changes: 74 additions & 0 deletions mesop/examples/web_component/hotkeys/hotkeys_component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
LitElement,
html,
} from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';

class HotKeys extends LitElement {
static properties = {
// Format: {key: String, action: String modifiers: String}
hotkeys: {type: Object},
keyPressEvent: {type: String},
triggeredAction: {type: String},
};

render() {
return html`
<div @keydown=${this._onKeyDown} @keyup=${this._onKeyUp}>
<slot></slot>
</div>
`;
}

_onKeyUp(e) {
if (!this.keyPressEvent || !this.triggeredAction) {
return;
}
this.dispatchEvent(
new MesopEvent(this.keyPressEvent, {
action: this.triggeredAction,
}),
);
// Reset the action so it won't get resent multiple times.
this.triggeredAction = '';
}

_onKeyDown(e) {
if (!this.keyPressEvent) {
return;
}

for (const hotkey of this.hotkeys) {
if (
e.key === hotkey.key &&
hotkey.modifiers.every((m) => this._isModifierPressed(e, m))
) {
// Prevent default behavior for cases where we want to override browser level
// commands, such as Cmd+S.
e.preventDefault();
// Store the action and wait for key up to send the event.
// This is mainly to make text input hot key actions more efficient. If the
// event is on key up, then the on_enter event will trigger first, allowing
// the input text to be saved. This way we can avoid using the on_input to
// to store text (though this wouldn't work for text area unfortunately). This
// is problematic due to the way web components force a full rerender of the
// child components, which makes on_input unusable in this scenario since
// it will keep refreshing the child components. It will also lose focus on the
// text area.
this.triggeredAction = hotkey.action;
}
}
}

_isModifierPressed(event, modifierString) {
const modifierMap = {
'ctrl': 'ctrlKey',
'shift': 'shiftKey',
'alt': 'altKey',
'meta': 'metaKey',
};
const modifierProperty = modifierMap[modifierString.toLowerCase()];
return modifierProperty ? event[modifierProperty] : false;
}
}

customElements.define('hotkeys-component', HotKeys);
Loading
Loading