diff --git a/demo/dialog.py b/demo/dialog.py index e683cbd6a..0a80bb0ca 100644 --- a/demo/dialog.py +++ b/demo/dialog.py @@ -1,5 +1,7 @@ """Simple dialog that looks similar to Angular Component Dialog.""" +from typing import Callable + import mesop as me @@ -17,7 +19,10 @@ class State: def app(): state = me.state(State) - with dialog(state.is_open): + with dialog( + is_open=state.is_open, + on_click_background=on_click_close_background, + ): me.text("Delete File", type="headline-5") with me.box(): me.text(text="Would you like to delete cat.jpeg?") @@ -31,6 +36,12 @@ def app(): ) +def on_click_close_background(e: me.ClickEvent): + state = me.state(State) + if e.is_target: + state.is_open = False + + def on_click_close_dialog(e: me.ClickEvent): state = me.state(State) state.is_open = False @@ -42,18 +53,15 @@ def on_click_dialog_open(e: me.ClickEvent): @me.content_component -def dialog(is_open: bool): +def dialog(*, is_open: bool, on_click_background: Callable | None = None): """Renders a dialog component. The design of the dialog borrows from the Angular component dialog. So basically rounded corners and some box shadow. - One current drawback is that it's not possible to close the dialog - by clicking on the overlay background. This is due to - https://github.com/google/mesop/issues/268. - Args: is_open: Whether the dialog is visible or not. + on_click_background: Event handler for when background is clicked """ with me.box( style=me.Style( @@ -67,14 +75,15 @@ def dialog(is_open: bool): position="fixed", width="100%", z_index=1000, - ) + ), ): with me.box( + on_click=on_click_background, style=me.Style( place_items="center", display="grid", height="100vh", - ) + ), ): with me.box( style=me.Style( diff --git a/mesop/component_helpers/helper.py b/mesop/component_helpers/helper.py index eb41a2b94..e44a33259 100644 --- a/mesop/component_helpers/helper.py +++ b/mesop/component_helpers/helper.py @@ -470,9 +470,11 @@ def register_event_mapper( ClickEvent, lambda userEvent, key: ClickEvent( key=key.key, + is_target=userEvent.click.is_target, ), ) + runtime().register_event_mapper( InputEvent, lambda userEvent, key: InputEvent( diff --git a/mesop/components/button/button.ts b/mesop/components/button/button.ts index f40977de8..90eeb4602 100644 --- a/mesop/components/button/button.ts +++ b/mesop/components/button/button.ts @@ -1,6 +1,7 @@ import {MatButtonModule} from '@angular/material/button'; import {Component, Input} from '@angular/core'; import { + ClickEvent, UserEvent, Key, Type, @@ -37,6 +38,9 @@ export class ButtonComponent { const userEvent = new UserEvent(); userEvent.setHandlerId(this.config().getOnClickHandlerId()!); userEvent.setKey(this.key); + const click = new ClickEvent(); + click.setIsTarget(event.target === event.currentTarget); + userEvent.setClick(click); this.channel.dispatch(userEvent); } diff --git a/mesop/events/events.py b/mesop/events/events.py index 0a6c946d1..34ea32218 100644 --- a/mesop/events/events.py +++ b/mesop/events/events.py @@ -13,9 +13,10 @@ class ClickEvent(MesopEvent): Attributes: key (str): key of the component that emitted this event. + is_target (bool): Whether the clicked target is the component which attached the event handler. """ - pass + is_target: bool @dataclass(kw_only=True) diff --git a/mesop/examples/testing/__init__.py b/mesop/examples/testing/__init__.py index 7fcced9cf..667b638be 100644 --- a/mesop/examples/testing/__init__.py +++ b/mesop/examples/testing/__init__.py @@ -1,3 +1,6 @@ +from mesop.examples.testing import ( + click_is_target as click_is_target, +) from mesop.examples.testing import ( complex_layout as complex_layout, ) diff --git a/mesop/examples/testing/click_is_target.py b/mesop/examples/testing/click_is_target.py new file mode 100644 index 000000000..d8b79eb15 --- /dev/null +++ b/mesop/examples/testing/click_is_target.py @@ -0,0 +1,25 @@ +import mesop as me + + +@me.stateclass +class State: + box_clicked: bool + button_clicked: bool + + +@me.page(path="/testing/click_is_target") +def page(): + state = me.state(State) + with me.box(on_click=on_click_box, style=me.Style(background="red")): + me.button("Click", on_click=on_click_button, type="flat") + me.text(f"Box clicked: {state.box_clicked}") + me.text(f"Button clicked: {state.button_clicked}") + + +def on_click_box(e: me.ClickEvent): + if e.is_target: + me.state(State).box_clicked = True + + +def on_click_button(e: me.ClickEvent): + me.state(State).button_clicked = True diff --git a/mesop/protos/ui.proto b/mesop/protos/ui.proto index ad9d866a7..b58e6c657 100644 --- a/mesop/protos/ui.proto +++ b/mesop/protos/ui.proto @@ -24,7 +24,7 @@ message QueryParam { repeated string values = 2; } -// Next ID: 16 +// Next ID: 17 message UserEvent { optional States states = 1; @@ -46,6 +46,7 @@ message UserEvent { ResizeEvent resize = 10; bytes bytes_value = 9; ChangePrefersColorScheme change_prefers_color_scheme = 14; + ClickEvent click = 16; } optional string state_token = 12; @@ -78,6 +79,11 @@ message ArgPathSegment { } } +// Click event parameters +message ClickEvent { + optional bool is_target = 1; +} + // This is a user-triggered navigation (e.g. go back/forwards) or a hot reload event. message NavigationEvent{ } diff --git a/mesop/tests/e2e/click_is_target_test.ts b/mesop/tests/e2e/click_is_target_test.ts new file mode 100644 index 000000000..ad3a13ba0 --- /dev/null +++ b/mesop/tests/e2e/click_is_target_test.ts @@ -0,0 +1,19 @@ +import {test, expect} from '@playwright/test'; + +test('is_target', async ({page}) => { + await page.goto('/testing/click_is_target'); + await expect(page.getByText('Box clicked: False')).toBeVisible(); + await expect(page.getByText('Button clicked: False')).toBeVisible(); + + await page.getByRole('button', {name: 'Click'}).click(); + await expect(page.getByText('Box clicked: False')).toBeVisible(); + await expect(page.getByText('Button clicked: True')).toBeVisible(); + + ( + await page.locator( + '//component-renderer[contains(@style, "background: red")]', + ) + ).click(); + await expect(page.getByText('Box clicked: True')).toBeVisible(); + await expect(page.getByText('Button clicked: True')).toBeVisible(); +}); diff --git a/mesop/web/src/component_renderer/component_renderer.ts b/mesop/web/src/component_renderer/component_renderer.ts index dea15c9a2..b46813660 100644 --- a/mesop/web/src/component_renderer/component_renderer.ts +++ b/mesop/web/src/component_renderer/component_renderer.ts @@ -11,6 +11,7 @@ import { } from '@angular/core'; import {CommonModule} from '@angular/common'; import { + ClickEvent, Component as ComponentProto, UserEvent, WebComponentType, @@ -414,6 +415,9 @@ Make sure the web component name is spelled the same between Python and JavaScri const userEvent = new UserEvent(); userEvent.setHandlerId(this._boxType.getOnClickHandlerId()!); userEvent.setKey(this.component.getKey()); + const click = new ClickEvent(); + click.setIsTarget(event.target === event.currentTarget); + userEvent.setClick(click); this.channel.dispatch(userEvent); }